Compare commits

..

9 Commits

49 changed files with 3861 additions and 162 deletions

254
Cargo.lock generated
View File

@ -132,6 +132,45 @@ dependencies = [
"rustversion",
]
[[package]]
name = "asn1-rs"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom 7.1.3",
"num-traits",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@ -203,7 +242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"base64",
"base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@ -251,12 +290,29 @@ dependencies = [
"tracing",
]
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64urlsafedata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c"
dependencies = [
"base64 0.21.7",
"pastey",
"serde",
]
[[package]]
name = "bit-set"
version = "0.5.3"
@ -810,6 +866,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@ -909,6 +971,20 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
[[package]]
name = "der-parser"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"asn1-rs",
"displaydoc",
"nom 7.1.3",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.5.8"
@ -1460,6 +1536,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@ -1646,7 +1733,7 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
@ -2096,7 +2183,7 @@ name = "llm-engine"
version = "0.2.1"
dependencies = [
"async-trait",
"base64",
"base64 0.22.1",
"chrono",
"clap",
"dotenv",
@ -2479,6 +2566,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.1"
@ -2496,6 +2593,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@ -2542,6 +2648,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "oid-registry"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
dependencies = [
"asn1-rs",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@ -2630,6 +2745,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pdf-extract"
version = "0.10.0"
@ -3258,7 +3379,7 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
@ -3346,6 +3467,15 @@ dependencies = [
"semver",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom 7.1.3",
]
[[package]]
name = "rustix"
version = "0.38.44"
@ -3562,6 +3692,16 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde_cbor_2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c"
dependencies = [
"half",
"serde",
]
[[package]]
name = "serde_core"
version = "1.0.228"
@ -4061,7 +4201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
dependencies = [
"anyhow",
"base64",
"base64 0.22.1",
"bitflags 2.11.0",
"fancy-regex",
"filedescriptor",
@ -4171,12 +4311,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
@ -4185,6 +4327,16 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.3"
@ -4524,7 +4676,7 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
name = "tui"
version = "0.1.0"
dependencies = [
"base64",
"base64 0.22.1",
"client",
"crossterm 0.28.1",
"fs4",
@ -4686,6 +4838,7 @@ dependencies = [
"idna",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]
@ -5211,6 +5364,74 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webauthn-attestation-ca"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e"
dependencies = [
"base64urlsafedata",
"openssl",
"openssl-sys",
"serde",
"tracing",
"uuid",
]
[[package]]
name = "webauthn-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422"
dependencies = [
"base64urlsafedata",
"serde",
"tracing",
"url",
"uuid",
"webauthn-rs-core",
]
[[package]]
name = "webauthn-rs-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a"
dependencies = [
"base64 0.21.7",
"base64urlsafedata",
"der-parser",
"hex",
"nom 7.1.3",
"openssl",
"openssl-sys",
"rand 0.9.4",
"rand_chacha 0.9.0",
"serde",
"serde_cbor_2",
"serde_json",
"thiserror 1.0.69",
"tracing",
"url",
"uuid",
"webauthn-attestation-ca",
"webauthn-rs-proto",
"x509-parser",
]
[[package]]
name = "webauthn-rs-proto"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e"
dependencies = [
"base64 0.21.7",
"base64urlsafedata",
"serde",
"serde_json",
"url",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.5"
@ -5577,7 +5798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [
"assert-json-diff",
"base64",
"base64 0.22.1",
"deadpool",
"futures",
"http",
@ -5777,6 +5998,23 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "x509-parser"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom 7.1.3",
"oid-registry",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "xattr"
version = "1.6.1"
@ -5868,7 +6106,9 @@ dependencies = [
"toml",
"tower",
"tracing",
"url",
"uuid",
"webauthn-rs",
"worker",
"worker-runtime",
]

View File

@ -99,4 +99,6 @@ tokio-tungstenite = "0.29"
tower = "0.5"
toml = "1.1"
tracing = "0.1"
url = "2.5"
uuid = "1.23"
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }

View File

@ -0,0 +1,161 @@
use serde::{Deserialize, Serialize};
use std::fmt;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendAuthTarget {
pub base_url: String,
}
impl BackendAuthTarget {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
}
}
fn api_url(&self, path: &str) -> String {
format!(
"{}{}",
self.base_url.trim_end_matches('/'),
path.strip_prefix('/')
.map(|path| format!("/{path}"))
.unwrap_or_else(|| path.to_string())
)
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct DeviceLoginStartResponse {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub verification_uri_complete: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct DeviceLoginPollResponse {
pub status: String,
pub access_token: Option<String>,
pub token_type: Option<String>,
}
#[derive(Debug)]
pub enum BackendAuthClientError {
Http(reqwest::Error),
BackendStatus { status: u16, body: String },
MissingAccessToken,
}
impl fmt::Display for BackendAuthClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Http(error) => write!(f, "Backend auth request failed: {error}"),
Self::BackendStatus { status, body } => {
write!(f, "Backend auth returned HTTP {status}: {body}")
}
Self::MissingAccessToken => {
f.write_str("Backend approved device login without an access token")
}
}
}
}
impl std::error::Error for BackendAuthClientError {}
impl From<reqwest::Error> for BackendAuthClientError {
fn from(value: reqwest::Error) -> Self {
Self::Http(value)
}
}
#[derive(Debug, Serialize)]
struct DeviceLoginStartRequest<'a> {
client_name: Option<&'a str>,
}
#[derive(Debug, Serialize)]
struct DeviceLoginPollRequest<'a> {
device_code: &'a str,
}
pub async fn start_device_login(
target: &BackendAuthTarget,
client_name: Option<&str>,
) -> Result<DeviceLoginStartResponse, BackendAuthClientError> {
let client = reqwest::Client::new();
let response = client
.post(target.api_url("/api/auth/device-login/start"))
.json(&DeviceLoginStartRequest { client_name })
.send()
.await?;
parse_json_response(response).await
}
pub async fn poll_device_login(
target: &BackendAuthTarget,
device_code: &str,
) -> Result<DeviceLoginPollResponse, BackendAuthClientError> {
let client = reqwest::Client::new();
let response = client
.post(target.api_url("/api/auth/device-login/poll"))
.json(&DeviceLoginPollRequest { device_code })
.send()
.await?;
parse_json_response(response).await
}
pub async fn wait_for_device_login(
target: &BackendAuthTarget,
device_code: &str,
interval: Duration,
expires_in: Duration,
) -> Result<String, BackendAuthClientError> {
let started = std::time::Instant::now();
loop {
let response = poll_device_login(target, device_code).await?;
match response.status.as_str() {
"approved" => {
return response
.access_token
.ok_or(BackendAuthClientError::MissingAccessToken);
}
"expired" => {
return Err(BackendAuthClientError::BackendStatus {
status: 410,
body: "device login expired".to_string(),
});
}
"consumed" => {
return Err(BackendAuthClientError::BackendStatus {
status: 409,
body: "device login was already consumed".to_string(),
});
}
_ => {}
}
if started.elapsed() >= expires_in {
return Err(BackendAuthClientError::BackendStatus {
status: 408,
body: "timed out waiting for device login approval".to_string(),
});
}
tokio::time::sleep(interval).await;
}
}
async fn parse_json_response<T: for<'de> Deserialize<'de>>(
response: reqwest::Response,
) -> Result<T, BackendAuthClientError> {
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(BackendAuthClientError::BackendStatus {
status: status.as_u16(),
body,
});
}
Ok(response.json::<T>().await?)
}

View File

@ -8,12 +8,17 @@
//!
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
pub mod backend_auth;
pub mod backend_runtime;
pub mod runtime_command;
pub mod spawn;
pub mod ticket_role;
mod worker_client;
pub use backend_auth::{
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
poll_device_login, start_device_login, wait_for_device_login,
};
pub use backend_runtime::{
BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse,
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,

View File

@ -28,7 +28,9 @@ worker.workspace = true
worker-runtime = { workspace = true, features = ["ws-server", "fs-store"] }
toml.workspace = true
tracing.workspace = true
url.workspace = true
uuid = { workspace = true, features = ["v7"] }
webauthn-rs = { workspace = true }
[dev-dependencies]
tempfile.workspace = true

View File

@ -0,0 +1,189 @@
use axum::http::{HeaderMap, header};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::{Error, Result, store::ControlPlaneStore};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuthPublicConfig {
pub rp_id: String,
pub origin: String,
pub public_base_url: String,
pub cookie_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RequestActor {
pub user_id: String,
pub account_id: String,
pub handle: String,
pub display_name: String,
pub auth_method: ActorAuthMethod,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ActorAuthMethod {
BrowserSession,
ApiToken,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuthenticatedUser {
pub user_id: String,
pub account_id: String,
pub handle: String,
pub display_name: String,
}
impl RequestActor {
pub fn user(&self) -> AuthenticatedUser {
AuthenticatedUser {
user_id: self.user_id.clone(),
account_id: self.account_id.clone(),
handle: self.handle.clone(),
display_name: self.display_name.clone(),
}
}
}
pub fn normalize_handle(handle: &str) -> Result<String> {
let normalized = handle.trim().to_ascii_lowercase();
let valid = !normalized.is_empty()
&& normalized.len() <= 64
&& normalized
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_');
if !valid {
return Err(auth_error(
"invalid_handle",
"handle must be 1-64 chars of ascii lowercase letters, digits, '-' or '_'",
));
}
Ok(normalized)
}
pub fn now_rfc3339() -> String {
Utc::now().to_rfc3339()
}
pub fn rfc3339_after(duration: Duration) -> String {
(Utc::now() + duration).to_rfc3339()
}
pub fn is_expired(expires_at: &str) -> bool {
match DateTime::parse_from_rfc3339(expires_at) {
Ok(expires_at) => expires_at.with_timezone(&Utc) <= Utc::now(),
Err(_) => true,
}
}
pub fn new_id(prefix: &str) -> String {
format!("{prefix}-{}", Uuid::now_v7())
}
pub fn new_challenge() -> String {
format!("chal-{}-{}", Uuid::now_v7(), Uuid::now_v7())
}
pub fn mint_secret(prefix: &str) -> String {
format!("{prefix}_{}_{}", Uuid::now_v7(), Uuid::now_v7())
}
pub fn token_hash(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let digest = hasher.finalize();
let mut encoded = String::with_capacity(digest.len() * 2 + "sha256:".len());
encoded.push_str("sha256:");
for byte in digest {
encoded.push_str(&format!("{byte:02x}"));
}
encoded
}
pub fn new_user_code() -> String {
let hex = Uuid::now_v7().simple().to_string().to_ascii_uppercase();
format!("{}-{}", &hex[0..4], &hex[4..8])
}
pub fn parse_bearer(headers: &HeaderMap) -> Option<String> {
headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option<String> {
let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?;
cookie_header.split(';').find_map(|pair| {
let (name, value) = pair.trim().split_once('=')?;
(name == cookie_name && !value.is_empty()).then(|| value.to_string())
})
}
pub async fn resolve_request_actor<S: ControlPlaneStore + ?Sized>(
store: &S,
headers: &HeaderMap,
cookie_name: &str,
) -> Result<Option<RequestActor>> {
if let Some(token) = parse_bearer(headers) {
let token_hash = token_hash(&token);
if let Some(api_token) = store.resolve_api_token(&token_hash)? {
if api_token
.expires_at
.as_deref()
.is_some_and(|expires_at| is_expired(expires_at))
{
return Ok(None);
}
store.mark_api_token_used(&token_hash, &now_rfc3339())?;
return actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken);
}
}
if let Some(session_token) = parse_cookie(headers, cookie_name) {
if let Some(session) = store.resolve_browser_session(&token_hash(&session_token))? {
if is_expired(&session.expires_at) {
return Ok(None);
}
return actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession);
}
}
Ok(None)
}
fn actor_for_user<S: ControlPlaneStore + ?Sized>(
store: &S,
user_id: &str,
auth_method: ActorAuthMethod,
) -> Result<Option<RequestActor>> {
let Some(user) = store.get_user(user_id)? else {
return Ok(None);
};
Ok(Some(RequestActor {
user_id: user.user_id,
account_id: user.account_id,
handle: user.handle,
display_name: user.display_name,
auth_method,
}))
}
pub fn session_set_cookie(cookie_name: &str, token: &str, max_age_seconds: i64) -> String {
format!("{cookie_name}={token}; Max-Age={max_age_seconds}; Path=/; HttpOnly; SameSite=Lax")
}
pub fn auth_error(code: &str, message: &str) -> Error {
Error::RuntimeOperationFailed {
runtime_id: "workspace-auth".to_string(),
code: code.to_string(),
message: message.to_string(),
}
}

View File

@ -15,8 +15,27 @@ pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str =
include_str!("../../../resources/workspace-backend.default.toml");
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173";
const DEFAULT_AUTH_PUBLIC_BASE_URL: &str = "http://localhost:8787";
const DEFAULT_AUTH_RP_ID: &str = "localhost";
const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session";
const DEFAULT_MAX_RECORDS: usize = 200;
fn default_auth_rp_id() -> String {
DEFAULT_AUTH_RP_ID.to_string()
}
fn default_auth_origin() -> String {
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
}
fn default_auth_public_base_url() -> String {
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
}
fn default_auth_cookie_name() -> String {
DEFAULT_AUTH_COOKIE_NAME.to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendConfigFile {
@ -27,6 +46,8 @@ pub struct WorkspaceBackendConfigFile {
#[serde(default)]
pub limits: WorkspaceBackendLimitsConfig,
#[serde(default)]
pub auth: WorkspaceBackendAuthConfig,
#[serde(default)]
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
#[serde(default)]
pub runtimes: WorkspaceBackendRuntimesConfig,
@ -61,6 +82,30 @@ pub struct WorkspaceBackendLimitsConfig {
pub max_records: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendAuthConfig {
#[serde(default = "default_auth_rp_id")]
pub rp_id: String,
#[serde(default = "default_auth_origin")]
pub origin: String,
#[serde(default = "default_auth_public_base_url")]
pub public_base_url: String,
#[serde(default = "default_auth_cookie_name")]
pub cookie_name: String,
}
impl Default for WorkspaceBackendAuthConfig {
fn default() -> Self {
Self {
rp_id: default_auth_rp_id(),
origin: default_auth_origin(),
public_base_url: default_auth_public_base_url(),
cookie_name: default_auth_cookie_name(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceRepositoryConfigFile {
@ -290,8 +335,11 @@ impl WorkspaceBackendConfigFile {
.iter()
.map(resolve_remote_runtime)
.collect::<Result<Vec<_>>>()?;
server.auth = AuthConfig::LocalDevToken {
token_configured: false,
server.auth = AuthConfig::Passkey {
rp_id: self.auth.rp_id.trim().to_string(),
origin: self.auth.origin.trim().to_string(),
public_base_url: self.auth.public_base_url.trim().to_string(),
cookie_name: self.auth.cookie_name.trim().to_string(),
};
Ok(ResolvedWorkspaceBackendConfig {

View File

@ -4,6 +4,7 @@
//! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files
//! remain the canonical project records and are read through bounded bridge APIs.
pub mod auth;
pub mod companion;
pub mod config;
pub mod hosts;

View File

@ -5,12 +5,12 @@ use std::sync::atomic::{AtomicU64, Ordering};
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Path as AxumPath, Query, State};
use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION};
use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION, SET_COOKIE};
use axum::http::{HeaderMap, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post, put};
use axum::{Json, Router};
use chrono::{SecondsFormat, Utc};
use chrono::{Duration, SecondsFormat, Utc};
use futures::{SinkExt, StreamExt};
use memory::backend::{
MemoryBackendHttpResponse, MemoryBackendOperation, execute_memory_backend_operation,
@ -26,9 +26,21 @@ use tokio::net::TcpListener;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use url::Url;
use uuid::Uuid;
use webauthn_rs::prelude::{
CreationChallengeResponse, Passkey, PasskeyAuthentication, PasskeyRegistration,
PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse, Webauthn,
WebauthnBuilder,
};
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
use crate::auth::{
AuthPublicConfig, AuthenticatedUser, RequestActor, auth_error, is_expired, mint_secret, new_id,
new_user_code, normalize_handle, parse_cookie, resolve_request_actor, rfc3339_after,
session_set_cookie, token_hash,
};
use crate::companion::{
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
CompanionStatusResponse, CompanionTranscriptProjection,
@ -64,8 +76,9 @@ use crate::repositories::{
use crate::resource_broker::BackendResourceBroker;
use crate::skills;
use crate::store::{
ControlPlaneStore, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord,
WorkspaceRecord,
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
DeviceLoginFlowRecord, PasskeyCredentialRecord, UserRecord, WorkdirRegistryRecord,
WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
};
use crate::{Error, Result};
use worker_runtime::catalog::{
@ -91,9 +104,14 @@ fn embedded_runtime_id() -> String {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum AuthConfig {
/// Local/dev-only mode. If a token is configured by a future entrypoint, it
/// is a development guard only and not a production SaaS auth model.
LocalDevToken { token_configured: bool },
/// Browser human auth uses Passkey ceremonies and HttpOnly cookie sessions;
/// CLI/TUI auth uses API tokens obtained through the device login flow.
Passkey {
rp_id: String,
origin: String,
public_base_url: String,
cookie_name: String,
},
}
#[derive(Clone)]
@ -126,8 +144,11 @@ impl ServerConfig {
frontend_url: "http://127.0.0.1:5173".to_string(),
embedded_runtime_store_root,
static_assets_dir: None,
auth: AuthConfig::LocalDevToken {
token_configured: false,
auth: AuthConfig::Passkey {
rp_id: "localhost".to_string(),
origin: "http://localhost:8787".to_string(),
public_base_url: "http://localhost:8787".to_string(),
cookie_name: "yoi_workspace_session".to_string(),
},
max_records: 200,
repositories: Vec::new(),
@ -233,6 +254,7 @@ impl WorkspaceApi {
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: config.workspace_id.clone(),
owner_account_id: None,
display_name: config.workspace_display_name.clone(),
state: "active".to_string(),
created_at: config.workspace_created_at.clone(),
@ -297,6 +319,29 @@ impl WorkspaceApi {
pub fn build_router(api: WorkspaceApi) -> Router {
Router::new()
.route("/api/auth/config", get(get_auth_config))
.route("/api/auth/bootstrap-user", post(post_auth_bootstrap_user))
.route(
"/api/auth/passkeys/registration/options",
post(post_passkey_registration_options),
)
.route(
"/api/auth/passkeys/registration/complete",
post(post_passkey_registration_complete),
)
.route(
"/api/auth/passkeys/login/options",
post(post_passkey_login_options),
)
.route(
"/api/auth/passkeys/login/complete",
post(post_passkey_login_complete),
)
.route("/api/auth/logout", post(post_auth_logout))
.route("/api/auth/device-login/start", post(post_device_login_start))
.route("/api/auth/device-login/approve", post(post_device_login_approve))
.route("/api/auth/device-login/poll", post(post_device_login_poll))
.route("/api/auth/whoami", get(get_auth_whoami))
.route("/api/workspace", get(get_workspace))
.route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace))
.route(
@ -2443,6 +2488,690 @@ async fn scoped_list_host_workers(
list_host_workers(State(api), AxumPath(path.host_id)).await
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct AuthBootstrapUserRequest {
handle: String,
#[serde(default)]
display_name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct AuthUserResponse {
user: AuthenticatedUser,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PasskeyRegistrationOptionsRequest {
handle: String,
#[serde(default)]
display_name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct PasskeyRegistrationOptionsResponse {
challenge_id: String,
public_key: CreationChallengeResponse,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PasskeyRegistrationCompleteRequest {
challenge_id: String,
credential: RegisterPublicKeyCredential,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PasskeyLoginOptionsRequest {
#[serde(default)]
handle: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct PasskeyLoginOptionsResponse {
challenge_id: String,
public_key: RequestChallengeResponse,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PasskeyLoginCompleteRequest {
challenge_id: String,
credential: PublicKeyCredential,
}
#[derive(Debug, Serialize, Deserialize)]
struct PasskeyLoginCompleteResponse {
user: AuthenticatedUser,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct DeviceLoginStartRequest {
#[serde(default)]
client_name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct DeviceLoginStartResponse {
device_code: String,
user_code: String,
verification_uri: String,
verification_uri_complete: String,
expires_in: u64,
interval: u64,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct DeviceLoginApproveRequest {
user_code: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct DeviceLoginApproveResponse {
status: String,
user: AuthenticatedUser,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct DeviceLoginPollRequest {
device_code: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct DeviceLoginPollResponse {
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
access_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
token_type: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct WhoamiResponse {
actor: Option<RequestActor>,
}
#[derive(Debug, Serialize, Deserialize)]
struct LogoutResponse {
status: String,
}
async fn get_auth_config(State(api): State<WorkspaceApi>) -> ApiResult<Json<AuthPublicConfig>> {
Ok(Json(auth_public_config(&api.config)))
}
async fn post_auth_bootstrap_user(
State(api): State<WorkspaceApi>,
Json(request): Json<AuthBootstrapUserRequest>,
) -> ApiResult<Json<AuthUserResponse>> {
let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?;
Ok(Json(AuthUserResponse { user }))
}
async fn post_passkey_registration_options(
State(api): State<WorkspaceApi>,
Json(request): Json<PasskeyRegistrationOptionsRequest>,
) -> ApiResult<Json<PasskeyRegistrationOptionsResponse>> {
let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?;
let webauthn = webauthn(&api.config)?;
let exclude_credentials = passkeys_for_user(&api, &user.user_id)?
.into_iter()
.map(|passkey| passkey.cred_id().clone())
.collect();
let user_unique_id = Uuid::now_v7();
let (public_key, state) = webauthn
.start_passkey_registration(
user_unique_id,
&user.handle,
&user.display_name,
Some(exclude_credentials),
)
.map_err(|error| auth_error("webauthn_registration_options_failed", &error.to_string()))?;
let challenge_id = new_id("webauthn-registration");
let auth = auth_public_config(&api.config);
api.store.put_auth_challenge(&AuthChallengeRecord {
challenge_id: challenge_id.clone(),
ceremony: "passkey_registration".to_string(),
challenge: challenge_id.clone(),
user_id: Some(user.user_id),
rp_id: auth.rp_id,
origin: auth.origin,
state_json: Some(
serde_json::to_string(&state).map_err(|error| {
auth_error("webauthn_state_serialize_failed", &error.to_string())
})?,
),
expires_at: rfc3339_after(Duration::minutes(5)),
created_at: crate::auth::now_rfc3339(),
consumed_at: None,
})?;
Ok(Json(PasskeyRegistrationOptionsResponse {
challenge_id,
public_key,
}))
}
async fn post_passkey_registration_complete(
State(api): State<WorkspaceApi>,
Json(request): Json<PasskeyRegistrationCompleteRequest>,
) -> ApiResult<Json<AuthUserResponse>> {
let challenge = api
.store
.consume_auth_challenge_by_id(
&request.challenge_id,
"passkey_registration",
&crate::auth::now_rfc3339(),
)?
.ok_or_else(|| {
auth_error(
"invalid_passkey_challenge",
"passkey registration challenge is invalid or already consumed",
)
})?;
if is_expired(&challenge.expires_at) {
return Err(auth_error(
"expired_passkey_challenge",
"passkey registration challenge expired",
)
.into());
}
let user_id = challenge.user_id.ok_or_else(|| {
auth_error(
"invalid_passkey_challenge",
"passkey registration challenge is not bound to a user",
)
})?;
let user = api.store.get_user(&user_id)?.ok_or_else(|| {
auth_error(
"unknown_auth_user",
"passkey registration user does not exist",
)
})?;
let state_json = challenge.state_json.ok_or_else(|| {
auth_error(
"missing_webauthn_state",
"passkey registration state was not persisted",
)
})?;
let state: PasskeyRegistration = serde_json::from_str(&state_json)
.map_err(|error| auth_error("webauthn_state_deserialize_failed", &error.to_string()))?;
let webauthn = webauthn(&api.config)?;
let passkey = webauthn
.finish_passkey_registration(&request.credential, &state)
.map_err(|error| {
auth_error(
"webauthn_registration_verification_failed",
&error.to_string(),
)
})?;
let credential_id = passkey_credential_id(&passkey)?;
api.store
.upsert_passkey_credential(&PasskeyCredentialRecord {
credential_id,
user_id: user.user_id.clone(),
public_key_cose: serde_json::to_string(&passkey).map_err(|error| {
auth_error("webauthn_passkey_serialize_failed", &error.to_string())
})?,
transports_json: None,
sign_count: 0,
created_at: crate::auth::now_rfc3339(),
last_used_at: None,
})?;
Ok(Json(AuthUserResponse {
user: user_response(user),
}))
}
async fn post_passkey_login_options(
State(api): State<WorkspaceApi>,
Json(request): Json<PasskeyLoginOptionsRequest>,
) -> ApiResult<Json<PasskeyLoginOptionsResponse>> {
let user = match request.handle.as_deref() {
Some(handle) => api.store.get_user_by_handle(&normalize_handle(handle)?)?,
None => api.store.any_user()?,
}
.ok_or_else(|| auth_error("unknown_auth_user", "no matching user account exists"))?;
let passkeys = passkeys_for_user(&api, &user.user_id)?;
if passkeys.is_empty() {
return Err(auth_error(
"passkey_not_registered",
"user has no registered passkey credentials",
)
.into());
}
let webauthn = webauthn(&api.config)?;
let (public_key, state) = webauthn
.start_passkey_authentication(&passkeys)
.map_err(|error| auth_error("webauthn_login_options_failed", &error.to_string()))?;
let challenge_id = new_id("webauthn-login");
let auth = auth_public_config(&api.config);
api.store.put_auth_challenge(&AuthChallengeRecord {
challenge_id: challenge_id.clone(),
ceremony: "passkey_login".to_string(),
challenge: challenge_id.clone(),
user_id: Some(user.user_id),
rp_id: auth.rp_id,
origin: auth.origin,
state_json: Some(
serde_json::to_string(&state).map_err(|error| {
auth_error("webauthn_state_serialize_failed", &error.to_string())
})?,
),
expires_at: rfc3339_after(Duration::minutes(5)),
created_at: crate::auth::now_rfc3339(),
consumed_at: None,
})?;
Ok(Json(PasskeyLoginOptionsResponse {
challenge_id,
public_key,
}))
}
async fn post_passkey_login_complete(
State(api): State<WorkspaceApi>,
Json(request): Json<PasskeyLoginCompleteRequest>,
) -> ApiResult<Response> {
let challenge = api
.store
.consume_auth_challenge_by_id(
&request.challenge_id,
"passkey_login",
&crate::auth::now_rfc3339(),
)?
.ok_or_else(|| {
auth_error(
"invalid_passkey_challenge",
"passkey login challenge is invalid or already consumed",
)
})?;
if is_expired(&challenge.expires_at) {
return Err(auth_error(
"expired_passkey_challenge",
"passkey login challenge expired",
)
.into());
}
let user_id = challenge.user_id.ok_or_else(|| {
auth_error(
"invalid_passkey_challenge",
"passkey login challenge is not bound to a user",
)
})?;
let user = api
.store
.get_user(&user_id)?
.ok_or_else(|| auth_error("unknown_auth_user", "passkey user does not exist"))?;
let state_json = challenge.state_json.ok_or_else(|| {
auth_error(
"missing_webauthn_state",
"passkey login state was not persisted",
)
})?;
let state: PasskeyAuthentication = serde_json::from_str(&state_json)
.map_err(|error| auth_error("webauthn_state_deserialize_failed", &error.to_string()))?;
let webauthn = webauthn(&api.config)?;
let auth_result = webauthn
.finish_passkey_authentication(&request.credential, &state)
.map_err(|error| auth_error("webauthn_login_verification_failed", &error.to_string()))?;
let credential_id = serde_json::to_value(auth_result.cred_id())
.ok()
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.ok_or_else(|| {
auth_error(
"invalid_webauthn_credential_id",
"verified credential id was not serializable",
)
})?;
let stored = api
.store
.get_passkey_credential(&credential_id)?
.ok_or_else(|| {
auth_error(
"unknown_passkey_credential",
"verified passkey credential is not registered",
)
})?;
if stored.user_id != user.user_id {
return Err(auth_error(
"passkey_user_mismatch",
"verified passkey credential does not belong to the challenged user",
)
.into());
}
api.store
.upsert_passkey_credential(&PasskeyCredentialRecord {
credential_id,
user_id: user.user_id.clone(),
public_key_cose: stored.public_key_cose,
transports_json: stored.transports_json,
sign_count: u64::from(auth_result.counter()),
created_at: stored.created_at,
last_used_at: Some(crate::auth::now_rfc3339()),
})?;
let session_token = mint_secret("yoi_sess");
api.store.create_browser_session(&BrowserSessionRecord {
session_id: new_id("session"),
token_hash: token_hash(&session_token),
user_id: user.user_id.clone(),
created_at: crate::auth::now_rfc3339(),
expires_at: rfc3339_after(Duration::days(14)),
revoked_at: None,
})?;
let cookie_name = auth_public_config(&api.config).cookie_name;
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
session_set_cookie(&cookie_name, &session_token, 14 * 24 * 60 * 60)
.parse()
.map_err(|error| {
auth_error(
"invalid_session_cookie",
&format!("failed to build session cookie: {error}"),
)
})?,
);
Ok((
headers,
Json(PasskeyLoginCompleteResponse {
user: user_response(user),
}),
)
.into_response())
}
async fn post_device_login_start(
State(api): State<WorkspaceApi>,
Json(request): Json<DeviceLoginStartRequest>,
) -> ApiResult<Json<DeviceLoginStartResponse>> {
let auth = auth_public_config(&api.config);
let device_code = mint_secret("yoi_device");
let user_code = new_user_code();
let verification_uri = format!(
"{}/login/device",
auth.public_base_url.trim_end_matches('/')
);
let verification_uri_complete = format!("{verification_uri}?user_code={user_code}");
api.store.create_device_login_flow(&DeviceLoginFlowRecord {
device_code: device_code.clone(),
user_code: user_code.clone(),
verification_uri: verification_uri.clone(),
client_name: request.client_name,
user_id: None,
api_token_id: None,
issued_access_token: None,
created_at: crate::auth::now_rfc3339(),
expires_at: rfc3339_after(Duration::minutes(10)),
approved_at: None,
consumed_at: None,
})?;
Ok(Json(DeviceLoginStartResponse {
device_code,
user_code,
verification_uri,
verification_uri_complete,
expires_in: 600,
interval: 5,
}))
}
async fn post_device_login_approve(
State(api): State<WorkspaceApi>,
headers: HeaderMap,
Json(request): Json<DeviceLoginApproveRequest>,
) -> ApiResult<Json<DeviceLoginApproveResponse>> {
let actor = require_actor(&api, &headers).await?;
let flow = api
.store
.get_device_login_flow_by_user_code(&request.user_code.trim().to_ascii_uppercase())?
.ok_or_else(|| {
auth_error(
"unknown_device_login_code",
"device login code does not exist",
)
})?;
if is_expired(&flow.expires_at) {
return Err(auth_error("expired_device_login", "device login code expired").into());
}
if flow.approved_at.is_some() || flow.consumed_at.is_some() {
return Err(auth_error(
"device_login_already_used",
"device login code is already used",
)
.into());
}
let access_token = mint_secret("yoi_api");
let token_id = new_id("api-token");
api.store.create_api_token(&ApiTokenRecord {
token_id: token_id.clone(),
token_hash: token_hash(&access_token),
user_id: actor.user_id.clone(),
label: flow
.client_name
.clone()
.unwrap_or_else(|| "yoi device login".to_string()),
created_at: crate::auth::now_rfc3339(),
expires_at: None,
revoked_at: None,
last_used_at: None,
})?;
let approved = api.store.approve_device_login_flow(
&flow.device_code,
&actor.user_id,
&token_id,
&access_token,
&crate::auth::now_rfc3339(),
)?;
if !approved {
return Err(auth_error(
"device_login_already_used",
"device login code is already used",
)
.into());
}
Ok(Json(DeviceLoginApproveResponse {
status: "approved".to_string(),
user: actor.user(),
}))
}
async fn post_device_login_poll(
State(api): State<WorkspaceApi>,
Json(request): Json<DeviceLoginPollRequest>,
) -> ApiResult<Json<DeviceLoginPollResponse>> {
let Some(flow) = api
.store
.get_device_login_flow_by_device_code(&request.device_code)?
else {
return Err(auth_error("unknown_device_login", "device login flow does not exist").into());
};
if is_expired(&flow.expires_at) {
return Ok(Json(DeviceLoginPollResponse {
status: "expired".to_string(),
access_token: None,
token_type: None,
}));
}
if flow.approved_at.is_none() {
return Ok(Json(DeviceLoginPollResponse {
status: "pending".to_string(),
access_token: None,
token_type: None,
}));
}
let Some(consumed) = api
.store
.consume_device_login_token(&request.device_code, &crate::auth::now_rfc3339())?
else {
return Ok(Json(DeviceLoginPollResponse {
status: "consumed".to_string(),
access_token: None,
token_type: None,
}));
};
Ok(Json(DeviceLoginPollResponse {
status: "approved".to_string(),
access_token: consumed.issued_access_token,
token_type: Some("Bearer".to_string()),
}))
}
async fn get_auth_whoami(
State(api): State<WorkspaceApi>,
headers: HeaderMap,
) -> ApiResult<Json<WhoamiResponse>> {
Ok(Json(WhoamiResponse {
actor: resolve_actor(&api, &headers).await?,
}))
}
async fn post_auth_logout(
State(api): State<WorkspaceApi>,
headers: HeaderMap,
) -> ApiResult<Response> {
let auth = auth_public_config(&api.config);
if let Some(session_token) = parse_cookie(&headers, &auth.cookie_name) {
let _ = api
.store
.revoke_browser_session(&token_hash(&session_token), &crate::auth::now_rfc3339())?;
}
let mut response_headers = HeaderMap::new();
response_headers.insert(
SET_COOKIE,
session_set_cookie(&auth.cookie_name, "", 0)
.parse()
.map_err(|error| {
auth_error(
"invalid_session_cookie",
&format!("failed to build logout cookie: {error}"),
)
})?,
);
Ok((
response_headers,
Json(LogoutResponse {
status: "logged_out".to_string(),
}),
)
.into_response())
}
fn webauthn(config: &ServerConfig) -> ApiResult<Webauthn> {
let auth = auth_public_config(config);
let origin = Url::parse(&auth.origin)
.map_err(|error| auth_error("invalid_webauthn_origin", &error.to_string()))?;
WebauthnBuilder::new(&auth.rp_id, &origin)
.map_err(|error| auth_error("webauthn_builder_failed", &error.to_string()))?
.rp_name("Yoi Workspace")
.build()
.map_err(|error| auth_error("webauthn_builder_failed", &error.to_string()).into())
}
fn passkeys_for_user(api: &WorkspaceApi, user_id: &str) -> ApiResult<Vec<Passkey>> {
api.store
.list_passkey_credentials_for_user(user_id)?
.into_iter()
.map(|record| {
serde_json::from_str::<Passkey>(&record.public_key_cose).map_err(|error| {
auth_error("webauthn_passkey_deserialize_failed", &error.to_string()).into()
})
})
.collect()
}
fn passkey_credential_id(passkey: &Passkey) -> ApiResult<String> {
serde_json::to_value(passkey.cred_id())
.ok()
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.ok_or_else(|| {
auth_error(
"invalid_webauthn_credential_id",
"verified passkey credential id was not serializable",
)
.into()
})
}
fn auth_public_config(config: &ServerConfig) -> AuthPublicConfig {
match &config.auth {
AuthConfig::Passkey {
rp_id,
origin,
public_base_url,
cookie_name,
} => AuthPublicConfig {
rp_id: rp_id.clone(),
origin: origin.clone(),
public_base_url: public_base_url.clone(),
cookie_name: cookie_name.clone(),
},
}
}
fn ensure_user_account(
api: &WorkspaceApi,
handle: &str,
display_name: Option<&str>,
) -> ApiResult<AuthenticatedUser> {
let handle = normalize_handle(handle)?;
if let Some(user) = api.store.get_user_by_handle(&handle)? {
return Ok(user_response(user));
}
let now = crate::auth::now_rfc3339();
let display_name = display_name
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(handle.as_str())
.to_string();
let account = AccountRecord {
account_id: new_id("acct-user"),
kind: "user".to_string(),
handle: handle.clone(),
display_name: display_name.clone(),
created_at: now.clone(),
updated_at: now.clone(),
};
api.store.upsert_account(&account)?;
let user = UserRecord {
user_id: new_id("user"),
account_id: account.account_id,
handle,
display_name,
created_at: now.clone(),
updated_at: now,
};
api.store.upsert_user(&user)?;
Ok(user_response(user))
}
fn user_response(user: UserRecord) -> AuthenticatedUser {
AuthenticatedUser {
user_id: user.user_id,
account_id: user.account_id,
handle: user.handle,
display_name: user.display_name,
}
}
async fn resolve_actor(api: &WorkspaceApi, headers: &HeaderMap) -> ApiResult<Option<RequestActor>> {
let cookie_name = auth_public_config(&api.config).cookie_name;
Ok(resolve_request_actor(api.store.as_ref(), headers, &cookie_name).await?)
}
async fn require_actor(api: &WorkspaceApi, headers: &HeaderMap) -> ApiResult<RequestActor> {
resolve_actor(api, headers).await?.ok_or_else(|| {
auth_error(
"auth_required",
"request requires a browser session or Bearer API token",
)
.into()
})
}
async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> {
let schema_version = api.store.schema_version().await?;
let stored = api.store.get_workspace(api.workspace_id()).await?;
@ -7927,6 +8656,68 @@ mod tests {
dir,
)
}
#[tokio::test]
async fn passkey_registration_rejects_unverified_credential_response() {
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path()).await;
let registration_options = post_json(
app.clone(),
"/api/auth/passkeys/registration/options",
json!({
"handle": "alice",
"display_name": "Alice"
}),
)
.await;
assert!(registration_options["public_key"].is_object());
let challenge_id = registration_options["challenge_id"].as_str().unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/passkeys/registration/complete")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&json!({
"challenge_id": challenge_id,
"credential": {
"id": "credential-1",
"rawId": "Y3JlZGVudGlhbC0x",
"type": "public-key",
"response": {
"clientDataJSON": "e30",
"attestationObject": "e30"
}
}
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_ne!(response.status(), StatusCode::OK);
let login_without_registered_passkey = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/passkeys/login/options")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&json!({ "handle": "alice" })).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_ne!(login_without_registered_passkey.status(), StatusCode::OK);
}
async fn get_json(app: Router, uri: &str) -> Value {
let response = app
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())

File diff suppressed because it is too large Load Diff

View File

@ -12,10 +12,14 @@ use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use std::time::Duration;
use client::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
use client::{
BackendAuthTarget, BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand,
start_device_login, wait_for_device_login,
};
use memory_lint::{LintCliOptions, LintStatus};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use session_store::SegmentId;
use tui::{LaunchMode, LaunchOptions};
@ -36,6 +40,10 @@ enum Mode {
subcommand: String,
args: Vec<String>,
},
Login {
backend_url: String,
no_wait: bool,
},
WorkerRuntime(Vec<String>),
Keys,
SetupModel,
@ -85,6 +93,16 @@ async fn main() -> ExitCode {
ExitCode::SUCCESS
}
Mode::WorkspaceServer { subcommand, args } => run_workspace_server(&subcommand, args),
Mode::Login {
backend_url,
no_wait,
} => match run_login(&backend_url, no_wait).await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("yoi login: {e}");
ExitCode::FAILURE
}
},
Mode::MemoryLint(options) => match memory_lint::run(&options) {
Ok(LintStatus::Clean) => ExitCode::SUCCESS,
Ok(LintStatus::Failed) => ExitCode::FAILURE,
@ -241,6 +259,9 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
"workspace" => {
return parse_workspace_args(&args[1..]);
}
"login" => {
return parse_login_args(&args[1..]);
}
"mcp" => {
let mcp_cli = parse_mcp_args(&args[1..])?;
return Ok(Mode::Mcp(mcp_cli));
@ -859,15 +880,7 @@ fn read_client_config() -> Result<Option<ClientConfigFile>, ParseError> {
}
fn client_config_path() -> Option<PathBuf> {
if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") {
return Some(PathBuf::from(home).join("yoi").join("client.toml"));
}
std::env::var_os("HOME").map(|home| {
PathBuf::from(home)
.join(".config")
.join("yoi")
.join("client.toml")
})
yoi_config_dir().map(|dir| dir.join("client.toml"))
}
fn client_config_missing_message(workspace_id: Option<&str>) -> String {
@ -879,6 +892,137 @@ fn client_config_missing_message(workspace_id: Option<&str>) -> String {
}
}
fn parse_login_args(args: &[String]) -> Result<Mode, ParseError> {
if args.iter().any(|arg| arg == "--help" || arg == "-h") {
return Err(ParseError(
"yoi login usage: yoi login [--backend <URL>] [--no-wait]".to_string(),
));
}
let mut backend_url = None;
let mut no_wait = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--backend" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--backend requires a URL".to_string()))?;
if value.starts_with('-') || value.is_empty() {
return Err(ParseError("--backend requires a URL".to_string()));
}
backend_url = Some(value.clone());
i += 2;
}
arg if arg.starts_with("--backend=") => {
let value = arg.trim_start_matches("--backend=");
if value.is_empty() {
return Err(ParseError("--backend requires a URL".to_string()));
}
backend_url = Some(value.to_string());
i += 1;
}
"--no-wait" => {
no_wait = true;
i += 1;
}
other => {
return Err(ParseError(format!(
"unknown yoi login argument '{other}' (try 'yoi login --help')"
)));
}
}
}
let backend_url = resolve_backend_url(backend_url, None)?;
Ok(Mode::Login {
backend_url,
no_wait,
})
}
async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> {
let target = BackendAuthTarget::new(backend_url.to_string());
let start = start_device_login(&target, Some("yoi cli"))
.await
.map_err(|error| ParseError(error.to_string()))?;
println!("Open this URL in your browser to approve Yoi CLI login:");
println!(" {}", start.verification_uri_complete);
println!();
println!("User code: {}", start.user_code);
println!("Expires in: {} seconds", start.expires_in);
if no_wait {
println!("Device code: {}", start.device_code);
println!(
"Run without --no-wait after approving, or poll the Backend device endpoint manually."
);
return Ok(());
}
let token = wait_for_device_login(
&target,
&start.device_code,
Duration::from_secs(start.interval.max(1)),
Duration::from_secs(start.expires_in.max(1)),
)
.await
.map_err(|error| ParseError(error.to_string()))?;
save_backend_token(backend_url, &token)?;
println!("Saved Backend API token for {backend_url}");
Ok(())
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct BackendTokenFile {
#[serde(default)]
tokens: BTreeMap<String, BackendTokenEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BackendTokenEntry {
token_type: String,
access_token: String,
}
fn save_backend_token(backend_url: &str, access_token: &str) -> Result<(), ParseError> {
let path = backend_token_path().ok_or_else(|| {
ParseError("HOME or XDG_CONFIG_HOME is required to save Backend token".to_string())
})?;
let mut file = if path.is_file() {
let contents = fs::read_to_string(&path)
.map_err(|error| ParseError(format!("failed to read {}: {error}", path.display())))?;
serde_json::from_str::<BackendTokenFile>(&contents)
.map_err(|error| ParseError(format!("failed to parse {}: {error}", path.display())))?
} else {
BackendTokenFile::default()
};
file.tokens.insert(
backend_url.trim_end_matches('/').to_string(),
BackendTokenEntry {
token_type: "Bearer".to_string(),
access_token: access_token.to_string(),
},
);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
ParseError(format!("failed to create {}: {error}", parent.display()))
})?;
}
let serialized = serde_json::to_string_pretty(&file)
.map_err(|error| ParseError(format!("failed to serialize Backend token file: {error}")))?;
fs::write(&path, format!("{serialized}\n"))
.map_err(|error| ParseError(format!("failed to write {}: {error}", path.display())))?;
Ok(())
}
fn backend_token_path() -> Option<PathBuf> {
yoi_config_dir().map(|dir| dir.join("backend-tokens.json"))
}
fn yoi_config_dir() -> Option<PathBuf> {
if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") {
return Some(PathBuf::from(home).join("yoi"));
}
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("yoi"))
}
fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError(

View File

@ -15,8 +15,9 @@ It is not a dumping ground for external research, old plans, API inventories, or
7. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins.
8. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records.
9. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions.
10. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
11. [`development/validation.md`](development/validation.md) — how to check changes.
10. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary.
11. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
12. [`development/validation.md`](development/validation.md) — how to check changes.
## What belongs here

View File

@ -0,0 +1,54 @@
# Backend account model and login flows
This document records the first Backend identity/auth layer introduced for the multi-workspace Backend direction.
## Account and owner model
The Backend store now separates a human user from the owner namespace used by workspace ownership:
- `accounts`: canonical namespace records. `kind = "user"` is implemented now; `kind = "organization"` is reserved in the schema for a later organization model.
- `users`: human user records. Each user owns exactly one `accounts` row.
- `workspaces.owner_account_id`: optional account owner reference. Existing/local workspaces may keep it unset until account bootstrap/ownership migration code assigns it.
This avoids baking `owner_user_id` into workspace authority while keeping the current implementation small.
## Browser login
Human browser auth is modeled as Passkey/WebAuthn ceremony endpoints:
- `POST /api/auth/bootstrap-user`
- `POST /api/auth/passkeys/registration/options`
- `POST /api/auth/passkeys/registration/complete`
- `POST /api/auth/passkeys/login/options`
- `POST /api/auth/passkeys/login/complete`
- `GET /api/auth/whoami`
The store persists registration/login challenges, serialized WebAuthn ceremony state, verified passkey credentials, browser sessions, and challenge consumption. Successful login mints an HttpOnly `SameSite=Lax` cookie session.
Completion endpoints require browser `PublicKeyCredential` responses and verify them with `webauthn-rs` before a passkey is stored or a browser session is issued. Fake or stale credential responses are rejected by the verifier, and consumed challenges cannot be reused.
## CLI/TUI device login
CLI/TUI clients use a device-login flow instead of browser cookies:
- `POST /api/auth/device-login/start` creates a `device_code` and human `user_code`.
- The CLI prints `verification_uri_complete` and polls `POST /api/auth/device-login/poll`.
- A browser-authenticated user approves with `POST /api/auth/device-login/approve`.
- Approval mints a Backend API token and returns it exactly once to the polling client.
`yoi login [--backend <URL>] [--no-wait]` starts this flow and stores the received token in `$XDG_CONFIG_HOME/yoi/backend-tokens.json` or `$HOME/.config/yoi/backend-tokens.json`.
## Request actor extraction
`workspace-server::auth::resolve_request_actor` resolves a typed request actor from either:
- `Authorization: Bearer <token>` for CLI/TUI API tokens, or
- the configured browser session cookie.
The resolved actor includes `user_id`, `account_id`, `handle`, `display_name`, and `auth_method`. This helper is intended to be used by mutating Backend APIs as authorization gets added route-by-route.
## Non-goals for this step
- Organization membership and RBAC.
- Password login, OAuth login, or other human auth methods.
- Token scoping beyond Backend API bearer authentication.

View File

@ -0,0 +1,155 @@
# Workspace / Runtime / Docker launch boundary
Yoi's browser-facing deployment is a three-process boundary: WebUI, Workspace Backend, and Worker Runtime. Docker Compose currently exercises that boundary with locally loaded Nix-built images; it is not a separate authority model.
This document records the current design state for the split launch path, Docker image layout, and working-directory ownership rules. It is intentionally about boundaries and invariants rather than a complete operations manual.
## Process boundary
The browser launch path is:
```text
Browser WebUI
-> Workspace Backend Server
-> Worker Runtime
-> Worker process
```
Responsibilities are split as follows:
- WebUI renders the workspace and submits browser-safe requests. It does not hold filesystem authority, raw runtime paths, raw socket paths, or local Worker names as operation authority.
- Workspace Backend Server is the workspace control plane. It owns workspace-scoped API identity, Runtime registry projection, Worker registry projection, Ticket/workspace records, memory backend access, and browser-safe launch options.
- Worker Runtime owns runtime-specific process spawning, Worker controller state, workdir materialization, runtime-local workdir cleanup, and Worker protocol connectivity.
- Worker process owns model turns, tool execution inside its granted scope, session history append, and Worker protocol handling.
The Backend can project Runtime and Worker state, but it should not become a hidden filesystem/runtime implementation. Runtime observations should be reconstructable from Runtime APIs and committed Backend records.
## Docker image layout
Docker images are built through Nix `dockerTools.buildImage`, not through a root Dockerfile.
Current image roles:
```text
yoi-runtime:latest
worker-runtime-rest-server
yoi-server:latest
yoi-workspace-server
yoi-webui:latest
nginx serving built WebUI assets and proxying API requests
```
The Compose setup assumes those images have already been loaded into the local Docker daemon. Compose is a local wiring layer, not the builder.
Typical development flow:
```text
nix build .#docker-runtime
nix build .#docker-server
nix build .#docker-webui
docker load < result
# repeat load for each built image result
docker compose up
```
The Compose files live at:
```text
compose.yaml
docker/workspace/.yoi/workspace.toml
docker/workspace/.yoi/workspace-backend.local.toml
```
The WebUI container serves static assets and proxies `/api` to the Backend Server. The Backend Server registers the Runtime container as a remote Runtime such as `docker-runtime`. The Runtime container runs `worker-runtime-rest-server` and owns Worker spawning/materialization for that runtime.
Container user and writable data directories matter: runtime/server images must be able to write their configured data directories and named volumes. The current local-image Compose setup avoids an image-level `User` override and sets data-directory permissions accordingly.
## Worker launch path
A browser-created Worker follows this control flow:
```text
WebUI new-worker form
runtime_id / profile / initial input / optional workdir selection
Workspace Backend create-worker API
validate workspace scope
resolve Runtime by canonical runtime_id
resolve profile/manifest using Backend authority
convert browser workdir selection into a Runtime working-directory claim
forward a typed Worker creation request to the selected Runtime
Worker Runtime create_worker
reject a requested primary workdir if an existing live/registered Worker already owns it
spawn the Worker through the runtime execution backend
return Worker detail, run state, and workdir binding
Workspace Backend post-spawn projection
sync Worker registry
sync workdir registry from Runtime summary
sync Worker/workdir link
return browser-safe Worker detail
```
The Runtime guard against assigning the same primary workdir to two active Workers is required even when the Backend has a registry projection. It is the last authority that sees runtime-local Worker requests and execution bindings.
Worker interaction uses the unified Worker protocol WebSocket path. The older event-only stream is not an operation authority. Normal interaction should go through protocol transport and runtime/backend forwarding rather than raw local sockets.
## Working-directory model
A working directory is a materialized repository for a workspace on a concrete Runtime.
The normal invariant is:
```text
workdir {
workspace_id # Backend workspace scope
runtime_id # concrete Runtime that owns the materialization
repository_id # repository being materialized
working_directory_id
selector / resolved commit
materialization status
cleanliness
}
```
`repository_id` is part of the normal model. A workdir without a repository is not a normal reusable workspace workdir; it should be treated as diagnostic/orphan/corrupted state rather than as a first-class launch candidate.
The removed `management_kind` state must not be reintroduced. Whether a row was first authored by Backend creation or later observed from Runtime is provenance, not current workdir authority. Reuse eligibility must be derived from current state and ownership:
- Runtime reports the workdir as active/present;
- cleanliness is clean;
- no active Runtime Worker owns it as a primary workdir;
- Backend worker/workdir links do not show an existing owner;
- `primary_worker_id` is absent in the browser-safe summary.
Runtime-observed workdirs that satisfy those conditions are valid reusable candidates for the selected Runtime. Hiding them because Backend did not originally author the registry row creates a stale provenance bug.
## Workspace and Runtime sharing assumption
The current Docker and browser launch path treats a configured Runtime as serving the Workspace that registered it. Runtime workdir summaries do not carry independent workspace authority sufficient for arbitrary multi-workspace sharing.
If one Runtime is later shared by multiple Workspace Backends, the Runtime workdir materialization record/API must carry enough workspace identity to let a Backend decide whether a workdir belongs to its workspace. That should be modeled explicitly as workspace identity on materialization records, not through a vague managed/unmanaged kind.
Until that exists, do not infer cross-workspace ownership from display names, local paths, Runtime labels, or Backend registry provenance.
## Memory backend launch pitfall
Workspace memory backend HTTP operations run inside async Worker/runtime paths. They must use async HTTP clients. Blocking HTTP clients such as `reqwest::blocking` can panic when created/dropped inside a Tokio async context and can kill Worker controller tasks.
The current memory backend HTTP path uses async `reqwest::Client`. New Backend/Runtime launch code should preserve that boundary.
## Current validation surface
The local-image Compose path has been checked by building/loading the Nix Docker images, running `docker compose config`, starting the Compose stack, loading WebUI `/`, and confirming `/api/runtimes` sees the remote Runtime.
Code-level changes around Runtime/Backend/WebUI boundaries should generally be checked with the relevant Rust crates, WebUI check, and whitespace validation, for example:
```text
nix develop -c cargo check -p yoi-workspace-server -p worker-runtime -p client
nix develop -c cargo test -p yoi-workspace-server --lib
cd web/workspace && deno task check
git diff --check
```

View File

@ -41,6 +41,14 @@ frontend_url = "http://127.0.0.1:5173"
[limits]
max_records = 200
[auth]
# WebAuthn / Passkey relying-party settings. For local development keep rp_id
# aligned with the browser host in public_base_url/origin.
rp_id = "localhost"
origin = "http://localhost:8787"
public_base_url = "http://localhost:8787"
cookie_name = "yoi_workspace_session"
# Repository registry. Browser/API repository projection reads only configured
# entries and never falls back to the backend process cwd. Relative URI values
# are resolved from this workspace config root. Git is the v0 supported provider.

View File

@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/api/http.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts",
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},

View File

@ -445,12 +445,14 @@
.runtime-card {
padding: 0;
}
.ticket-list,
.objective-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-4);
}
.ticket-row,
.objective-row {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
@ -463,27 +465,34 @@
text-decoration: none;
transition: background-color 140ms ease;
}
.ticket-row:hover,
.ticket-row:focus-visible,
.objective-row:hover,
.objective-row:focus-visible {
background: var(--interactive-hover);
}
.ticket-row.selected,
.objective-row.selected {
background: var(--interactive-selected);
}
.ticket-row.selected .ticket-title,
.objective-row.selected .objective-title {
color: var(--accent);
}
.ticket-main,
.objective-main {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.ticket-title-row,
.objective-title-row {
display: flex;
align-items: baseline;
gap: var(--space-2);
min-width: 0;
}
.ticket-title,
.objective-title {
min-width: 0;
overflow: hidden;
@ -500,12 +509,14 @@
letter-spacing: 0.05em;
text-transform: uppercase;
}
.ticket-summary,
.objective-summary {
margin: 0;
color: var(--text-muted);
font-size: 0.88rem;
line-height: 1.4;
}
.ticket-meta,
.objective-meta {
display: flex;
flex-wrap: wrap;
@ -518,10 +529,12 @@
white-space: nowrap;
}
@media (max-width: 900px) {
.ticket-row,
.objective-row {
grid-template-columns: 1fr;
gap: var(--space-2);
}
.ticket-meta,
.objective-meta {
justify-content: flex-start;
text-align: left;
@ -542,6 +555,9 @@
.runtime-heading span.warn {
color: var(--warning);
}
.detail-heading.compact {
margin-bottom: var(--space-2);
}
.detail-heading h3 {
margin: 0;
}
@ -549,6 +565,36 @@
color: var(--text-muted);
font-size: 0.8rem;
}
.ticket-detail-grid {
margin-bottom: var(--space-4);
}
.risk-flags {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.risk-flags span,
.warning-pill {
border-radius: 999px;
padding: 0.18rem 0.55rem;
background: var(--bg-subtle);
color: var(--warning);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.ticket-body pre {
overflow-x: auto;
margin: 0;
padding: var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: var(--text);
white-space: pre-wrap;
}
dl {
display: grid;
gap: var(--space-1);
@ -826,6 +872,45 @@
color: var(--text-muted);
}
.error-message {
margin: 0;
color: var(--danger);
}
.account-details {
display: grid;
gap: var(--space-2);
margin: var(--space-3) 0;
}
.account-details div {
display: grid;
grid-template-columns: minmax(8rem, 0.35fr) 1fr;
gap: var(--space-3);
align-items: baseline;
}
.account-details dt {
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
}
.account-details dd {
margin: 0;
color: var(--text-strong);
}
.account-details code {
font-size: 0.78rem;
}
.account-panel {
display: grid;
gap: var(--space-4);
}
@media (max-width: 760px) {
.settings-hero,
.settings-notice,

View File

@ -38,7 +38,7 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor
);
});
Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped workspace data", async () => {
Deno.test("root layout bootstraps only the scoped workspace entry", async () => {
const layout = await Deno.readTextFile(
new URL("./../../../routes/+layout.ts", import.meta.url),
);
@ -47,24 +47,17 @@ Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped
"unscoped layout may use only the workspace-id bootstrap endpoint",
);
assert(
layout.includes("throw redirect(307") && layout.includes("workspaceRoute("),
"unscoped layout should redirect to the scoped workspace route",
layout.includes("throw redirect(307") &&
layout.includes("workspaceRoute(workspace.data.workspace_id)") &&
!layout.includes("scopedCompatibilityRoute") &&
!layout.includes("workspaceRoute(workspaceId, pathname)"),
"root layout should redirect only to the scoped workspace entry",
);
assert(
!layout.includes("`/api${path}`") &&
!layout.includes('"/api/repositories"'),
"layout must not fall back to unscoped workspace-scoped API calls",
);
const unscopedSettings = await Deno.readTextFile(
new URL("./../../../routes/settings/+page.svelte", import.meta.url),
);
assert(
unscopedSettings.includes("Redirecting to scoped settings") &&
!unscopedSettings.includes("fetch(") &&
!unscopedSettings.includes("settingsApiPath"),
"unscoped settings route should remain a thin redirect shim, not a data/control surface",
);
});
Deno.test("Skill API paths use workspace backend scoped endpoints", () => {

View File

@ -0,0 +1,102 @@
import {
authenticationCredentialToJson,
isPublicKeyCredential,
prepareLoginOptions,
prepareRegistrationOptions,
registrationCredentialToJson,
type DeviceApprovalResponse,
type PasskeyLoginOptionsResponse,
type PasskeyRegistrationOptionsResponse,
type PasskeyUserResponse,
type WhoamiResponse,
} from "./model";
async function jsonOrThrow<T>(response: Response): Promise<T> {
const text = await response.text();
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}${text ? `: ${text}` : ""}`);
}
return text ? JSON.parse(text) as T : (null as T);
}
export async function loadWhoami(fetcher: typeof fetch = fetch): Promise<WhoamiResponse> {
return await fetcher("/api/auth/whoami", { credentials: "same-origin" }).then(jsonOrThrow<WhoamiResponse>);
}
export async function registerPasskey(
handle: string,
displayName: string,
fetcher: typeof fetch = fetch,
): Promise<PasskeyUserResponse> {
const options = await fetcher("/api/auth/passkeys/registration/options", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ handle, display_name: displayName }),
}).then(jsonOrThrow<PasskeyRegistrationOptionsResponse>);
const credential = await navigator.credentials.create({
publicKey: prepareRegistrationOptions(options),
});
if (!isPublicKeyCredential(credential)) {
throw new Error("Passkey registration did not return a public-key credential.");
}
return await fetcher("/api/auth/passkeys/registration/complete", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
challenge_id: options.challenge_id,
credential: registrationCredentialToJson(credential),
}),
}).then(jsonOrThrow<PasskeyUserResponse>);
}
export async function loginWithPasskey(
handle: string,
fetcher: typeof fetch = fetch,
): Promise<PasskeyUserResponse> {
const options = await fetcher("/api/auth/passkeys/login/options", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ handle }),
}).then(jsonOrThrow<PasskeyLoginOptionsResponse>);
const credential = await navigator.credentials.get({
publicKey: prepareLoginOptions(options),
});
if (!isPublicKeyCredential(credential)) {
throw new Error("Passkey login did not return a public-key credential.");
}
return await fetcher("/api/auth/passkeys/login/complete", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
challenge_id: options.challenge_id,
credential: authenticationCredentialToJson(credential),
}),
}).then(jsonOrThrow<PasskeyUserResponse>);
}
export async function logout(fetcher: typeof fetch = fetch): Promise<void> {
await fetcher("/api/auth/logout", {
method: "POST",
credentials: "same-origin",
}).then(jsonOrThrow<unknown>);
}
export async function approveDeviceLogin(
userCode: string,
fetcher: typeof fetch = fetch,
): Promise<DeviceApprovalResponse> {
return await fetcher("/api/auth/device-login/approve", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ user_code: userCode }),
}).then(jsonOrThrow<DeviceApprovalResponse>);
}

View File

@ -0,0 +1,66 @@
import {
base64UrlToBuffer,
bufferToBase64Url,
prepareLoginOptions,
prepareRegistrationOptions,
} from "./model.ts";
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
function assertEquals<T>(actual: T, expected: T): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function bytes(buffer: BufferSource): number[] {
if (buffer instanceof ArrayBuffer) {
return [...new Uint8Array(buffer)];
}
return [...new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)];
}
Deno.test("base64url helpers round-trip binary data", () => {
const source = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255]).buffer;
const encoded = bufferToBase64Url(source);
assertEquals(encoded.includes("+"), false);
assertEquals(encoded.includes("/"), false);
assertEquals(encoded.includes("="), false);
assertEquals(bytes(base64UrlToBuffer(encoded)), bytes(source));
});
Deno.test("prepareRegistrationOptions decodes binary public key fields", () => {
const options = prepareRegistrationOptions({
challenge_id: "challenge-1",
public_key: {
challenge: "AQID" as unknown as BufferSource,
rp: { id: "localhost", name: "Yoi" },
user: {
id: "BAUG" as unknown as BufferSource,
name: "local",
displayName: "Local User",
},
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
excludeCredentials: [{ type: "public-key", id: "BwgJ" as unknown as BufferSource }],
},
});
assertEquals(bytes(options.challenge), [1, 2, 3]);
assertEquals(bytes(options.user.id), [4, 5, 6]);
assertEquals(bytes(options.excludeCredentials?.[0].id as BufferSource), [7, 8, 9]);
});
Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", () => {
const options = prepareLoginOptions({
challenge_id: "challenge-1",
public_key: {
challenge: "AQID" as unknown as BufferSource,
allowCredentials: [{ type: "public-key", id: "BwgJ" as unknown as BufferSource }],
},
});
assertEquals(bytes(options.challenge), [1, 2, 3]);
assertEquals(bytes(options.allowCredentials?.[0].id as BufferSource), [7, 8, 9]);
});

View File

@ -0,0 +1,142 @@
export type AuthenticatedUser = {
user_id: string;
account_id: string;
handle: string;
display_name: string;
};
export type RequestActor = {
user: AuthenticatedUser;
auth_kind: string;
};
export type WhoamiResponse = {
actor: RequestActor | null;
};
export type PasskeyRegistrationOptionsResponse = {
challenge_id: string;
public_key: PublicKeyCredentialCreationOptions | { publicKey: PublicKeyCredentialCreationOptions };
};
export type PasskeyLoginOptionsResponse = {
challenge_id: string;
public_key: PublicKeyCredentialRequestOptions | { publicKey: PublicKeyCredentialRequestOptions };
};
export type PasskeyUserResponse = {
user: AuthenticatedUser;
};
export type DeviceApprovalResponse = {
status: string;
};
export type RegistrationCredentialJson = {
id: string;
rawId: string;
type: string;
response: {
clientDataJSON: string;
attestationObject: string;
transports: string[];
};
};
export type AuthenticationCredentialJson = {
id: string;
rawId: string;
type: string;
response: {
clientDataJSON: string;
authenticatorData: string;
signature: string;
userHandle: string | null;
};
};
export function base64UrlToBuffer(value: string): ArrayBuffer {
const padding = "=".repeat((4 - (value.length % 4)) % 4);
const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64);
return Uint8Array.from(binary, (char) => char.charCodeAt(0)).buffer;
}
export function bufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function unwrapPublicKey<T>(value: T | { publicKey: T }): T {
if (value && typeof value === "object" && "publicKey" in value) {
return (value as { publicKey: T }).publicKey;
}
return value as T;
}
export function prepareRegistrationOptions(
options: PasskeyRegistrationOptionsResponse,
): PublicKeyCredentialCreationOptions {
const publicKey = structuredClone(unwrapPublicKey(options.public_key));
publicKey.challenge = base64UrlToBuffer(publicKey.challenge as unknown as string);
publicKey.user = {
...publicKey.user,
id: base64UrlToBuffer(publicKey.user.id as unknown as string),
};
publicKey.excludeCredentials = publicKey.excludeCredentials?.map((credential) => ({
...credential,
id: base64UrlToBuffer(credential.id as unknown as string),
}));
return publicKey;
}
export function prepareLoginOptions(
options: PasskeyLoginOptionsResponse,
): PublicKeyCredentialRequestOptions {
const publicKey = structuredClone(unwrapPublicKey(options.public_key));
publicKey.challenge = base64UrlToBuffer(publicKey.challenge as unknown as string);
publicKey.allowCredentials = publicKey.allowCredentials?.map((credential) => ({
...credential,
id: base64UrlToBuffer(credential.id as unknown as string),
}));
return publicKey;
}
export function registrationCredentialToJson(
credential: PublicKeyCredential,
): RegistrationCredentialJson {
const response = credential.response as AuthenticatorAttestationResponse;
return {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
attestationObject: bufferToBase64Url(response.attestationObject),
transports: response.getTransports?.() ?? [],
},
};
}
export function authenticationCredentialToJson(
credential: PublicKeyCredential,
): AuthenticationCredentialJson {
const response = credential.response as AuthenticatorAssertionResponse;
return {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
authenticatorData: bufferToBase64Url(response.authenticatorData),
signature: bufferToBase64Url(response.signature),
userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null,
},
};
}
export function isPublicKeyCredential(credential: Credential | null): credential is PublicKeyCredential {
return credential != null && credential.type === "public-key";
}

View File

@ -27,9 +27,13 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
);
assert(
workspacePage.includes("href={`/w/${workspaceId}/runtimes`}") &&
workspacePage.includes("href={`/w/${workspaceId}/workers`}"),
"top workspace page should link to Runtimes and Workers pages",
workspacePage.includes("ticketsHref") &&
workspacePage.includes("runtimeSettingsHref") &&
workspacePage.includes("workersHref") &&
workspacePage.includes("workspaceRoute(workspaceId, '/tickets')") &&
workspacePage.includes("workspaceRoute(workspaceId, '/settings/runtimes')") &&
workspacePage.includes("workspaceRoute(workspaceId, '/workers')"),
"top workspace page should link to Tickets, Runtime Inventory under Settings, and the Workers page",
);
assert(
!workspacePage.includes("workerConsoleHref") &&
@ -51,8 +55,66 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
);
assert(
!sidebar.includes("CompanionNavSection") &&
sidebar.includes("TicketsNavSection") &&
sidebar.includes("WorkersNavSection"),
"standalone Companion/Console navigation should not remain canonical",
"standalone Companion/Console navigation should not remain canonical and Tickets should be primary workspace navigation",
);
});
Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async () => {
const ticketsNav = await Deno.readTextFile(
new URL("../sidebar/TicketsNavSection.svelte", import.meta.url),
);
const ticketsLoad = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/tickets/+page.ts", import.meta.url),
);
const ticketsPage = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/tickets/+page.svelte", import.meta.url),
);
const ticketDetailLoad = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.ts",
import.meta.url,
),
);
const ticketDetailPage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
import.meta.url,
),
);
assert(
ticketsNav.includes("workspaceRoute(workspaceId, '/tickets')") &&
ticketsNav.includes("Open Tickets"),
"Tickets sidebar section should link to the workspace Tickets surface",
);
assert(
ticketsLoad.includes('workspaceApiPath(params.workspaceId, "/tickets")') &&
ticketsPage.includes("This surface is read-only") &&
ticketsPage.includes("workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)"),
"Tickets list should read the workspace-scoped Ticket API and link to Ticket details",
);
assert(
ticketDetailLoad.includes("`/tickets/${encodeURIComponent(ticketId)}`") &&
ticketDetailPage.includes("event_count") &&
ticketDetailPage.includes("artifact_count") &&
ticketDetailPage.includes("<pre>{data.ticket.data.body"),
"Ticket detail should read one Ticket record and expose body plus metadata without mutation controls",
);
});
Deno.test("root layout does not keep legacy unscoped route compatibility", async () => {
const layoutLoad = await Deno.readTextFile(
new URL("./../../../routes/+layout.ts", import.meta.url),
);
assert(
!layoutLoad.includes("scopedCompatibilityRoute") &&
!layoutLoad.includes('pathname === "/runtimes"') &&
!layoutLoad.includes("return workspaceRoute(workspaceId, pathname)") &&
layoutLoad.includes("workspaceRoute(workspace.data.workspace_id)"),
"root layout should bootstrap the workspace entry only, not preserve legacy unscoped routes",
);
});
@ -188,49 +250,50 @@ Deno.test("Decodal source editor keeps imperative EditorView out of reactive sta
);
});
Deno.test("workspace Runtime management pages expose Runtimes and Runtime-owned workdirs", async () => {
Deno.test("workspace Runtime inventory lives under Settings admin routes", async () => {
const sidebar = await Deno.readTextFile(
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
);
const runtimesNav = await Deno.readTextFile(
new URL("../sidebar/RuntimesNavSection.svelte", import.meta.url),
const settingsModel = await Deno.readTextFile(
new URL("../settings/model.ts", import.meta.url),
);
const runtimesPage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/+page.svelte",
"./../../../routes/w/[workspaceId]/settings/runtimes/+page.svelte",
import.meta.url,
),
);
const workdirsPage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte",
"./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte",
import.meta.url,
),
);
const workdirsLoad = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.ts",
"./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.ts",
import.meta.url,
),
);
assert(
sidebar.includes("RuntimesNavSection") &&
runtimesNav.includes("href={runtimesHref}") &&
runtimesNav.includes("/runtimes"),
"sidebar should expose Runtime management navigation",
!sidebar.includes("RuntimesNavSection") &&
settingsModel.includes('id: "runtime-inventory"') &&
settingsModel.includes('return `${SETTINGS_ROUTE}/runtimes`;'),
"Runtime inventory should be admin Settings navigation, not primary workspace sidebar navigation",
);
assert(
runtimesPage.includes("Open workdirs") &&
runtimesPage.includes("Runtime Inventory") &&
runtimesPage.includes("Open workdirs") &&
runtimesPage.includes("runtimes-table") &&
runtimesPage.includes("/workdirs"),
"Runtimes page should table Runtimes and link to each Runtime's workdirs",
runtimesPage.includes("/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs"),
"Settings Runtime Inventory page should table Runtimes and link to each Runtime's workdirs",
);
assert(
workdirsPage.includes("Workdirs") &&
workdirsPage.includes("Runtime Inventory") &&
workdirsPage.includes("workdirs-table") &&
workdirsLoad.includes("/working-directories"),
"Workdirs page should read Runtime-owned working-directory API while using workdir UI language",
"Runtime workdirs should remain backed by Runtime APIs without legacy Runtime route redirects",
);
});
@ -310,3 +373,46 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
"target-change effect should load data without depending on manual refresh state reads",
);
});
Deno.test("Account UI owns browser passkey session state without workspace authorization", async () => {
const accountPage = await Deno.readTextFile(
new URL("./../../../routes/account/+page.svelte", import.meta.url),
);
const devicePage = await Deno.readTextFile(
new URL("./../../../routes/login/device/+page.svelte", import.meta.url),
);
const authApi = await Deno.readTextFile(
new URL("../auth/api.ts", import.meta.url),
);
const rootLayout = await Deno.readTextFile(
new URL("./../../../routes/+layout.ts", import.meta.url),
);
assert(
accountPage.includes("registerPasskey") &&
accountPage.includes("loginWithPasskey") &&
accountPage.includes("logout") &&
accountPage.includes("loadWhoami") &&
accountPage.includes("Current user"),
"Account page should expose registration, login, logout, and current-session inspection",
);
assert(
devicePage.includes("approveDeviceLogin") &&
devicePage.includes("loginWithPasskey") &&
devicePage.includes("user_code") &&
devicePage.includes("Approve device login"),
"Device login page should approve CLI login without DevTools console",
);
assert(
authApi.includes("/api/auth/whoami") &&
authApi.includes("/api/auth/passkeys/registration/options") &&
authApi.includes("/api/auth/passkeys/login/complete") &&
authApi.includes("/api/auth/logout") &&
authApi.includes("/api/auth/device-login/approve"),
"Auth model should stay on Backend auth APIs rather than workspace authorization APIs",
);
assert(
rootLayout.includes('"/account"') && rootLayout.includes('"/login/device"'),
"Root layout should not redirect account and device-login public routes to a workspace",
);
});

View File

@ -6,6 +6,7 @@ export type Diagnostic = {
export type SettingsSectionId =
| "runtime-connections"
| "runtime-inventory"
| "profile-sources"
| "backend-config"
| "workspace-identity";
@ -85,6 +86,18 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
"Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.",
],
},
{
id: "runtime-inventory",
label: "Runtime Inventory",
status: "read-only",
summary:
"Inspect registered Runtime handles and their materialized workdirs from the admin settings surface instead of the normal workspace navigation.",
bullets: [
"Runtime state is operational Backend/Runtime context, not a workspace content object like Objectives or Repositories.",
"Workdir cleanup remains scoped to typed Runtime APIs and stays outside the primary workspace sidebar.",
"Console routes may still target a Runtime handle directly, but Runtime discovery belongs under Settings.",
],
},
{
id: "profile-sources",
label: "Profile Sources",
@ -145,6 +158,8 @@ export function settingsSectionHref(id: SettingsSectionId): string {
switch (id) {
case "runtime-connections":
return `${SETTINGS_ROUTE}/runtime-connections`;
case "runtime-inventory":
return `${SETTINGS_ROUTE}/runtimes`;
case "profile-sources":
return `${SETTINGS_ROUTE}/profiles`;
case "workspace-identity":

View File

@ -1,23 +0,0 @@
<script lang="ts">
type Props = {
currentPath?: string;
workspaceId: string;
};
let { currentPath = '/', workspaceId }: Props = $props();
let runtimesHref = $derived(workspaceId ? `/w/${workspaceId}/runtimes` : '/runtimes');
let active = $derived(currentPath === runtimesHref || currentPath.startsWith(`${runtimesHref}/`));
</script>
<section class="sidebar-section" aria-labelledby="runtimes-heading">
<div class="section-heading-row">
<h2 id="runtimes-heading">
<a
class="section-heading-link"
class:active
href={runtimesHref}
aria-current={active ? 'page' : undefined}
>runtimes</a>
</h2>
</div>
</section>

View File

@ -0,0 +1,22 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
type Props = {
currentPath?: string;
workspaceId: string;
};
let { currentPath = '/', workspaceId }: Props = $props();
let ticketsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/tickets') : '/');
</script>
<section class="nav-section">
<header class="section-header">
<span>Tickets</span>
</header>
<a class="objective-link" class:active={currentPath.startsWith(ticketsHref)} href={ticketsHref}>
<span class="item-title">Open Tickets</span>
<span class="item-meta">workspace tickets</span>
</a>
</section>

View File

@ -2,7 +2,7 @@
import { workspaceRoute } from '$lib/workspace/api/http';
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import RuntimesNavSection from './RuntimesNavSection.svelte';
import TicketsNavSection from './TicketsNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte';
import type { RepositoryListResponse, WorkspaceResponse } from './types';
@ -28,6 +28,7 @@
let workspaceId = $derived(workspace?.workspace_id ?? '');
let homeHref = $derived(workspaceId ? workspaceRoute(workspaceId) : '/');
let accountHref = '/account';
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
</script>
@ -87,6 +88,17 @@
/>
</svg>
</a>
<a
class="sidebar-icon-button"
href={accountHref}
aria-label="Open Account"
title="Account"
>
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</a>
<a
class="sidebar-icon-button"
href={settingsHref}
@ -107,7 +119,7 @@
<nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
<ObjectivesNavSection {currentPath} {workspaceId} />
<RuntimesNavSection {currentPath} {workspaceId} />
<TicketsNavSection {currentPath} {workspaceId} />
<WorkersNavSection {currentPath} {workspaceId} />
</nav>
{/if}

View File

@ -320,6 +320,31 @@ export type TicketSummary = {
record_source?: string;
};
export type TicketListResponse = {
workspace_id: string;
limit: number;
items: TicketSummary[];
invalid_records: InvalidProjectRecord[];
record_authority: string;
};
export type TicketDetail = {
id: string;
title: string;
state: string;
priority?: string | null;
created_at?: string | null;
updated_at?: string | null;
queued_by?: string | null;
queued_at?: string | null;
risk_flags: string[];
body: string;
body_truncated: boolean;
event_count: number;
artifact_count: number;
record_source: string;
};
export type TicketKanbanColumn = {
state: string;
items: TicketSummary[];

View File

@ -11,11 +11,9 @@ export const load: LayoutLoad = async ({ fetch, params, url }) => {
if (!workspaceId) {
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
if (workspace.data) {
const scopedPath = workspaceRoute(
workspace.data.workspace_id,
url.pathname === "/" ? "" : url.pathname,
);
const publicRoutes = new Set(["/account", "/login/device"]);
if (workspace.data && !publicRoutes.has(url.pathname)) {
const scopedPath = workspaceRoute(workspace.data.workspace_id);
throw redirect(307, `${scopedPath}${url.search}`);
}
return {

View File

@ -1,6 +1,6 @@
<main class="workspace-panel-shell">
<section class="workspace-card">
<h1>Redirecting to scoped workspace…</h1>
<p class="section-note">This compatibility route bootstraps the current workspace id and redirects to the canonical <code>/w/&lt;workspace-id&gt;</code> route.</p>
<p class="section-note">The workspace entry bootstraps the current workspace id and opens the canonical <code>/w/&lt;workspace-id&gt;</code> route.</p>
</section>
</main>

View File

@ -0,0 +1,160 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
loadWhoami,
loginWithPasskey,
logout,
registerPasskey,
} from '$lib/workspace/auth/api';
import type { RequestActor } from '$lib/workspace/auth/model';
let actor = $state<RequestActor | null>(null);
let handle = $state('local');
let displayName = $state('Local User');
let loading = $state(true);
let busy = $state(false);
let status = $state('');
let error = $state('');
async function refreshWhoami() {
loading = true;
error = '';
try {
const whoami = await loadWhoami();
actor = whoami.actor;
if (actor?.user.handle) {
handle = actor.user.handle;
displayName = actor.user.display_name;
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function register() {
busy = true;
status = 'Waiting for passkey registration…';
error = '';
try {
await registerPasskey(handle.trim(), displayName.trim() || handle.trim());
status = 'Passkey registered.';
await refreshWhoami();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
status = '';
} finally {
busy = false;
}
}
async function login() {
busy = true;
status = 'Waiting for passkey login…';
error = '';
try {
await loginWithPasskey(handle.trim());
status = 'Logged in.';
await refreshWhoami();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
status = '';
} finally {
busy = false;
}
}
async function signOut() {
busy = true;
error = '';
try {
await logout();
actor = null;
status = 'Logged out.';
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
busy = false;
}
}
onMount(() => {
void refreshWhoami();
});
</script>
<svelte:head>
<title>Account · Yoi Workspace</title>
</svelte:head>
<main class="settings-page">
<header class="settings-header">
<p class="eyebrow">Account</p>
<h1>Account</h1>
<p>Register a passkey, sign in, sign out, and inspect the current browser session.</p>
</header>
<section class="settings-panel account-panel">
<div>
<h2>Current user</h2>
{#if loading}
<p class="muted">Loading session…</p>
{:else if actor}
<dl class="account-details">
<div>
<dt>Handle</dt>
<dd>{actor.user.handle}</dd>
</div>
<div>
<dt>Display name</dt>
<dd>{actor.user.display_name}</dd>
</div>
<div>
<dt>User ID</dt>
<dd><code>{actor.user.user_id}</code></dd>
</div>
<div>
<dt>Account ID</dt>
<dd><code>{actor.user.account_id}</code></dd>
</div>
<div>
<dt>Auth kind</dt>
<dd>{actor.auth_kind}</dd>
</div>
</dl>
<div class="settings-action-row">
<button type="button" disabled={busy} onclick={signOut}>Log out</button>
<button class="secondary-button" type="button" disabled={busy} onclick={refreshWhoami}>Refresh</button>
</div>
{:else}
<p class="muted">No browser session is active.</p>
{/if}
</div>
</section>
<section class="settings-panel">
<h2>Passkey</h2>
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); }}>
<label>
User handle
<input bind:value={handle} autocomplete="username webauthn" placeholder="local" />
</label>
<label>
Display name
<input bind:value={displayName} autocomplete="name" placeholder="Local User" />
</label>
<div class="settings-action-row">
<button type="button" disabled={busy || !handle.trim()} onclick={register}>Register passkey</button>
<button type="button" disabled={busy || !handle.trim()} onclick={login}>Log in with passkey</button>
</div>
</form>
</section>
{#if status}
<p class="status-message">{status}</p>
{/if}
{#if error}
<p class="error-message">{error}</p>
{/if}
</main>

View File

@ -0,0 +1,118 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import {
approveDeviceLogin,
loadWhoami,
loginWithPasskey,
} from '$lib/workspace/auth/api';
import type { RequestActor } from '$lib/workspace/auth/model';
let actor = $state<RequestActor | null>(null);
let handle = $state('local');
let userCode = $state('');
let loading = $state(true);
let busy = $state(false);
let status = $state('');
let error = $state('');
async function refreshWhoami() {
loading = true;
error = '';
try {
const whoami = await loadWhoami();
actor = whoami.actor;
if (actor?.user.handle) handle = actor.user.handle;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function login() {
busy = true;
status = 'Waiting for passkey login…';
error = '';
try {
await loginWithPasskey(handle.trim());
status = 'Logged in. You can approve the CLI login now.';
await refreshWhoami();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
status = '';
} finally {
busy = false;
}
}
async function approve() {
busy = true;
status = 'Approving device login…';
error = '';
try {
const result = await approveDeviceLogin(userCode.trim().toUpperCase());
status = result.status === 'approved'
? 'Device login approved. You can return to the CLI.'
: `Device login status: ${result.status}`;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
status = '';
} finally {
busy = false;
}
}
onMount(() => {
userCode = page.url.searchParams.get('user_code') ?? page.url.searchParams.get('code') ?? '';
void refreshWhoami();
});
</script>
<svelte:head>
<title>Device Login · Yoi Workspace</title>
</svelte:head>
<main class="settings-page">
<header class="settings-header">
<p class="eyebrow">Device Login</p>
<h1>Approve CLI login</h1>
<p>Use your browser session to approve a pending CLI/TUI login request.</p>
</header>
<section class="settings-panel">
<h2>Browser session</h2>
{#if loading}
<p class="muted">Loading session…</p>
{:else if actor}
<p>Logged in as <strong>{actor.user.display_name}</strong> <span class="muted">@{actor.user.handle}</span>.</p>
{:else}
<p class="muted">You need to log in with a passkey before approving a device login.</p>
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void login(); }}>
<label>
User handle
<input bind:value={handle} autocomplete="username webauthn" placeholder="local" />
</label>
<button type="submit" disabled={busy || !handle.trim()}>Log in with passkey</button>
</form>
{/if}
</section>
<section class="settings-panel">
<h2>Approval code</h2>
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void approve(); }}>
<label>
User code
<input bind:value={userCode} placeholder="ABCD-EFGH" autocapitalize="characters" />
</label>
<button type="submit" disabled={busy || !actor || !userCode.trim()}>Approve device login</button>
</form>
</section>
{#if status}
<p class="status-message">{status}</p>
{/if}
{#if error}
<p class="error-message">{error}</p>
{/if}
</main>

View File

@ -1,6 +0,0 @@
<main class="objectives-page">
<section class="workspace-card">
<h1>Redirecting to scoped objectives…</h1>
<p class="section-note">Objectives are available at the canonical <code>/w/&lt;workspace-id&gt;/objectives</code> route.</p>
</section>
</main>

View File

@ -1,3 +0,0 @@
import type { PageLoad } from "./$types";
export const load: PageLoad = async () => ({});

View File

@ -1,6 +0,0 @@
<main class="objective-detail-page">
<section class="workspace-card">
<h1>Redirecting to scoped objective…</h1>
<p class="section-note">Objective detail routes are canonical under <code>/w/&lt;workspace-id&gt;/objectives/&lt;objective-id&gt;</code>.</p>
</section>
</main>

View File

@ -1,3 +0,0 @@
import type { PageLoad } from "./$types";
export const load: PageLoad = async () => ({});

View File

@ -1,6 +0,0 @@
<main class="repository-detail-page">
<section class="workspace-card">
<h1>Redirecting to scoped repository…</h1>
<p class="section-note">Repository routes are canonical under <code>/w/&lt;workspace-id&gt;/repositories/&lt;repository-id&gt;</code>.</p>
</section>
</main>

View File

@ -1,3 +0,0 @@
import type { PageLoad } from "./$types";
export const load: PageLoad = async () => ({});

View File

@ -1,6 +0,0 @@
<main class="worker-console-page">
<section class="workspace-card">
<h1>Redirecting to scoped Worker Console…</h1>
<p class="section-note">Worker Console routes are canonical under <code>/w/&lt;workspace-id&gt;/runtimes/&lt;runtime-id&gt;/workers/&lt;worker-id&gt;/console</code>.</p>
</section>
</main>

View File

@ -1,3 +0,0 @@
import type { PageLoad } from "./$types";
export const load: PageLoad = async () => ({});

View File

@ -1,6 +0,0 @@
<main class="settings-page">
<section class="workspace-card">
<h1>Redirecting to scoped settings…</h1>
<p class="section-note">Settings are available at the canonical <code>/w/&lt;workspace-id&gt;/settings</code> route.</p>
</section>
</main>

View File

@ -1,8 +1,12 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? data.workspaceId);
let ticketsHref = $derived(workspaceRoute(workspaceId, '/tickets'));
let runtimeSettingsHref = $derived(workspaceRoute(workspaceId, '/settings/runtimes'));
let workersHref = $derived(workspaceRoute(workspaceId, '/workers'));
</script>
<svelte:head>
@ -39,12 +43,17 @@
</section>
<section class="workspace-actions" aria-label="Workspace sections">
<a class="workspace-action-card" href={`/w/${workspaceId}/runtimes`}>
<span>Runtimes</span>
<strong>Manage runtimes and workdirs</strong>
<a class="workspace-action-card" href={ticketsHref}>
<span>Tickets</span>
<strong>Browse workspace tickets</strong>
<small>Read typed Ticket records</small>
</a>
<a class="workspace-action-card" href={runtimeSettingsHref}>
<span>Runtime Inventory</span>
<strong>Open admin runtime inventory</strong>
<small>{data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible</small>
</a>
<a class="workspace-action-card" href={`/w/${workspaceId}/workers`}>
<a class="workspace-action-card" href={workersHref}>
<span>Workers</span>
<strong>Open worker list</strong>
<small>Inspect status and attach to consoles</small>

View File

@ -10,15 +10,15 @@
</script>
<svelte:head>
<title>Runtimes · Yoi Workspace</title>
<meta name="description" content="Workspace Runtimes" />
<title>Runtime Inventory · Settings · Yoi Workspace</title>
<meta name="description" content="Workspace Runtime inventory" />
</svelte:head>
<section class="runtimes-page" aria-labelledby="runtimes-heading">
<header class="page-header-row">
<div>
<h1 id="runtimes-heading">Runtimes</h1>
<p>Execution backends available to this workspace.</p>
<h1 id="runtimes-heading">Runtime Inventory</h1>
<p>Admin view of execution backends available to this workspace.</p>
</div>
</header>
@ -53,7 +53,7 @@
<td>{runtimePlatform(runtime)}</td>
<td>{runtime.capabilities.max_workers} workers</td>
<td>
<a class="inline-link" href={`/w/${data.workspaceId}/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
<a class="inline-link" href={`/w/${data.workspaceId}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
Open workdirs
</a>
</td>

View File

@ -105,7 +105,7 @@
<section class="workdirs-page" aria-labelledby="workdirs-heading">
<header class="page-header-row">
<div>
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/runtimes`}>Runtimes</a> / {runtimeLabel}</p>
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/settings/runtimes`}>Runtime Inventory</a> / {runtimeLabel}</p>
<h1 id="workdirs-heading">Workdirs</h1>
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
{#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if}

View File

@ -0,0 +1,64 @@
<script lang="ts">
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<svelte:head>
<title>Tickets · Yoi Workspace</title>
<meta name="description" content="Workspace Tickets" />
</svelte:head>
<section class="card">
<div class="detail-heading">
<div>
<p class="eyebrow">Workspace records</p>
<h2>Tickets</h2>
</div>
{#if data.tickets.data}
<span>{data.tickets.data.items.length} ticket{data.tickets.data.items.length === 1 ? '' : 's'}</span>
{/if}
</div>
<p class="section-note">
Tickets are read from the typed Ticket backend. This surface is read-only; creation and queue operations remain outside the browser UI for now.
</p>
{#if data.tickets.data}
{#if data.tickets.data.items.length === 0}
<p>No Ticket records are present.</p>
{:else}
<div class="ticket-list" aria-label="Workspace Tickets">
{#each data.tickets.data.items as ticket (ticket.id)}
<a class="ticket-row" href={workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)}>
<div class="ticket-main">
<div class="ticket-title-row">
<strong class="ticket-title">{ticket.title}</strong>
<span class="state-pill">{ticket.state}</span>
</div>
<p class="ticket-summary">
{ticket.priority ? `${ticket.priority} priority` : 'priority unspecified'} · {ticket.record_source ?? 'ticket backend'}
</p>
</div>
<div class="ticket-meta" aria-label="Ticket metadata">
<span>Updated {ticket.updated_at ? formatDate(ticket.updated_at) : 'unknown'}</span>
{#if ticket.queued_at}
<span>Queued {formatDate(ticket.queued_at)}</span>
{/if}
<code>{ticket.id}</code>
</div>
</a>
{/each}
</div>
{/if}
{#if data.tickets.data.invalid_records.length > 0}
<p class="error">{data.tickets.data.invalid_records.length} invalid Ticket record(s) hidden.</p>
{/if}
{:else if data.tickets.error}
<p class="error">{data.tickets.error}</p>
{:else}
<p>Waiting for <code>/api/w/{data.workspaceId}/tickets</code></p>
{/if}
</section>

View File

@ -0,0 +1,15 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { TicketListResponse } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
const tickets = await loadJson<TicketListResponse>(
fetch,
workspaceApiPath(params.workspaceId, "/tickets"),
);
return {
workspaceId: params.workspaceId,
tickets,
};
}) satisfies PageLoad;

View File

@ -0,0 +1,82 @@
<script lang="ts">
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<svelte:head>
<title>{data.ticket.data?.title ?? data.ticketId} · Tickets · Yoi Workspace</title>
<meta name="description" content="Workspace Ticket detail" />
</svelte:head>
<section class="card">
<p class="breadcrumb"><a href={workspaceRoute(data.workspaceId, '/tickets')}>Tickets</a> / {data.ticketId}</p>
{#if data.ticket.data}
<div class="detail-heading">
<div>
<p class="eyebrow">{data.ticket.data.id}</p>
<h2>{data.ticket.data.title}</h2>
</div>
<span class="state-pill">{data.ticket.data.state}</span>
</div>
<dl class="ticket-detail-grid">
<div>
<dt>Priority</dt>
<dd>{data.ticket.data.priority ?? 'unspecified'}</dd>
</div>
<div>
<dt>Updated</dt>
<dd>{data.ticket.data.updated_at ? formatDate(data.ticket.data.updated_at) : 'unknown'}</dd>
</div>
<div>
<dt>Created</dt>
<dd>{data.ticket.data.created_at ? formatDate(data.ticket.data.created_at) : 'unknown'}</dd>
</div>
<div>
<dt>Events</dt>
<dd>{data.ticket.data.event_count}</dd>
</div>
<div>
<dt>Artifacts</dt>
<dd>{data.ticket.data.artifact_count}</dd>
</div>
<div>
<dt>Source</dt>
<dd>{data.ticket.data.record_source}</dd>
</div>
{#if data.ticket.data.queued_at || data.ticket.data.queued_by}
<div>
<dt>Queued</dt>
<dd>
{data.ticket.data.queued_at ? formatDate(data.ticket.data.queued_at) : 'queued'}{data.ticket.data.queued_by ? ` by ${data.ticket.data.queued_by}` : ''}
</dd>
</div>
{/if}
</dl>
{#if data.ticket.data.risk_flags.length > 0}
<div class="risk-flags" aria-label="Risk flags">
{#each data.ticket.data.risk_flags as flag}
<span>{flag}</span>
{/each}
</div>
{/if}
<section class="ticket-body" aria-labelledby="ticket-body-heading">
<div class="detail-heading compact">
<h3 id="ticket-body-heading">Body</h3>
{#if data.ticket.data.body_truncated}
<span class="warning-pill">truncated</span>
{/if}
</div>
<pre>{data.ticket.data.body || 'No body text is available.'}</pre>
</section>
{:else if data.ticket.error}
<p class="error">{data.ticket.error}</p>
{:else}
<p>Waiting for <code>/api/w/{data.workspaceId}/tickets/{data.ticketId}</code></p>
{/if}
</section>

View File

@ -0,0 +1,17 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { TicketDetail } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
const ticketId = params.ticketId;
const ticket = await loadJson<TicketDetail>(
fetch,
workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(ticketId)}`),
);
return {
workspaceId: params.workspaceId,
ticketId,
ticket,
};
}) satisfies PageLoad;