14 changed files with 1381 additions and 264 deletions
Generated
+1
View File
@@ -637,6 +637,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
name = "client" name = "client"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono",
"futures", "futures",
"manifest", "manifest",
"protocol", "protocol",
+1
View File
@@ -5,6 +5,7 @@ edition.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
chrono = { version = "0.4", default-features = false, features = ["clock"] }
protocol = { workspace = true } protocol = { workspace = true }
manifest = { workspace = true } manifest = { workspace = true }
ticket = { workspace = true } ticket = { workspace = true }
+768
View File
@@ -0,0 +1,768 @@
use chrono::{DateTime, Utc};
use reqwest::{Method, StatusCode, Url, redirect};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::env;
use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::Write as _;
use std::path::{Path, PathBuf};
const TOKEN_FILE_NAME: &str = "backend-tokens.json";
const MAX_REDIRECTS: usize = 10;
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BackendOrigin(String);
impl BackendOrigin {
pub fn parse(input: &str) -> Result<Self, BackendApiClientError> {
let url = Url::parse(input.trim()).map_err(|error| {
BackendApiClientError::InvalidBackendOrigin(format!(
"Backend URL is not a valid absolute URL: {error}"
))
})?;
if !url.path().bytes().all(|byte| byte == b'/')
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(BackendApiClientError::InvalidBackendOrigin(
"Backend URL must contain only an origin, without a path, query, or fragment"
.to_string(),
));
}
Self::from_url(url)
}
fn from_url(mut url: Url) -> Result<Self, BackendApiClientError> {
if !matches!(url.scheme(), "http" | "https") {
return Err(BackendApiClientError::InvalidBackendOrigin(
"Backend URL scheme must be http or https".to_string(),
));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(BackendApiClientError::InvalidBackendOrigin(
"Backend URL must not contain user information".to_string(),
));
}
if url.host().is_none() {
return Err(BackendApiClientError::InvalidBackendOrigin(
"Backend URL must contain a host".to_string(),
));
}
let default_port = match url.scheme() {
"http" => 80,
"https" => 443,
_ => unreachable!("validated Backend URL scheme"),
};
if url.port() == Some(default_port) {
url.set_port(None).map_err(|()| {
BackendApiClientError::InvalidBackendOrigin(
"Backend URL contains an invalid port".to_string(),
)
})?;
}
url.set_path("");
url.set_query(None);
url.set_fragment(None);
let normalized = url.as_str().trim_end_matches('/').to_string();
Ok(Self(normalized))
}
pub fn as_str(&self) -> &str {
&self.0
}
fn url(&self, path_and_query: &str) -> Result<Url, BackendApiClientError> {
if !path_and_query.starts_with('/') || path_and_query.starts_with("//") {
return Err(BackendApiClientError::InvalidRequestPath(
"Backend API request path must start with one `/`".to_string(),
));
}
Url::parse(&format!("{}{path_and_query}", self.0)).map_err(|error| {
BackendApiClientError::InvalidRequestPath(format!(
"Backend API request path is invalid: {error}"
))
})
}
}
impl fmt::Debug for BackendOrigin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("BackendOrigin").field(&self.0).finish()
}
}
impl fmt::Display for BackendOrigin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone)]
struct BackendAccessToken(String);
impl fmt::Debug for BackendAccessToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("BackendAccessToken([REDACTED])")
}
}
#[derive(Clone)]
pub struct BackendApiClient {
origin: BackendOrigin,
access_token: BackendAccessToken,
asynchronous: reqwest::Client,
}
impl fmt::Debug for BackendApiClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BackendApiClient")
.field("origin", &self.origin)
.field("access_token", &self.access_token)
.finish_non_exhaustive()
}
}
impl BackendApiClient {
pub fn from_stored_token(base_url: &str) -> Result<Self, BackendApiClientError> {
let path = backend_token_file_path()?;
Self::from_token_file(base_url, &path)
}
fn from_token_file(base_url: &str, path: &Path) -> Result<Self, BackendApiClientError> {
let origin = BackendOrigin::parse(base_url)?;
let token_file = read_token_file(path)?;
let entry = token_file.tokens.get(origin.as_str()).ok_or_else(|| {
BackendApiClientError::TokenEntryMissing {
origin: origin.clone(),
path: path.to_path_buf(),
}
})?;
validate_token_entry(entry, &origin, path)?;
Self::new(origin, BackendAccessToken(entry.access_token.clone()))
}
fn new(
origin: BackendOrigin,
access_token: BackendAccessToken,
) -> Result<Self, BackendApiClientError> {
let asynchronous = reqwest::Client::builder()
.redirect(redirect_policy(origin.clone()))
.build()
.map_err(BackendApiClientError::Http)?;
Ok(Self {
origin,
access_token,
asynchronous,
})
}
pub fn origin(&self) -> &BackendOrigin {
&self.origin
}
pub fn request(
&self,
method: Method,
path_and_query: &str,
) -> Result<reqwest::RequestBuilder, BackendApiClientError> {
let url = self.origin.url(path_and_query)?;
Ok(self
.asynchronous
.request(method, url)
.bearer_auth(&self.access_token.0))
}
pub fn blocking_request(
&self,
method: Method,
path_and_query: &str,
) -> Result<reqwest::blocking::RequestBuilder, BackendApiClientError> {
let url = self.origin.url(path_and_query)?;
let client = reqwest::blocking::Client::builder()
.redirect(redirect_policy(self.origin.clone()))
.build()
.map_err(BackendApiClientError::Http)?;
Ok(client
.request(method, url)
.bearer_auth(&self.access_token.0))
}
pub(crate) fn authorization_header_value(&self) -> String {
format!("Bearer {}", self.access_token.0)
}
pub fn check_status(&self, status: StatusCode) -> Result<(), BackendApiClientError> {
match status {
StatusCode::UNAUTHORIZED => Err(BackendApiClientError::Unauthorized {
origin: self.origin.clone(),
}),
StatusCode::FORBIDDEN => Err(BackendApiClientError::Forbidden {
origin: self.origin.clone(),
}),
status if !status.is_success() => Err(BackendApiClientError::BackendStatus {
origin: self.origin.clone(),
status: status.as_u16(),
}),
_ => Ok(()),
}
}
#[cfg(test)]
pub(crate) fn from_access_token_for_test(
base_url: &str,
access_token: &str,
) -> Result<Self, BackendApiClientError> {
Self::new(
BackendOrigin::parse(base_url)?,
BackendAccessToken(access_token.to_string()),
)
}
}
fn redirect_policy(origin: BackendOrigin) -> redirect::Policy {
redirect::Policy::custom(move |attempt| {
if attempt.previous().len() >= MAX_REDIRECTS {
return attempt.error("Backend request exceeded the redirect limit");
}
match BackendOrigin::from_url(attempt.url().clone()) {
Ok(target_origin) if target_origin == origin => attempt.follow(),
Ok(target_origin) => attempt.error(format!(
"Backend request refused a cross-origin redirect from {origin} to {target_origin}"
)),
Err(error) => attempt.error(error.to_string()),
}
})
}
#[derive(Debug)]
pub enum BackendApiClientError {
InvalidBackendOrigin(String),
InvalidRequestPath(String),
ConfigDirectoryUnavailable,
TokenFileMissing {
path: PathBuf,
},
TokenFileMalformed {
path: PathBuf,
message: String,
},
TokenEntryMissing {
origin: BackendOrigin,
path: PathBuf,
},
TokenExpired {
origin: BackendOrigin,
expired_at: String,
},
Http(reqwest::Error),
Unauthorized {
origin: BackendOrigin,
},
Forbidden {
origin: BackendOrigin,
},
BackendStatus {
origin: BackendOrigin,
status: u16,
},
Io {
path: PathBuf,
source: std::io::Error,
},
}
impl fmt::Display for BackendApiClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidBackendOrigin(message) | Self::InvalidRequestPath(message) => {
f.write_str(message)
}
Self::ConfigDirectoryUnavailable => f.write_str(
"cannot locate the client configuration directory for backend-tokens.json",
),
Self::TokenFileMissing { path } => write!(
f,
"Backend token file {} is missing; run `yoi login --backend <BACKEND>` first",
path.display()
),
Self::TokenFileMalformed { path, message } => write!(
f,
"Backend token file {} is malformed: {message}; run `yoi login --backend <BACKEND>` again",
path.display()
),
Self::TokenEntryMissing { origin, path } => write!(
f,
"no Backend token for {origin} exists in {}; login URLs are matched by normalized origin, so run `yoi login --backend {origin}`",
path.display()
),
Self::TokenExpired { origin, expired_at } => write!(
f,
"Backend token for {origin} expired at {expired_at}; run `yoi login --backend {origin}` again"
),
Self::Http(error) => write!(f, "Backend request failed: {error}"),
Self::Unauthorized { origin } => write!(
f,
"Backend {origin} returned HTTP 401 for the saved token; it may be expired or revoked, so run `yoi login --backend {origin}` again"
),
Self::Forbidden { origin } => write!(
f,
"Backend {origin} returned HTTP 403; the saved token is authenticated but is not authorized for this operation"
),
Self::BackendStatus { origin, status } => {
write!(f, "Backend {origin} returned HTTP {status}")
}
Self::Io { path, source } => {
write!(f, "failed to access {}: {source}", path.display())
}
}
}
}
impl std::error::Error for BackendApiClientError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Http(error) => Some(error),
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct BackendTokenFile {
tokens: BTreeMap<String, BackendTokenEntry>,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct BackendTokenEntry {
token_type: String,
access_token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
expires_at: Option<String>,
}
pub fn save_backend_token(
base_url: &str,
token_type: &str,
access_token: &str,
) -> Result<PathBuf, BackendApiClientError> {
save_backend_token_with_expiry(base_url, token_type, access_token, None)
}
fn save_backend_token_with_expiry(
base_url: &str,
token_type: &str,
access_token: &str,
expires_at: Option<String>,
) -> Result<PathBuf, BackendApiClientError> {
let path = backend_token_file_path()?;
save_backend_token_to_file(base_url, token_type, access_token, expires_at, &path)?;
Ok(path)
}
fn save_backend_token_to_file(
base_url: &str,
token_type: &str,
access_token: &str,
expires_at: Option<String>,
path: &Path,
) -> Result<(), BackendApiClientError> {
let origin = BackendOrigin::parse(base_url)?;
let mut token_file = if path.exists() {
read_token_file(&path)?
} else {
BackendTokenFile {
tokens: BTreeMap::new(),
}
};
let entry = BackendTokenEntry {
token_type: token_type.to_string(),
access_token: access_token.to_string(),
expires_at,
};
validate_token_entry(&entry, &origin, path)?;
token_file.tokens.insert(origin.to_string(), entry);
write_token_file(path, &token_file)?;
Ok(())
}
pub fn backend_token_file_path() -> Result<PathBuf, BackendApiClientError> {
if let Some(config_home) = env::var_os("XDG_CONFIG_HOME") {
return Ok(PathBuf::from(config_home).join("yoi").join(TOKEN_FILE_NAME));
}
let Some(home) = env::var_os("HOME") else {
return Err(BackendApiClientError::ConfigDirectoryUnavailable);
};
Ok(PathBuf::from(home)
.join(".config")
.join("yoi")
.join(TOKEN_FILE_NAME))
}
fn read_token_file(path: &Path) -> Result<BackendTokenFile, BackendApiClientError> {
let bytes = fs::read(path).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
BackendApiClientError::TokenFileMissing {
path: path.to_path_buf(),
}
} else {
BackendApiClientError::Io {
path: path.to_path_buf(),
source,
}
}
})?;
let raw: BackendTokenFile = serde_json::from_slice(&bytes).map_err(|error| {
BackendApiClientError::TokenFileMalformed {
path: path.to_path_buf(),
message: error.to_string(),
}
})?;
normalize_token_file(raw, path)
}
fn normalize_token_file(
token_file: BackendTokenFile,
path: &Path,
) -> Result<BackendTokenFile, BackendApiClientError> {
let mut normalized = BTreeMap::new();
for (raw_origin, entry) in token_file.tokens {
let origin = BackendOrigin::parse(&raw_origin).map_err(|error| {
BackendApiClientError::TokenFileMalformed {
path: path.to_path_buf(),
message: format!("token key `{raw_origin}` is invalid: {error}"),
}
})?;
if normalized.insert(origin.to_string(), entry).is_some() {
return Err(BackendApiClientError::TokenFileMalformed {
path: path.to_path_buf(),
message: format!("more than one token entry normalizes to `{origin}`"),
});
}
}
Ok(BackendTokenFile { tokens: normalized })
}
fn validate_token_entry(
entry: &BackendTokenEntry,
origin: &BackendOrigin,
path: &Path,
) -> Result<(), BackendApiClientError> {
if !entry.token_type.eq_ignore_ascii_case("Bearer") {
return Err(BackendApiClientError::TokenFileMalformed {
path: path.to_path_buf(),
message: format!("token for `{origin}` does not use the Bearer token type"),
});
}
if entry.access_token.trim().is_empty()
|| entry.access_token.contains('\r')
|| entry.access_token.contains('\n')
{
return Err(BackendApiClientError::TokenFileMalformed {
path: path.to_path_buf(),
message: format!("token for `{origin}` is empty or contains an invalid line break"),
});
}
if reqwest::header::HeaderValue::from_str(&format!("Bearer {}", entry.access_token)).is_err() {
return Err(BackendApiClientError::TokenFileMalformed {
path: path.to_path_buf(),
message: format!("token for `{origin}` cannot be represented as an HTTP header"),
});
}
if let Some(expires_at) = entry.expires_at.as_deref() {
let expiration = DateTime::parse_from_rfc3339(expires_at).map_err(|error| {
BackendApiClientError::TokenFileMalformed {
path: path.to_path_buf(),
message: format!("token for `{origin}` has invalid expires_at: {error}"),
}
})?;
if expiration <= Utc::now() {
return Err(BackendApiClientError::TokenExpired {
origin: origin.clone(),
expired_at: expires_at.to_string(),
});
}
}
Ok(())
}
fn write_token_file(
path: &Path,
token_file: &BackendTokenFile,
) -> Result<(), BackendApiClientError> {
let parent = path
.parent()
.ok_or(BackendApiClientError::ConfigDirectoryUnavailable)?;
fs::create_dir_all(parent).map_err(|source| BackendApiClientError::Io {
path: parent.to_path_buf(),
source,
})?;
let payload = serde_json::to_vec_pretty(token_file).map_err(|error| {
BackendApiClientError::TokenFileMalformed {
path: path.to_path_buf(),
message: error.to_string(),
}
})?;
let temp_path = parent.join(format!(".{TOKEN_FILE_NAME}.tmp-{}", std::process::id()));
let mut options = OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(&temp_path)
.map_err(|source| BackendApiClientError::Io {
path: temp_path.clone(),
source,
})?;
file.write_all(&payload)
.and_then(|()| file.write_all(b"\n"))
.and_then(|()| file.sync_all())
.map_err(|source| BackendApiClientError::Io {
path: temp_path.clone(),
source,
})?;
fs::rename(&temp_path, path).map_err(|source| BackendApiClientError::Io {
path: path.to_path_buf(),
source,
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
fn temp_path(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
env::temp_dir().join(format!(
"yoi-client-{label}-{}-{nonce}.json",
std::process::id()
))
}
fn write_fixture(path: &Path, value: serde_json::Value) {
fs::write(path, serde_json::to_vec(&value).unwrap()).unwrap();
}
#[test]
fn backend_origin_normalizes_safe_equivalents() {
let variants = [
"HTTP://Example.COM",
"http://example.com/",
"http://example.com:80////",
];
for variant in variants {
assert_eq!(
BackendOrigin::parse(variant).unwrap().as_str(),
"http://example.com"
);
}
assert_eq!(
BackendOrigin::parse("https://EXAMPLE.com:443/")
.unwrap()
.as_str(),
"https://example.com"
);
assert_eq!(
BackendOrigin::parse("https://example.com:8443/")
.unwrap()
.as_str(),
"https://example.com:8443"
);
}
#[test]
fn backend_origin_rejects_unsafe_authority_changes() {
for invalid in [
"ftp://example.com",
"https://user@example.com",
"https://example.com/api",
"https://example.com/?query=1",
"https://example.com/#fragment",
] {
assert!(BackendOrigin::parse(invalid).is_err(), "accepted {invalid}");
}
assert_ne!(
BackendOrigin::parse("http://localhost:8787").unwrap(),
BackendOrigin::parse("http://127.0.0.1:8787").unwrap()
);
}
#[test]
fn token_lookup_distinguishes_missing_malformed_mismatch_and_expired() {
let missing = temp_path("missing");
assert!(matches!(
BackendApiClient::from_token_file("http://localhost:8787", &missing),
Err(BackendApiClientError::TokenFileMissing { .. })
));
let malformed = temp_path("malformed");
fs::write(&malformed, b"not json").unwrap();
assert!(matches!(
BackendApiClient::from_token_file("http://localhost:8787", &malformed),
Err(BackendApiClientError::TokenFileMalformed { .. })
));
let mismatch = temp_path("mismatch");
write_fixture(
&mismatch,
serde_json::json!({"tokens": {"http://localhost:8787": {
"token_type": "Bearer", "access_token": "secret"
}}}),
);
assert!(matches!(
BackendApiClient::from_token_file("http://127.0.0.1:8787", &mismatch),
Err(BackendApiClientError::TokenEntryMissing { .. })
));
let expired = temp_path("expired");
write_fixture(
&expired,
serde_json::json!({"tokens": {"http://localhost:8787": {
"token_type": "Bearer",
"access_token": "secret",
"expires_at": "2000-01-01T00:00:00Z"
}}}),
);
assert!(matches!(
BackendApiClient::from_token_file("http://localhost:8787", &expired),
Err(BackendApiClientError::TokenExpired { .. })
));
for path in [malformed, mismatch, expired] {
let _ = fs::remove_file(path);
}
}
#[test]
fn token_write_and_lookup_share_origin_normalization() {
let path = temp_path("normalized-write");
save_backend_token_to_file(
"HTTP://Example.COM:80////",
"Bearer",
"normalized-secret",
None,
&path,
)
.unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("\"http://example.com\""));
let client = BackendApiClient::from_token_file("http://example.com/", &path).unwrap();
assert_eq!(
client.authorization_header_value(),
"Bearer normalized-secret"
);
fs::remove_file(path).unwrap();
}
#[test]
fn client_debug_and_errors_never_include_token_value() {
let client = BackendApiClient::from_access_token_for_test(
"http://localhost:8787",
"never-print-this-token",
)
.unwrap();
assert!(!format!("{client:?}").contains("never-print-this-token"));
assert!(
!BackendApiClientError::Unauthorized {
origin: client.origin().clone()
}
.to_string()
.contains("never-print-this-token")
);
}
#[test]
fn authenticated_requests_follow_only_same_origin_redirects() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let origin = format!("http://{}", listener.local_addr().unwrap());
let handle = thread::spawn(move || {
for response in [
"HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
] {
let (mut stream, _) = listener.accept().unwrap();
let mut request = vec![0; 4096];
let read = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
assert!(request.contains("authorization: bearer redirect-secret\r\n"));
stream.write_all(response.as_bytes()).unwrap();
}
});
let client =
BackendApiClient::from_access_token_for_test(&origin, "redirect-secret").unwrap();
let response = client
.blocking_request(Method::GET, "/start")
.unwrap()
.send()
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
handle.join().unwrap();
}
#[test]
fn authenticated_requests_reject_cross_origin_redirects_without_leaking_token() {
let source = TcpListener::bind("127.0.0.1:0").unwrap();
let target = TcpListener::bind("127.0.0.1:0").unwrap();
target.set_nonblocking(true).unwrap();
let source_origin = format!("http://{}", source.local_addr().unwrap());
let target_origin = format!("http://{}", target.local_addr().unwrap());
let location = format!("{target_origin}/capture");
let handle = thread::spawn(move || {
let (mut stream, _) = source.accept().unwrap();
let mut request = vec![0; 4096];
let read = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
assert!(request.contains("authorization: bearer redirect-secret\r\n"));
let response = format!(
"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
stream.write_all(response.as_bytes()).unwrap();
});
let client =
BackendApiClient::from_access_token_for_test(&source_origin, "redirect-secret")
.unwrap();
let error = client
.blocking_request(Method::GET, "/start")
.unwrap()
.send()
.unwrap_err();
let message = error.to_string();
assert!(message.contains("redirect"));
assert!(!message.contains("redirect-secret"));
handle.join().unwrap();
thread::sleep(Duration::from_millis(20));
assert!(matches!(
target.accept(),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock
));
}
#[test]
fn status_diagnostics_distinguish_unauthorized_and_forbidden() {
let client =
BackendApiClient::from_access_token_for_test("http://localhost:8787", "secret")
.unwrap();
assert!(matches!(
client.check_status(StatusCode::UNAUTHORIZED),
Err(BackendApiClientError::Unauthorized { .. })
));
assert!(matches!(
client.check_status(StatusCode::FORBIDDEN),
Err(BackendApiClientError::Forbidden { .. })
));
}
}
+6 -3
View File
@@ -1,3 +1,4 @@
use crate::BackendOrigin;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use std::time::Duration; use std::time::Duration;
@@ -9,9 +10,11 @@ pub struct BackendAuthTarget {
impl BackendAuthTarget { impl BackendAuthTarget {
pub fn new(base_url: impl Into<String>) -> Self { pub fn new(base_url: impl Into<String>) -> Self {
Self { let base_url = base_url.into();
base_url: base_url.into(), let base_url = BackendOrigin::parse(&base_url)
} .map(|origin| origin.to_string())
.unwrap_or(base_url);
Self { base_url }
} }
fn api_url(&self, path: &str) -> String { fn api_url(&self, path: &str) -> String {
+136 -51
View File
@@ -1,11 +1,16 @@
use crate::{BackendApiClient, BackendApiClientError};
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
use protocol::stream::{decode_event, encode_method}; use protocol::stream::{decode_event, encode_method};
use protocol::{ErrorCode, Event, Method}; use protocol::{ErrorCode, Event, Method};
use reqwest::Method as HttpMethod;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::fmt; use std::fmt;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_tungstenite::connect_async; use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary; pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
pub use workspace_api::{ pub use workspace_api::{
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity, Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
@@ -113,6 +118,7 @@ pub struct BackendRuntimeClient {
#[derive(Debug)] #[derive(Debug)]
pub enum BackendRuntimeClientError { pub enum BackendRuntimeClientError {
InvalidTarget(String), InvalidTarget(String),
Api(BackendApiClientError),
Http(reqwest::Error), Http(reqwest::Error),
} }
@@ -120,6 +126,7 @@ impl fmt::Display for BackendRuntimeClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::InvalidTarget(message) => f.write_str(message), Self::InvalidTarget(message) => f.write_str(message),
Self::Api(error) => write!(f, "{error}"),
Self::Http(error) => write!(f, "{error}"), Self::Http(error) => write!(f, "{error}"),
} }
} }
@@ -127,6 +134,12 @@ impl fmt::Display for BackendRuntimeClientError {
impl std::error::Error for BackendRuntimeClientError {} impl std::error::Error for BackendRuntimeClientError {}
impl From<BackendApiClientError> for BackendRuntimeClientError {
fn from(error: BackendApiClientError) -> Self {
Self::Api(error)
}
}
impl From<reqwest::Error> for BackendRuntimeClientError { impl From<reqwest::Error> for BackendRuntimeClientError {
fn from(error: reqwest::Error) -> Self { fn from(error: reqwest::Error) -> Self {
Self::Http(error) Self::Http(error)
@@ -137,7 +150,7 @@ pub async fn list_backend_workers(
target: &BackendRuntimeListTarget, target: &BackendRuntimeListTarget,
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> { ) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
validate_list_target(target)?; validate_list_target(target)?;
let http = reqwest::Client::new(); let api = BackendApiClient::from_stored_token(&target.base_url)?;
if let Some(runtime_id) = target.runtime_id.as_deref() { if let Some(runtime_id) = target.runtime_id.as_deref() {
let path = backend_runtime_workers_path( let path = backend_runtime_workers_path(
target target
@@ -146,12 +159,9 @@ pub async fn list_backend_workers(
.expect("validated Backend Workspace scope"), .expect("validated Backend Workspace scope"),
runtime_id, runtime_id,
); );
let url = join_base_and_path(&target.base_url, &path); let response = api.request(HttpMethod::GET, &path)?.send().await?;
return Ok(http api.check_status(response.status())?;
.get(url) return Ok(response
.send()
.await?
.error_for_status()?
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>() .json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.await?); .await?);
} }
@@ -162,12 +172,9 @@ pub async fn list_backend_workers(
.as_deref() .as_deref()
.expect("validated Backend Workspace scope"), .expect("validated Backend Workspace scope"),
); );
let runtime_url = join_base_and_path(&target.base_url, &runtime_path); let response = api.request(HttpMethod::GET, &runtime_path)?.send().await?;
let runtimes = http api.check_status(response.status())?;
.get(runtime_url) let runtimes = response
.send()
.await?
.error_for_status()?
.json::<BackendRuntimeListResponse<BackendRuntimeSummary>>() .json::<BackendRuntimeListResponse<BackendRuntimeSummary>>()
.await?; .await?;
@@ -181,29 +188,43 @@ pub async fn list_backend_workers(
.expect("validated Backend Workspace scope"), .expect("validated Backend Workspace scope"),
&runtime.runtime_id, &runtime.runtime_id,
); );
let url = join_base_and_path(&target.base_url, &path); let response = match api.request(HttpMethod::GET, &path)?.send().await {
match http Ok(response) => response,
.get(url) Err(error) => {
.send() diagnostics.push(BackendDiagnostic {
.await code: "runtime_worker_list_failed".to_string(),
.and_then(|response| response.error_for_status()) severity: BackendDiagnosticSeverity::Error,
{ message: format!(
Ok(response) => { "failed to list workers for runtime {}: {error}",
let response = response runtime.runtime_id
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>() ),
.await?; });
diagnostics.extend(response.diagnostics); continue;
items.extend(response.items);
} }
Err(error) => diagnostics.push(BackendDiagnostic { };
if matches!(
response.status(),
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
) {
api.check_status(response.status())?;
}
if !response.status().is_success() {
diagnostics.push(BackendDiagnostic {
code: "runtime_worker_list_failed".to_string(), code: "runtime_worker_list_failed".to_string(),
severity: BackendDiagnosticSeverity::Error, severity: BackendDiagnosticSeverity::Error,
message: format!( message: format!(
"failed to list workers for runtime {}: {error}", "failed to list workers for runtime {}: Backend returned HTTP {}",
runtime.runtime_id runtime.runtime_id,
response.status().as_u16()
), ),
}), });
continue;
} }
let response = response
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.await?;
diagnostics.extend(response.diagnostics);
items.extend(response.items);
} }
Ok(BackendRuntimeListResponse { Ok(BackendRuntimeListResponse {
@@ -224,7 +245,7 @@ pub async fn list_backend_stopped_workers(
"stopped worker listing requires a runtime id".to_string(), "stopped worker listing requires a runtime id".to_string(),
)); ));
}; };
let http = reqwest::Client::new(); let api = BackendApiClient::from_stored_token(&target.base_url)?;
let path = backend_runtime_workers_path( let path = backend_runtime_workers_path(
target target
.workspace_id .workspace_id
@@ -232,12 +253,12 @@ pub async fn list_backend_stopped_workers(
.expect("validated Backend Workspace scope"), .expect("validated Backend Workspace scope"),
runtime_id, runtime_id,
); );
let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped")); let response = api
Ok(http .request(HttpMethod::GET, &format!("{path}?status=stopped"))?
.get(url)
.send() .send()
.await? .await?;
.error_for_status()? api.check_status(response.status())?;
Ok(response
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>() .json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.await?) .await?)
} }
@@ -246,33 +267,33 @@ pub async fn restore_backend_worker(
target: &BackendRuntimeTarget, target: &BackendRuntimeTarget,
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> { ) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
validate_target(target)?; validate_target(target)?;
let http = reqwest::Client::new(); let api = BackendApiClient::from_stored_token(&target.base_url)?;
let path = backend_runtime_worker_restore_path( let path = backend_runtime_worker_restore_path(
&target.workspace_id, &target.workspace_id,
&target.runtime_id, &target.runtime_id,
&target.worker_id, &target.worker_id,
); );
let url = join_base_and_path(&target.base_url, &path); let response = api
Ok(http .request(HttpMethod::POST, &path)?
.post(url)
.json(&serde_json::json!({})) .json(&serde_json::json!({}))
.send() .send()
.await? .await?;
.error_for_status()? api.check_status(response.status())?;
.json::<BackendWorkerRestoreResponse>() Ok(response.json::<BackendWorkerRestoreResponse>().await?)
.await?)
} }
impl BackendRuntimeClient { impl BackendRuntimeClient {
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> { pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
validate_target(&target)?; validate_target(&target)?;
let api = BackendApiClient::from_stored_token(&target.base_url)?;
let (event_tx, rx) = mpsc::unbounded_channel(); let (event_tx, rx) = mpsc::unbounded_channel();
let (command_tx, command_rx) = mpsc::unbounded_channel(); let (command_tx, command_rx) = mpsc::unbounded_channel();
let protocol_target = target.clone(); let protocol_target = target.clone();
let protocol_event_tx = event_tx.clone(); let protocol_event_tx = event_tx.clone();
let protocol_task = tokio::spawn(async move { let protocol_task = tokio::spawn(async move {
run_worker_protocol_transport(protocol_target, command_rx, protocol_event_tx).await; run_worker_protocol_transport(protocol_target, api, command_rx, protocol_event_tx)
.await;
}); });
Ok(Self { Ok(Self {
@@ -317,11 +338,21 @@ impl Drop for BackendRuntimeClient {
async fn run_worker_protocol_transport( async fn run_worker_protocol_transport(
target: BackendRuntimeTarget, target: BackendRuntimeTarget,
api: BackendApiClient,
mut commands: mpsc::UnboundedReceiver<Method>, mut commands: mpsc::UnboundedReceiver<Method>,
tx: mpsc::UnboundedSender<Event>, tx: mpsc::UnboundedSender<Event>,
) { ) {
let url = protocol_ws_url(&target); let request = match protocol_ws_request(&target, &api) {
match connect_async(&url).await { Ok(request) => request,
Err(error) => {
let _ = tx.send(diagnostic_event(format!(
"Backend protocol request could not be constructed for {}: {error}",
target.display_label()
)));
return;
}
};
match connect_async(request).await {
Ok((ws, _)) => { Ok((ws, _)) => {
let (mut sink, mut stream) = ws.split(); let (mut sink, mut stream) = ws.split();
loop { loop {
@@ -387,10 +418,8 @@ async fn run_worker_protocol_transport(
} }
} }
Err(error) => { Err(error) => {
let _ = tx.send(diagnostic_event(format!( let message = protocol_connect_error_message(&target, &api, &error);
"Backend protocol WebSocket connect failed for {}: {error}", let _ = tx.send(diagnostic_event(message));
target.display_label()
)));
while commands.recv().await.is_some() { while commands.recv().await.is_some() {
let _ = tx.send(diagnostic_event(format!( let _ = tx.send(diagnostic_event(format!(
"Backend protocol command was not sent because command stream is unavailable for {}", "Backend protocol command was not sent because command stream is unavailable for {}",
@@ -401,6 +430,29 @@ async fn run_worker_protocol_transport(
} }
} }
fn protocol_connect_error_message(
target: &BackendRuntimeTarget,
api: &BackendApiClient,
error: &tokio_tungstenite::tungstenite::Error,
) -> String {
if let tokio_tungstenite::tungstenite::Error::Http(response) = error {
if let Ok(status) = reqwest::StatusCode::from_u16(response.status().as_u16()) {
if matches!(
status,
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
) {
if let Err(error) = api.check_status(status) {
return error.to_string();
}
}
}
}
format!(
"Backend protocol WebSocket connect failed for {}: {error}",
target.display_label()
)
}
fn diagnostic_event(message: impl Into<String>) -> Event { fn diagnostic_event(message: impl Into<String>) -> Event {
Event::Error { Event::Error {
code: ErrorCode::Internal, code: ErrorCode::Internal,
@@ -496,6 +548,19 @@ fn backend_runtime_worker_restore_path(
) )
} }
fn protocol_ws_request(
target: &BackendRuntimeTarget,
api: &BackendApiClient,
) -> Result<tokio_tungstenite::tungstenite::http::Request<()>, String> {
let mut request = protocol_ws_url(target)
.into_client_request()
.map_err(|error| error.to_string())?;
let value = HeaderValue::from_str(&api.authorization_header_value())
.map_err(|_| "saved Backend token is not a valid Authorization header".to_string())?;
request.headers_mut().insert(AUTHORIZATION, value);
Ok(request)
}
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String { fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
let path = format!( let path = format!(
"/api/w/{}/runtimes/{}/workers/{}/protocol/ws", "/api/w/{}/runtimes/{}/workers/{}/protocol/ws",
@@ -557,6 +622,26 @@ mod tests {
); );
} }
#[test]
fn protocol_request_attaches_saved_bearer_authorization() {
let target = BackendRuntimeTarget::new(
"http://127.0.0.1:8787/",
"workspace alpha",
"runtime/one",
"worker one",
);
let api = BackendApiClient::from_access_token_for_test(
"http://127.0.0.1:8787",
"websocket-secret",
)
.unwrap();
let request = protocol_ws_request(&target, &api).unwrap();
assert_eq!(
request.headers().get(AUTHORIZATION).unwrap(),
"Bearer websocket-secret"
);
}
#[test] #[test]
fn backend_worker_summary_decodes_current_occupied_workdir_contract() { fn backend_worker_summary_decodes_current_occupied_workdir_contract() {
let payload = serde_json::json!({ let payload = serde_json::json!({
+60 -38
View File
@@ -1,3 +1,5 @@
use crate::{BackendApiClient, BackendApiClientError};
use reqwest::Method;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use workspace_api::{RepositoryObservedStatus, RepositorySource}; use workspace_api::{RepositoryObservedStatus, RepositorySource};
@@ -70,7 +72,7 @@ impl BackendWorkspaceCatalogTarget {
#[derive(Debug)] #[derive(Debug)]
pub enum BackendWorkspaceClientError { pub enum BackendWorkspaceClientError {
InvalidTarget(String), InvalidTarget(String),
RequestFailed { status: u16, message: String }, Api(BackendApiClientError),
Http(reqwest::Error), Http(reqwest::Error),
} }
@@ -78,9 +80,7 @@ impl fmt::Display for BackendWorkspaceClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::InvalidTarget(message) => f.write_str(message), Self::InvalidTarget(message) => f.write_str(message),
Self::RequestFailed { status, message } => { Self::Api(error) => write!(f, "{error}"),
write!(f, "Backend request failed with HTTP {status}: {message}")
}
Self::Http(error) => write!(f, "{error}"), Self::Http(error) => write!(f, "{error}"),
} }
} }
@@ -88,6 +88,12 @@ impl fmt::Display for BackendWorkspaceClientError {
impl std::error::Error for BackendWorkspaceClientError {} impl std::error::Error for BackendWorkspaceClientError {}
impl From<BackendApiClientError> for BackendWorkspaceClientError {
fn from(error: BackendApiClientError) -> Self {
Self::Api(error)
}
}
impl From<reqwest::Error> for BackendWorkspaceClientError { impl From<reqwest::Error> for BackendWorkspaceClientError {
fn from(error: reqwest::Error) -> Self { fn from(error: reqwest::Error) -> Self {
Self::Http(error) Self::Http(error)
@@ -97,13 +103,21 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
pub async fn list_backend_workspaces( pub async fn list_backend_workspaces(
target: &BackendWorkspaceCatalogTarget, target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> { ) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
validate_target(target)?; let client = BackendApiClient::from_stored_token(&target.base_url)?;
let url = format!( list_backend_workspaces_with_client(&client).await
"{}/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}", }
target.base_url.trim_end_matches('/')
); async fn list_backend_workspaces_with_client(
let response = reqwest::Client::new().get(url).send().await?; client: &BackendApiClient,
let response = require_success(response).await?; ) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
let response = client
.request(
Method::GET,
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
)?
.send()
.await?;
client.check_status(response.status())?;
Ok(response.json::<Vec<BackendWorkspace>>().await?) Ok(response.json::<Vec<BackendWorkspace>>().await?)
} }
@@ -111,42 +125,50 @@ pub async fn create_backend_workspace(
target: &BackendWorkspaceCatalogTarget, target: &BackendWorkspaceCatalogTarget,
request: &CreateBackendWorkspaceRequest, request: &CreateBackendWorkspaceRequest,
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> { ) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
validate_target(target)?; let client = BackendApiClient::from_stored_token(&target.base_url)?;
let url = format!("{}/api/workspaces", target.base_url.trim_end_matches('/')); let response = client
let response = reqwest::Client::new() .request(Method::POST, "/api/workspaces")?
.post(url)
.json(request) .json(request)
.send() .send()
.await?; .await?;
let response = require_success(response).await?; client.check_status(response.status())?;
Ok(response.json::<CreateBackendWorkspaceResponse>().await?) Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
} }
async fn require_success(
response: reqwest::Response,
) -> Result<reqwest::Response, BackendWorkspaceClientError> {
if response.status().is_success() {
return Ok(response);
}
let status = response.status().as_u16();
let message = response.text().await.unwrap_or_default();
Err(BackendWorkspaceClientError::RequestFailed { status, message })
}
fn validate_target(
target: &BackendWorkspaceCatalogTarget,
) -> Result<(), BackendWorkspaceClientError> {
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
return Err(BackendWorkspaceClientError::InvalidTarget(
"Backend API base URL must start with http:// or https://".to_string(),
));
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
#[tokio::test]
async fn workspace_catalog_request_uses_shared_bearer_client() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = vec![0; 4096];
let read = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
assert!(request.starts_with("get /api/workspaces?limit=200 "));
assert!(request.contains("authorization: bearer catalog-secret\r\n"));
stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]",
)
.unwrap();
});
let client =
BackendApiClient::from_access_token_for_test(&base_url, "catalog-secret").unwrap();
assert!(
list_backend_workspaces_with_client(&client)
.await
.unwrap()
.is_empty()
);
handle.join().unwrap();
}
#[test] #[test]
fn create_request_keeps_operation_key_for_exact_retry() { fn create_request_keeps_operation_key_for_exact_retry() {
+6 -1
View File
@@ -8,7 +8,8 @@
//! //!
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。 //! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
pub mod backend_auth; pub mod backend_api;
mod backend_auth;
pub mod backend_runtime; pub mod backend_runtime;
pub mod backend_workspace; pub mod backend_workspace;
pub mod runtime_command; pub mod runtime_command;
@@ -18,6 +19,10 @@ pub mod ticket_role;
mod worker_client; mod worker_client;
mod workspace_product; mod workspace_product;
pub use backend_api::{
BackendApiClient, BackendApiClientError, BackendOrigin, backend_token_file_path,
save_backend_token,
};
pub use backend_auth::{ pub use backend_auth::{
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse, BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
poll_device_login, start_device_login, wait_for_device_login, poll_device_login, start_device_login, wait_for_device_login,
+13 -2
View File
@@ -1,6 +1,9 @@
use std::fmt; use std::fmt;
use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand}; use crate::{
BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget,
BackendRuntimeTarget, WorkerRuntimeCommand,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetKind { pub enum TargetKind {
@@ -62,11 +65,19 @@ pub struct BackendTarget {
impl BackendTarget { impl BackendTarget {
pub fn new(base_url: impl Into<String>, workspace_id: Option<impl Into<String>>) -> Self { pub fn new(base_url: impl Into<String>, workspace_id: Option<impl Into<String>>) -> Self {
let base_url = base_url.into();
let base_url = BackendOrigin::parse(&base_url)
.map(|origin| origin.to_string())
.unwrap_or(base_url);
Self { Self {
base_url: base_url.into(), base_url,
workspace_id: workspace_id.map(Into::into), workspace_id: workspace_id.map(Into::into),
} }
} }
pub fn authenticated_client(&self) -> Result<BackendApiClient, BackendApiClientError> {
BackendApiClient::from_stored_token(&self.base_url)
}
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
+77 -47
View File
@@ -14,7 +14,7 @@ use workspace_api::{
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
}; };
use crate::BackendWorkspaceClientError; use crate::{BackendApiClient, BackendWorkspaceClientError};
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000; const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
@@ -47,9 +47,9 @@ struct BackendWorkspaceOrchestratorResponse {
/// Construction requires both the selected Backend URL and Workspace identity. /// Construction requires both the selected Backend URL and Workspace identity.
/// Callers should derive these once from `Target::resolve()` and must not retry /// Callers should derive these once from `Target::resolve()` and must not retry
/// failed requests against repository-local state. /// failed requests against repository-local state.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone)]
pub struct BackendWorkspaceProductClient { pub struct BackendWorkspaceProductClient {
base_url: String, api: BackendApiClient,
workspace_id: String, workspace_id: String,
} }
@@ -58,22 +58,32 @@ impl BackendWorkspaceProductClient {
base_url: impl Into<String>, base_url: impl Into<String>,
workspace_id: impl Into<String>, workspace_id: impl Into<String>,
) -> Result<Self, BackendWorkspaceClientError> { ) -> Result<Self, BackendWorkspaceClientError> {
let base_url = base_url.into().trim_end_matches('/').to_string(); let base_url = base_url.into();
if base_url.is_empty() { let api = BackendApiClient::from_stored_token(&base_url)?;
return Err(BackendWorkspaceClientError::InvalidTarget(
"Backend base URL must not be empty".into(),
));
}
let workspace_id = workspace_id.into(); let workspace_id = workspace_id.into();
if workspace_id.trim().is_empty() { if workspace_id.trim().is_empty() {
return Err(BackendWorkspaceClientError::InvalidTarget( return Err(BackendWorkspaceClientError::InvalidTarget(
"Backend Workspace identity must not be empty".into(), "Backend Workspace identity must not be empty".into(),
)); ));
} }
Ok(Self { Ok(Self { api, workspace_id })
base_url, }
workspace_id,
}) #[cfg(test)]
fn new_with_access_token(
base_url: impl Into<String>,
workspace_id: impl Into<String>,
access_token: &str,
) -> Result<Self, BackendWorkspaceClientError> {
let base_url = base_url.into();
let api = BackendApiClient::from_access_token_for_test(&base_url, access_token)?;
let workspace_id = workspace_id.into();
if workspace_id.trim().is_empty() {
return Err(BackendWorkspaceClientError::InvalidTarget(
"Backend Workspace identity must not be empty".into(),
));
}
Ok(Self { api, workspace_id })
} }
pub fn workspace_id(&self) -> &str { pub fn workspace_id(&self) -> &str {
@@ -316,7 +326,7 @@ impl BackendWorkspaceProductClient {
body: Option<&B>, body: Option<&B>,
) -> Result<R, BackendWorkspaceClientError> { ) -> Result<R, BackendWorkspaceClientError> {
let response = self.request(method, path, body)?.send()?; let response = self.request(method, path, body)?.send()?;
let response = ensure_success(response)?; self.api.check_status(response.status())?;
response.json().map_err(BackendWorkspaceClientError::Http) response.json().map_err(BackendWorkspaceClientError::Http)
} }
@@ -326,7 +336,8 @@ impl BackendWorkspaceProductClient {
path: &str, path: &str,
body: Option<&B>, body: Option<&B>,
) -> Result<(), BackendWorkspaceClientError> { ) -> Result<(), BackendWorkspaceClientError> {
ensure_success(self.request(method, path, body)?.send()?)?; let response = self.request(method, path, body)?.send()?;
self.api.check_status(response.status())?;
Ok(()) Ok(())
} }
@@ -336,14 +347,12 @@ impl BackendWorkspaceProductClient {
path: &str, path: &str,
body: Option<&B>, body: Option<&B>,
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> { ) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
let client = reqwest::blocking::Client::builder().build()?; let path = format!(
let url = format!( "/api/w/{}/{}",
"{}/api/w/{}/{}",
self.base_url,
encode_path_segment(&self.workspace_id), encode_path_segment(&self.workspace_id),
path.trim_start_matches('/') path.trim_start_matches('/')
); );
let request = client.request(method, url); let request = self.api.blocking_request(method, &path)?;
Ok(match body { Ok(match body {
Some(body) => request.json(body), Some(body) => request.json(body),
None => request, None => request,
@@ -588,19 +597,6 @@ fn ticket_client_error(error: BackendWorkspaceClientError) -> TicketError {
TicketError::Sqlite(format!("Backend request failed: {error}")) TicketError::Sqlite(format!("Backend request failed: {error}"))
} }
fn ensure_success(
response: reqwest::blocking::Response,
) -> Result<reqwest::blocking::Response, BackendWorkspaceClientError> {
if response.status().is_success() {
return Ok(response);
}
let status = response.status().as_u16();
let message = response
.text()
.unwrap_or_else(|_| "Backend request failed".to_string());
Err(BackendWorkspaceClientError::RequestFailed { status, message })
}
fn ticket_reference(id: &TicketIdOrSlug) -> String { fn ticket_reference(id: &TicketIdOrSlug) -> String {
match id { match id {
TicketIdOrSlug::Id(id) => id.to_string(), TicketIdOrSlug::Id(id) => id.to_string(),
@@ -698,24 +694,32 @@ mod tests {
fn objective_list_uses_workspace_scoped_backend_route() { fn objective_list_uses_workspace_scoped_backend_route() {
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#; let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
let (base_url, request, handle) = one_response_server("200 OK", body); let (base_url, request, handle) = one_response_server("200 OK", body);
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let response = client.list_objectives(1_000).unwrap(); let response = client.list_objectives(1_000).unwrap();
assert!(response.items.is_empty()); assert!(response.items.is_empty());
assert!( let request = request.recv().unwrap();
request assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 "));
.recv() assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
.unwrap()
.starts_with("GET /api/w/workspace-a/objectives?limit=1000 ")
);
handle.join().unwrap(); handle.join().unwrap();
} }
#[test] #[test]
fn backend_mutation_failure_is_returned_without_local_fallback() { fn backend_mutation_failure_is_returned_without_local_fallback() {
let (base_url, request, handle) = one_response_server("403 Forbidden", "denied"); let (base_url, request, handle) =
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); one_response_server("403 Forbidden", "test-backend-token");
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let error = client let error = client
.create_objective(&ObjectiveCreateRequest { .create_objective(&ObjectiveCreateRequest {
@@ -727,6 +731,7 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert!(error.to_string().contains("403")); assert!(error.to_string().contains("403"));
assert!(!error.to_string().contains("test-backend-token"));
assert!( assert!(
request request
.recv() .recv()
@@ -739,7 +744,12 @@ mod tests {
#[test] #[test]
fn ticket_relation_query_uses_workspace_scoped_backend_route() { fn ticket_relation_query_uses_workspace_scoped_backend_route() {
let (base_url, request, handle) = one_response_server("200 OK", "[]"); let (base_url, request, handle) = one_response_server("200 OK", "[]");
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let relations = client let relations = client
.query_ticket_relations( .query_ticket_relations(
@@ -758,7 +768,12 @@ mod tests {
#[test] #[test]
fn orchestration_plan_query_uses_workspace_scoped_backend_route() { fn orchestration_plan_query_uses_workspace_scoped_backend_route() {
let (base_url, request, handle) = one_response_server("200 OK", "[]"); let (base_url, request, handle) = one_response_server("200 OK", "[]");
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap(); let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap();
@@ -784,7 +799,12 @@ mod tests {
r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#, r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#,
), ),
]); ]);
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let status = client.launch_ticket_intake("T-1").unwrap(); let status = client.launch_ticket_intake("T-1").unwrap();
@@ -806,7 +826,12 @@ mod tests {
fn workspace_orchestrator_launch_uses_scoped_backend_route() { fn workspace_orchestrator_launch_uses_scoped_backend_route() {
let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#; let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#;
let (base_url, request, handle) = one_response_server("200 OK", body); let (base_url, request, handle) = one_response_server("200 OK", body);
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let status = client.start_workspace_orchestrator().unwrap(); let status = client.start_workspace_orchestrator().unwrap();
@@ -822,7 +847,12 @@ mod tests {
#[test] #[test]
fn product_client_requires_workspace_identity() { fn product_client_requires_workspace_identity() {
let error = BackendWorkspaceProductClient::new("http://127.0.0.1:8787", "").unwrap_err(); let error = BackendWorkspaceProductClient::new_with_access_token(
"http://127.0.0.1:8787",
"",
"test-backend-token",
)
.unwrap_err();
assert!(error.to_string().contains("Workspace identity")); assert!(error.to_string().contains("Workspace identity"));
} }
@@ -422,27 +422,11 @@ fn project_relation(
kind_key: &str, kind_key: &str,
) -> Result<ModelRelation, String> { ) -> Result<ModelRelation, String> {
let relation = object(value, "Ticket relation")?; let relation = object(value, "Ticket relation")?;
let relation_data = relation.get("relation").and_then(Value::as_object);
let kind = if kind_key == "kind" {
relation_data
.ok_or_else(|| "Ticket relation is missing relation data".to_string())
.and_then(|data| string_field(data, "kind"))?
} else {
string_field(relation, kind_key)?
};
let note = match relation_data {
Some(data) => optional_string(data, "note")?,
None => optional_string(relation, "note")?,
};
let created_at = match relation_data {
Some(data) => optional_string(data, "at")?,
None => optional_string(relation, "at")?,
};
Ok(ModelRelation { Ok(ModelRelation {
ticket: resource_ref(relation, ticket_key, "T-")?, ticket: resource_ref(relation, ticket_key, "T-")?,
kind, kind: string_field(relation, kind_key)?,
note, note: optional_string(relation, "note")?,
created_at, created_at: optional_string(relation, "at")?,
}) })
} }
@@ -776,6 +760,95 @@ mod tests {
assert!(!objective_json.contains("00001TICKETINTERNAL")); assert!(!objective_json.contains("00001TICKETINTERNAL"));
} }
#[test]
fn relation_projection_accepts_current_workspace_api_shapes() {
let outgoing = project_relation(
&json!({
"ticket_id": "internal-source-ticket",
"kind": "depends_on",
"target": "internal-target-ticket",
"target_resource_key": "T-535",
"note": "required foundation",
"author": "internal-author",
"at": "2026-08-22T00:00:00Z"
}),
"target_resource_key",
"kind",
)
.expect("outgoing relation projection");
let incoming = project_relation(
&json!({
"source_ticket": "internal-source-ticket",
"source_resource_key": "T-536",
"inverse_kind": "blocks",
"forward_kind": "depends_on",
"note": null,
"author": "internal-author",
"at": "2026-08-22T00:01:00Z"
}),
"source_resource_key",
"forward_kind",
)
.expect("incoming relation projection");
let outgoing = serde_json::to_value(outgoing).expect("serialize outgoing relation");
assert_eq!(
outgoing,
json!({
"ticket": "T-535",
"kind": "depends_on",
"note": "required foundation",
"created_at": "2026-08-22T00:00:00Z"
})
);
let incoming = serde_json::to_value(incoming).expect("serialize incoming relation");
assert_eq!(
incoming,
json!({
"ticket": "T-536",
"kind": "depends_on",
"note": null,
"created_at": "2026-08-22T00:01:00Z"
})
);
let projection = format!("{outgoing}{incoming}");
for internal in [
"internal-source-ticket",
"internal-target-ticket",
"internal-author",
] {
assert!(!projection.contains(internal));
}
}
#[test]
fn relation_projection_rejects_missing_workspace_keys() {
let outgoing = json!({
"kind": "depends_on",
"target": "internal-target-ticket",
"note": null,
"author": "internal-author",
"at": "2026-08-22T00:00:00Z"
});
let incoming = json!({
"source_resource_key": "not-a-ticket-key",
"forward_kind": "depends_on",
"note": null,
"author": "internal-author",
"at": "2026-08-22T00:01:00Z"
});
assert!(
project_relation(&outgoing, "target_resource_key", "kind")
.expect_err("missing outgoing key must fail")
.contains("T-")
);
assert!(
project_relation(&incoming, "source_resource_key", "forward_kind")
.expect_err("invalid incoming key must fail")
.contains("T-")
);
}
#[test] #[test]
fn resource_projection_rejects_noncanonical_keys() { fn resource_projection_rejects_noncanonical_keys() {
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] { for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
+31 -2
View File
@@ -105,8 +105,13 @@ pub fn token_hash(token: &str) -> String {
} }
pub fn new_user_code() -> String { pub fn new_user_code() -> String {
let hex = Uuid::now_v7().simple().to_string().to_ascii_uppercase(); user_code_from_uuid(Uuid::now_v7())
format!("{}-{}", &hex[0..4], &hex[4..8]) }
fn user_code_from_uuid(id: Uuid) -> String {
let hex = id.simple().to_string().to_ascii_uppercase();
let random_tail = &hex[24..32];
format!("{}-{}", &random_tail[..4], &random_tail[4..])
} }
pub fn parse_bearer(headers: &HeaderMap) -> Option<String> { pub fn parse_bearer(headers: &HeaderMap) -> Option<String> {
@@ -207,3 +212,27 @@ pub fn auth_error(code: &str, message: &str) -> Error {
message: message.to_string(), message: message.to_string(),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_code_uses_the_uuid_random_tail() {
let id = Uuid::parse_str("01234567-89ab-7cde-8123-456789abcdef").expect("UUID");
assert_eq!(user_code_from_uuid(id), "89AB-CDEF");
}
#[test]
fn user_code_preserves_the_human_readable_format() {
let code = new_user_code();
assert_eq!(code.len(), 9);
assert_eq!(code.as_bytes()[4], b'-');
assert!(
code.chars()
.enumerate()
.all(|(index, value)| index == 4 || value.is_ascii_hexdigit())
);
assert_eq!(code, code.to_ascii_uppercase());
}
}
+116 -39
View File
@@ -6448,9 +6448,18 @@ fn canonical_ticket_resource_key(resource_key: &str) -> Option<&str> {
.then_some(resource_key) .then_some(resource_key)
} }
fn ticket_notification_content(resource_key: &str, current_state: &str) -> String { fn ticket_notification_content(
resource_key: &str,
previous_state: &str,
current_state: &str,
) -> String {
if previous_state == current_state {
return format!(
"Ticket {resource_key} has new activity while {current_state}. Reread the current Ticket before acting."
);
}
format!( format!(
"Ticket {resource_key} changed to {current_state}. Reread the current Ticket before acting." "Ticket {resource_key} changed state from {previous_state} to {current_state}. Reread the current Ticket before acting."
) )
} }
@@ -6528,7 +6537,7 @@ fn notify_ticket_recipients(
api: &WorkspaceApi, api: &WorkspaceApi,
workspace_id: &str, workspace_id: &str,
ticket_id: &str, ticket_id: &str,
_previous_state: &str, previous_state: &str,
current_state: &str, current_state: &str,
source: Option<RuntimeWorkerRef>, source: Option<RuntimeWorkerRef>,
) { ) {
@@ -6562,7 +6571,7 @@ fn notify_ticket_recipients(
recipients.sort(); recipients.sort();
recipients.dedup(); recipients.dedup();
let content = ticket_notification_content(resource_key, current_state); let content = ticket_notification_content(resource_key, previous_state, current_state);
for recipient in recipients { for recipient in recipients {
if source.as_ref().is_some_and(|source| source == &recipient) { if source.as_ref().is_some_and(|source| source == &recipient) {
continue; continue;
@@ -10567,35 +10576,48 @@ async fn post_device_login_start(
State(api): State<ServerAuthApi>, State(api): State<ServerAuthApi>,
Json(request): Json<DeviceLoginStartRequest>, Json(request): Json<DeviceLoginStartRequest>,
) -> ApiResult<Json<DeviceLoginStartResponse>> { ) -> ApiResult<Json<DeviceLoginStartResponse>> {
const MAX_CODE_ALLOCATION_ATTEMPTS: usize = 8;
let auth = auth_public_config(&api.config); let auth = auth_public_config(&api.config);
let device_code = mint_secret("yoi_device");
let user_code = new_user_code();
let verification_uri = format!( let verification_uri = format!(
"{}/login/device", "{}/login/device",
auth.public_base_url.trim_end_matches('/') auth.public_base_url.trim_end_matches('/')
); );
let verification_uri_complete = format!("{verification_uri}?user_code={user_code}"); for _ in 0..MAX_CODE_ALLOCATION_ATTEMPTS {
api.store.create_device_login_flow(&DeviceLoginFlowRecord { let device_code = mint_secret("yoi_device");
device_code: device_code.clone(), let user_code = new_user_code();
user_code: user_code.clone(), let verification_uri_complete = format!("{verification_uri}?user_code={user_code}");
verification_uri: verification_uri.clone(), let flow = DeviceLoginFlowRecord {
client_name: request.client_name, device_code: device_code.clone(),
user_id: None, user_code: user_code.clone(),
api_token_id: None, verification_uri: verification_uri.clone(),
issued_access_token: None, client_name: request.client_name.clone(),
created_at: crate::auth::now_rfc3339(), user_id: None,
expires_at: rfc3339_after(Duration::minutes(10)), api_token_id: None,
approved_at: None, issued_access_token: None,
consumed_at: None, created_at: crate::auth::now_rfc3339(),
})?; expires_at: rfc3339_after(Duration::minutes(10)),
Ok(Json(DeviceLoginStartResponse { approved_at: None,
device_code, consumed_at: None,
user_code, };
verification_uri, if !api.store.try_create_device_login_flow(&flow)? {
verification_uri_complete, continue;
expires_in: 600, }
interval: 5, return Ok(Json(DeviceLoginStartResponse {
})) device_code,
user_code,
verification_uri,
verification_uri_complete,
expires_in: 600,
interval: 5,
}));
}
Err(auth_error(
"device_login_code_allocation_failed",
"could not allocate a unique device login user code",
)
.into())
} }
async fn post_device_login_approve( async fn post_device_login_approve(
@@ -17753,6 +17775,49 @@ mod tests {
assert_ne!(csrf_accepted.status(), StatusCode::FORBIDDEN); assert_ne!(csrf_accepted.status(), StatusCode::FORBIDDEN);
} }
#[tokio::test]
async fn device_login_start_can_retry_while_previous_flow_is_pending() {
let workspace = tempfile::tempdir().unwrap();
let api = test_api(workspace.path()).await;
let app = build_router(api.clone());
let mut starts = Vec::new();
for _ in 0..2 {
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/auth/device-login/start")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"client_name":"retry-test"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
starts.push(
serde_json::from_slice::<serde_json::Value>(&body)
.expect("device login start response"),
);
}
assert_ne!(starts[0]["device_code"], starts[1]["device_code"]);
assert_ne!(starts[0]["user_code"], starts[1]["user_code"]);
for start in starts {
let device_code = start["device_code"].as_str().expect("device code");
let user_code = start["user_code"].as_str().expect("user code");
assert_eq!(user_code.len(), 9);
assert!(
api.store
.get_device_login_flow_by_device_code(device_code)
.expect("stored device login flow")
.is_some()
);
}
}
#[tokio::test] #[tokio::test]
async fn direct_workspace_router_enforces_origin_on_browser_auth_mutations() { async fn direct_workspace_router_enforces_origin_on_browser_auth_mutations() {
let workspace = tempfile::tempdir().unwrap(); let workspace = tempfile::tempdir().unwrap();
@@ -18312,16 +18377,19 @@ mod tests {
} }
#[test] #[test]
fn ticket_notification_projection_exposes_only_resource_key_and_current_state() { fn ticket_notification_projection_distinguishes_state_changes_from_new_activity() {
const INTERNAL_ID: &str = "00001KZ9SR97B"; const INTERNAL_ID: &str = "00001KZ9SR97B";
for current_state in ["queued", "inprogress"] { let state_change = ticket_notification_content("T-429", "ready", "queued");
let content = ticket_notification_content("T-429", current_state); assert_eq!(
assert_eq!( state_change,
content, "Ticket T-429 changed state from ready to queued. Reread the current Ticket before acting."
format!( );
"Ticket T-429 changed to {current_state}. Reread the current Ticket before acting." let new_activity = ticket_notification_content("T-429", "inprogress", "inprogress");
) assert_eq!(
); new_activity,
"Ticket T-429 has new activity while inprogress. Reread the current Ticket before acting."
);
for content in [state_change, new_activity] {
assert!(!content.contains(INTERNAL_ID)); assert!(!content.contains(INTERNAL_ID));
for forbidden in [ for forbidden in [
"workspace_id", "workspace_id",
@@ -18496,15 +18564,23 @@ mod tests {
} }
let inputs = execution.take_inputs(); let inputs = execution.take_inputs();
let expected_states = ["queued", "inprogress", "inprogress", "inprogress"]; let expected_states = [
("ready", "queued"),
("inprogress", "inprogress"),
("inprogress", "inprogress"),
("inprogress", "inprogress"),
];
assert_eq!(inputs.len(), expected_states.len()); assert_eq!(inputs.len(), expected_states.len());
for ((recipient, content), current_state) in inputs.iter().zip(expected_states) { for ((recipient, content), (previous_state, current_state)) in
inputs.iter().zip(expected_states)
{
assert_eq!(recipient.worker_id.to_string(), orchestrator.worker_id); assert_eq!(recipient.worker_id.to_string(), orchestrator.worker_id);
assert!(!content.contains(&ticket.id)); assert!(!content.contains(&ticket.id));
assert_eq!( assert_eq!(
content, content,
&ticket_notification_content( &ticket_notification_content(
ticket.resource_key.as_deref().unwrap(), ticket.resource_key.as_deref().unwrap(),
previous_state,
current_state, current_state,
) )
); );
@@ -19623,6 +19699,7 @@ mod tests {
notifications[0].1, notifications[0].1,
ticket_notification_content( ticket_notification_content(
ticket_ref.resource_key.as_deref().unwrap(), ticket_ref.resource_key.as_deref().unwrap(),
TicketWorkflowState::Queued.as_str(),
TicketWorkflowState::Queued.as_str() TicketWorkflowState::Queued.as_str()
) )
); );
+67 -6
View File
@@ -996,7 +996,7 @@ pub trait ControlPlaneStore: Send + Sync {
fn create_api_token(&self, record: &ApiTokenRecord) -> Result<()>; fn create_api_token(&self, record: &ApiTokenRecord) -> Result<()>;
fn resolve_api_token(&self, token_hash: &str) -> Result<Option<ApiTokenRecord>>; fn resolve_api_token(&self, token_hash: &str) -> Result<Option<ApiTokenRecord>>;
fn mark_api_token_used(&self, token_hash: &str, used_at: &str) -> Result<()>; fn mark_api_token_used(&self, token_hash: &str, used_at: &str) -> Result<()>;
fn create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<()>; fn try_create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<bool>;
fn get_device_login_flow_by_user_code( fn get_device_login_flow_by_user_code(
&self, &self,
user_code: &str, user_code: &str,
@@ -3123,14 +3123,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}) })
} }
fn create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<()> { fn try_create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<bool> {
self.with_conn(|conn| { self.with_conn(|conn| {
conn.execute( let changed = conn.execute(
r#"INSERT INTO device_login_flows (device_code, user_code, verification_uri, client_name, user_id, api_token_id, issued_access_token, created_at, expires_at, approved_at, consumed_at) r#"INSERT INTO device_login_flows (device_code, user_code, verification_uri, client_name, user_id, api_token_id, issued_access_token, created_at, expires_at, approved_at, consumed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"#, VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(user_code) DO NOTHING"#,
params![record.device_code, record.user_code, record.verification_uri, record.client_name, record.user_id, record.api_token_id, record.issued_access_token, record.created_at, record.expires_at, record.approved_at, record.consumed_at], params![record.device_code, record.user_code, record.verification_uri, record.client_name, record.user_id, record.api_token_id, record.issued_access_token, record.created_at, record.expires_at, record.approved_at, record.consumed_at],
)?; )?;
Ok(()) Ok(changed == 1)
}) })
} }
@@ -13524,7 +13525,67 @@ CREATE TABLE ticket_assignment_operations (
approved_at: None, approved_at: None,
consumed_at: None, consumed_at: None,
}; };
store.create_device_login_flow(&flow).unwrap(); assert!(store.try_create_device_login_flow(&flow).unwrap());
let conflicting_flow = DeviceLoginFlowRecord {
device_code: "device-2".into(),
user_code: flow.user_code.clone(),
verification_uri: flow.verification_uri.clone(),
client_name: Some("other-cli".into()),
user_id: None,
api_token_id: None,
issued_access_token: None,
created_at: "2026-08-22T00:00:01Z".into(),
expires_at: "2026-08-22T00:10:01Z".into(),
approved_at: None,
consumed_at: None,
};
assert!(
!store
.try_create_device_login_flow(&conflicting_flow)
.expect("user code collision")
);
assert!(
store
.get_device_login_flow_by_device_code("device-2")
.expect("read conflicting flow")
.is_none()
);
let fresh_flow = DeviceLoginFlowRecord {
device_code: "device-3".into(),
user_code: "DCBA-5678".into(),
verification_uri: flow.verification_uri.clone(),
client_name: Some("retry-cli".into()),
user_id: None,
api_token_id: None,
issued_access_token: None,
created_at: "2026-08-22T00:00:02Z".into(),
expires_at: "2026-08-22T00:10:02Z".into(),
approved_at: None,
consumed_at: None,
};
assert!(
store
.try_create_device_login_flow(&fresh_flow)
.expect("fresh user code")
);
let device_code_conflict = DeviceLoginFlowRecord {
device_code: flow.device_code.clone(),
user_code: "ABCD-9999".into(),
verification_uri: flow.verification_uri.clone(),
client_name: Some("invalid-cli".into()),
user_id: None,
api_token_id: None,
issued_access_token: None,
created_at: "2026-08-22T00:00:03Z".into(),
expires_at: "2026-08-22T00:10:03Z".into(),
approved_at: None,
consumed_at: None,
};
assert!(
store
.try_create_device_login_flow(&device_code_conflict)
.is_err()
);
store store
.approve_device_login_flow( .approve_device_login_flow(
"device", "device",
+7 -56
View File
@@ -22,7 +22,7 @@ use cli_connection::{
}; };
use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login}; use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login};
use memory_lint::{LintCliOptions, LintStatus}; use memory_lint::{LintCliOptions, LintStatus};
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use session_store::SegmentId; use session_store::SegmentId;
use tui::{LaunchMode, LaunchOptions}; use tui::{LaunchMode, LaunchOptions};
@@ -1220,65 +1220,16 @@ async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> {
) )
.await .await
.map_err(|error| ParseError(error.to_string()))?; .map_err(|error| ParseError(error.to_string()))?;
save_backend_token(backend_url, &token)?; let token_path = client::save_backend_token(backend_url, "Bearer", &token)
println!("Saved Backend API token for {backend_url}"); .map_err(|error| ParseError(error.to_string()))?;
Ok(()) println!(
} "Saved Backend API token for {} in {}",
client::BackendOrigin::parse(backend_url).map_err(|error| ParseError(error.to_string()))?,
#[derive(Debug, Default, Serialize, Deserialize)] token_path.display()
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(()) 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_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> { fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
let Some((subcommand, rest)) = args.split_first() else { let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError( return Err(ParseError(