feat: split auth into sso/git, Planka token exchange, board discovery

Auth:
- sunbeam auth login runs SSO (Hydra OIDC) then Git (Gitea PAT)
- SSO callback auto-redirects browser to Gitea token page
- sunbeam auth sso / sunbeam auth git for individual flows
- Gitea PAT verified against API before saving

Planka:
- Token exchange via /api/access-tokens/exchange-using-token endpoint
- Board discovery via GET /api/projects
- String IDs (snowflake) handled throughout

Config:
- kubectl-style contexts: --context flag > current-context > "local"
- Removed --env flag
- Per-domain auth token storage
This commit is contained in:
2026-03-20 19:25:10 +00:00
parent ded0ab442e
commit ffc0fe917b
3 changed files with 311 additions and 87 deletions

View File

@@ -20,6 +20,9 @@ pub struct AuthTokens {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id_token: Option<String>,
pub domain: String,
/// Gitea personal access token (created during auth login).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gitea_token: Option<String>,
}
/// Default client ID when the K8s secret is unavailable.
@@ -265,6 +268,7 @@ async fn refresh_token(cached: &AuthTokens) -> Result<AuthTokens> {
expires_at,
id_token: token_resp.id_token.or_else(|| cached.id_token.clone()),
domain: cached.domain.clone(),
gitea_token: cached.gitea_token.clone(),
};
write_cache(&new_tokens)?;
@@ -342,6 +346,7 @@ async fn bind_callback_listener() -> Result<(tokio::net::TcpListener, u16)> {
async fn wait_for_callback(
listener: tokio::net::TcpListener,
expected_state: &str,
redirect_url: Option<&str>,
) -> Result<CallbackParams> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -400,23 +405,44 @@ async fn wait_for_callback(
));
}
// Send success response
let html = concat!(
"<!DOCTYPE html><html><head><style>",
"body{font-family:system-ui,sans-serif;display:flex;justify-content:center;",
"align-items:center;min-height:100vh;margin:0;background:#1a1f2e;color:#e8e6e3}",
".card{text-align:center;padding:3rem;border:1px solid #334;border-radius:1rem}",
"h2{margin:0 0 1rem}p{color:#9ca3af}",
"</style></head><body><div class='card'>",
"<h2>Authentication successful</h2>",
"<p>You can close this tab and return to the terminal.</p>",
"</div></body></html>"
);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
);
// Send success response — redirect to next step if provided, otherwise show done page
let response = if let Some(next_url) = redirect_url {
let html = format!(
"<!DOCTYPE html><html><head>\
<meta http-equiv='refresh' content='1;url={next_url}'>\
<style>\
body{{font-family:system-ui,sans-serif;display:flex;justify-content:center;\
align-items:center;min-height:100vh;margin:0;background:#1a1f2e;color:#e8e6e3}}\
.card{{text-align:center;padding:3rem;border:1px solid #334;border-radius:1rem}}\
h2{{margin:0 0 1rem}}p{{color:#9ca3af}}a{{color:#d97706}}\
</style></head><body><div class='card'>\
<h2>SSO login successful</h2>\
<p>Redirecting to Gitea token setup...</p>\
<p><a href='{next_url}'>Click here if not redirected</a></p>\
</div></body></html>"
);
format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
)
} else {
let html = "\
<!DOCTYPE html><html><head><style>\
body{font-family:system-ui,sans-serif;display:flex;justify-content:center;\
align-items:center;min-height:100vh;margin:0;background:#1a1f2e;color:#e8e6e3}\
.card{text-align:center;padding:3rem;border:1px solid #334;border-radius:1rem}\
h2{margin:0 0 1rem}p{color:#9ca3af}\
</style></head><body><div class='card'>\
<h2>Authentication successful</h2>\
<p>You can close this tab and return to the terminal.</p>\
</div></body></html>";
format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
)
};
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
@@ -464,7 +490,12 @@ pub async fn get_token() -> Result<String> {
}
/// Interactive browser-based OAuth2 login.
pub async fn cmd_auth_login(domain_override: Option<&str>) -> Result<()> {
/// SSO login — Hydra OIDC authorization code flow with PKCE.
/// `gitea_redirect`: if Some, the browser callback page auto-redirects to Gitea token page.
pub async fn cmd_auth_sso_login_with_redirect(
domain_override: Option<&str>,
gitea_redirect: Option<&str>,
) -> Result<()> {
crate::output::step("Authenticating with Hydra");
// Resolve domain: explicit flag > cached token domain > config > cluster discovery
@@ -507,7 +538,7 @@ pub async fn cmd_auth_login(domain_override: Option<&str>) -> Result<()> {
// Wait for callback
crate::output::ok("Waiting for authentication callback...");
let callback = wait_for_callback(listener, &state).await?;
let callback = wait_for_callback(listener, &state, gitea_redirect).await?;
// Exchange code for tokens
crate::output::ok("Exchanging authorization code for tokens...");
@@ -529,24 +560,117 @@ pub async fn cmd_auth_login(domain_override: Option<&str>) -> Result<()> {
expires_at,
id_token: token_resp.id_token.clone(),
domain: domain.clone(),
gitea_token: None,
};
write_cache(&tokens)?;
// Print success with email if available
if let Some(ref id_token) = tokens.id_token {
if let Some(email) = extract_email(id_token) {
crate::output::ok(&format!("Logged in as {email}"));
} else {
crate::output::ok("Logged in successfully");
}
let email = tokens
.id_token
.as_ref()
.and_then(|t| extract_email(t));
if let Some(ref email) = email {
crate::output::ok(&format!("Logged in as {email}"));
} else {
crate::output::ok("Logged in successfully");
}
write_cache(&tokens)?;
Ok(())
}
/// SSO login — standalone (no redirect after callback).
pub async fn cmd_auth_sso_login(domain_override: Option<&str>) -> Result<()> {
cmd_auth_sso_login_with_redirect(domain_override, None).await
}
/// Gitea token login — opens the PAT creation page and prompts for the token.
pub async fn cmd_auth_git_login(domain_override: Option<&str>) -> Result<()> {
crate::output::step("Setting up Gitea API access");
let domain = resolve_domain(domain_override).await?;
let url = format!("https://src.{domain}/user/settings/applications");
crate::output::ok("Opening Gitea token page in your browser...");
crate::output::ok("Create a token with all scopes selected, then paste it below.");
println!("\n {url}\n");
let _ = open_browser(&url);
// Prompt for the token
eprint!(" Gitea token: ");
let mut token = String::new();
std::io::stdin()
.read_line(&mut token)
.ctx("Failed to read token from stdin")?;
let token = token.trim().to_string();
if token.is_empty() {
return Err(SunbeamError::identity("No token provided."));
}
// Verify the token works
let client = reqwest::Client::new();
let resp = client
.get(format!("https://src.{domain}/api/v1/user"))
.header("Authorization", format!("token {token}"))
.send()
.await
.ctx("Failed to verify Gitea token")?;
if !resp.status().is_success() {
return Err(SunbeamError::identity(format!(
"Gitea token is invalid (HTTP {}). Check the token and try again.",
resp.status()
)));
}
let user: serde_json::Value = resp.json().await?;
let login = user
.get("login")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
// Save to cache
let mut tokens = read_cache().unwrap_or_else(|_| AuthTokens {
access_token: String::new(),
refresh_token: String::new(),
expires_at: Utc::now(),
id_token: None,
domain: domain.clone(),
gitea_token: None,
});
tokens.gitea_token = Some(token);
if tokens.domain.is_empty() {
tokens.domain = domain;
}
write_cache(&tokens)?;
crate::output::ok(&format!("Gitea authenticated as {login}"));
Ok(())
}
/// Combined login — SSO first, then Gitea.
pub async fn cmd_auth_login_all(domain_override: Option<&str>) -> Result<()> {
// Resolve domain early so we can build the Gitea redirect URL
let domain = resolve_domain(domain_override).await?;
let gitea_url = format!("https://src.{domain}/user/settings/applications");
cmd_auth_sso_login_with_redirect(Some(&domain), Some(&gitea_url)).await?;
cmd_auth_git_login(Some(&domain)).await?;
Ok(())
}
/// Get the Gitea API token (for use by pm.rs).
pub fn get_gitea_token() -> Result<String> {
let tokens = read_cache().map_err(|_| {
SunbeamError::identity("Not logged in. Run `sunbeam auth login` first.")
})?;
tokens.gitea_token.ok_or_else(|| {
SunbeamError::identity(
"No Gitea token. Run `sunbeam auth login` or `sunbeam auth set-gitea-token <token>`.",
)
})
}
/// Remove cached auth tokens.
pub async fn cmd_auth_logout() -> Result<()> {
let path = cache_path();
@@ -674,6 +798,7 @@ mod tests {
expires_at: Utc::now() + Duration::hours(1),
id_token: Some("eyJhbGciOiJSUzI1NiJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.sig".to_string()),
domain: "sunbeam.pt".to_string(),
gitea_token: None,
};
let json = serde_json::to_string_pretty(&tokens).unwrap();
@@ -699,6 +824,7 @@ mod tests {
expires_at: Utc::now() + Duration::hours(1),
id_token: None,
domain: "example.com".to_string(),
gitea_token: None,
};
let json = serde_json::to_string(&tokens).unwrap();
@@ -717,6 +843,7 @@ mod tests {
expires_at: Utc::now() + Duration::hours(1),
id_token: None,
domain: "example.com".to_string(),
gitea_token: None,
};
let now = Utc::now();
@@ -732,6 +859,7 @@ mod tests {
expires_at: Utc::now() - Duration::hours(1),
id_token: None,
domain: "example.com".to_string(),
gitea_token: None,
};
let now = Utc::now();
@@ -747,6 +875,7 @@ mod tests {
expires_at: Utc::now() + Duration::seconds(30),
id_token: None,
domain: "example.com".to_string(),
gitea_token: None,
};
let now = Utc::now();

View File

@@ -149,13 +149,25 @@ pub enum Verb {
#[derive(Subcommand, Debug)]
pub enum AuthAction {
/// Log in via browser (OAuth2 authorization code flow).
/// Log in to both SSO and Gitea.
Login {
/// Domain to authenticate against (e.g. sunbeam.pt).
#[arg(long)]
domain: Option<String>,
},
/// Log out (remove cached tokens).
/// Log in to SSO only (Hydra OIDC — for Planka, identity management).
Sso {
/// Domain to authenticate against.
#[arg(long)]
domain: Option<String>,
},
/// Log in to Gitea only (personal access token).
Git {
/// Domain to authenticate against.
#[arg(long)]
domain: Option<String>,
},
/// Log out (remove all cached tokens).
Logout,
/// Show current authentication status.
Status,
@@ -1022,11 +1034,15 @@ pub async fn dispatch() -> Result<()> {
},
Some(Verb::Auth { action }) => match action {
None => {
crate::auth::cmd_auth_status().await
}
None => crate::auth::cmd_auth_status().await,
Some(AuthAction::Login { domain }) => {
crate::auth::cmd_auth_login(domain.as_deref()).await
crate::auth::cmd_auth_login_all(domain.as_deref()).await
}
Some(AuthAction::Sso { domain }) => {
crate::auth::cmd_auth_sso_login(domain.as_deref()).await
}
Some(AuthAction::Git { domain }) => {
crate::auth::cmd_auth_git_login(domain.as_deref()).await
}
Some(AuthAction::Logout) => crate::auth::cmd_auth_logout().await,
Some(AuthAction::Status) => crate::auth::cmd_auth_status().await,

187
src/pm.rs
View File

@@ -142,7 +142,7 @@ pub struct CardUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub list_id: Option<u64>,
pub list_id: Option<serde_json::Value>,
}
struct PlankaClient {
@@ -168,13 +168,13 @@ mod planka_json {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Card {
pub id: u64,
pub id: serde_json::Value,
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub list_id: Option<u64>,
pub list_id: Option<serde_json::Value>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
@@ -208,21 +208,21 @@ mod planka_json {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CardMembership {
pub card_id: u64,
pub user_id: u64,
pub card_id: serde_json::Value,
pub user_id: serde_json::Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CardLabel {
pub card_id: u64,
pub label_id: u64,
pub card_id: serde_json::Value,
pub label_id: serde_json::Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Label {
pub id: u64,
pub id: serde_json::Value,
#[serde(default)]
pub name: Option<String>,
}
@@ -230,7 +230,7 @@ mod planka_json {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct List {
pub id: u64,
pub id: serde_json::Value,
#[serde(default)]
pub name: String,
}
@@ -238,7 +238,7 @@ mod planka_json {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub id: u64,
pub id: serde_json::Value,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
@@ -301,7 +301,7 @@ mod planka_json {
// Derive web URL from API base URL (strip `/api`).
let web_base = base_url.trim_end_matches("/api");
Ticket {
id: format!("planka:{}", self.id),
id: format!("p:{}", self.id.as_str().unwrap_or(&self.id.to_string())),
source: Source::Planka,
title: self.name,
description: self.description.unwrap_or_default(),
@@ -342,27 +342,29 @@ impl PlankaClient {
.build()
.map_err(|e| SunbeamError::network(format!("Failed to build HTTP client: {e}")))?;
// Try OIDC exchange first to get a Planka-native JWT.
let exchange_url = format!("{base_url}/access-tokens/exchange-using-oidc");
// Exchange the Hydra access token for a Planka JWT via our custom endpoint.
let exchange_url = format!("{base_url}/access-tokens/exchange-using-token");
let exchange_resp = http
.post(&exchange_url)
.bearer_auth(&hydra_token)
.json(&serde_json::json!({ "token": hydra_token }))
.send()
.await;
.await
.map_err(|e| SunbeamError::network(format!("Planka token exchange failed: {e}")))?;
let token = match exchange_resp {
Ok(resp) if resp.status().is_success() => {
let body: planka_json::ExchangeResponse = resp
.json()
.await
.map_err(|e| SunbeamError::network(format!("Planka OIDC exchange parse error: {e}")))?;
body.token
.or(body.item)
.unwrap_or_else(|| hydra_token.clone())
}
// Exchange not available or failed -- fall back to using the Hydra token directly.
_ => hydra_token,
};
if !exchange_resp.status().is_success() {
let status = exchange_resp.status();
let body = exchange_resp.text().await.unwrap_or_default();
return Err(SunbeamError::identity(format!(
"Planka token exchange returned {status}: {body}"
)));
}
let body: serde_json::Value = exchange_resp.json().await?;
let token = body
.get("item")
.and_then(|v| v.as_str())
.ok_or_else(|| SunbeamError::identity("Planka exchange response missing 'item' field"))?
.to_string();
Ok(Self {
base_url,
@@ -371,8 +373,66 @@ impl PlankaClient {
})
}
/// Discover all projects and boards, then fetch cards from each.
async fn list_all_cards(&self) -> Result<Vec<Ticket>> {
// GET /api/projects returns all projects the user has access to,
// with included boards.
let url = format!("{}/projects", self.base_url);
let resp = self
.http
.get(&url)
.bearer_auth(&self.token)
.send()
.await
.map_err(|e| SunbeamError::network(format!("Planka list projects: {e}")))?;
if !resp.status().is_success() {
return Err(SunbeamError::network(format!(
"Planka GET projects returned {}",
resp.status()
)));
}
let body: serde_json::Value = resp.json().await?;
// Extract board IDs — Planka uses string IDs (snowflake-style)
let board_ids: Vec<String> = body
.get("included")
.and_then(|inc| inc.get("boards"))
.and_then(|b| b.as_array())
.map(|boards| {
boards
.iter()
.filter_map(|b| {
b.get("id").and_then(|id| {
id.as_str()
.map(|s| s.to_string())
.or_else(|| id.as_u64().map(|n| n.to_string()))
})
})
.collect()
})
.unwrap_or_default();
if board_ids.is_empty() {
return Ok(vec![]);
}
// Fetch cards from each board
let mut all_tickets = Vec::new();
for board_id in &board_ids {
match self.list_cards(board_id).await {
Ok(tickets) => all_tickets.extend(tickets),
Err(e) => {
crate::output::warn(&format!("Planka board {board_id}: {e}"));
}
}
}
Ok(all_tickets)
}
/// GET /api/boards/{id} and extract all cards.
async fn list_cards(&self, board_id: u64) -> Result<Vec<Ticket>> {
async fn list_cards(&self, board_id: &str) -> Result<Vec<Ticket>> {
let url = format!("{}/boards/{board_id}", self.base_url);
let resp = self
.http
@@ -501,7 +561,7 @@ impl PlankaClient {
self.update_card(
id,
&CardUpdate {
list_id: Some(list_id),
list_id: Some(serde_json::Value::Number(list_id.into())),
..Default::default()
},
)
@@ -642,7 +702,7 @@ mod gitea_json {
.unwrap_or_else(|| format!("{web_base}/{org}/{repo}/issues/{}", self.number));
Ticket {
id: format!("gitea:{org}/{repo}#{}", self.number),
id: format!("g:{org}/{repo}#{}", self.number),
source: Source::Gitea,
title: self.title,
description: self.body.unwrap_or_default(),
@@ -670,7 +730,8 @@ impl GiteaClient {
/// Create a new Gitea client using the Hydra OAuth2 token.
async fn new(domain: &str) -> Result<Self> {
let base_url = format!("https://src.{domain}/api/v1");
let token = get_token().await?;
// Gitea needs its own PAT, not the Hydra access token
let token = crate::auth::get_gitea_token()?;
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
@@ -683,6 +744,25 @@ impl GiteaClient {
})
}
/// Build a request with Gitea PAT auth (`Authorization: token <pat>`).
fn authed_get(&self, url: &str) -> reqwest::RequestBuilder {
self.http
.get(url)
.header("Authorization", format!("token {}", self.token))
}
fn authed_post(&self, url: &str) -> reqwest::RequestBuilder {
self.http
.post(url)
.header("Authorization", format!("token {}", self.token))
}
fn authed_patch(&self, url: &str) -> reqwest::RequestBuilder {
self.http
.patch(url)
.header("Authorization", format!("token {}", self.token))
}
/// List issues for an org/repo (or search across an org).
fn list_issues<'a>(
&'a self,
@@ -705,7 +785,7 @@ impl GiteaClient {
let resp = self
.http
.get(&url)
.bearer_auth(&self.token)
.header("Authorization", format!("token {}", self.token))
.query(&[("state", state), ("type", "issues"), ("limit", "50")])
.send()
.await
@@ -734,7 +814,7 @@ impl GiteaClient {
let repos_resp = self
.http
.get(&repos_url)
.bearer_auth(&self.token)
.header("Authorization", format!("token {}", self.token))
.query(&[("limit", "50")])
.send()
.await
@@ -774,7 +854,7 @@ impl GiteaClient {
let resp = self
.http
.get(&url)
.bearer_auth(&self.token)
.header("Authorization", format!("token {}", self.token))
.send()
.await
.map_err(|e| SunbeamError::network(format!("Gitea get_issue: {e}")))?;
@@ -811,7 +891,7 @@ impl GiteaClient {
let resp = self
.http
.post(&url)
.bearer_auth(&self.token)
.header("Authorization", format!("token {}", self.token))
.json(&payload)
.send()
.await
@@ -844,7 +924,7 @@ impl GiteaClient {
let resp = self
.http
.patch(&url)
.bearer_auth(&self.token)
.header("Authorization", format!("token {}", self.token))
.json(updates)
.send()
.await
@@ -890,7 +970,7 @@ impl GiteaClient {
let resp = self
.http
.post(&url)
.bearer_auth(&self.token)
.header("Authorization", format!("token {}", self.token))
.json(&payload)
.send()
.await
@@ -923,7 +1003,7 @@ impl GiteaClient {
let resp = self
.http
.post(&url)
.bearer_auth(&self.token)
.header("Authorization", format!("token {}", self.token))
.json(&payload)
.send()
.await
@@ -1009,8 +1089,7 @@ pub async fn cmd_pm_list(source: Option<&str>, state: &str) -> Result<()> {
let planka_fut = async {
if fetch_planka {
let client = PlankaClient::new(&domain).await?;
// Board ID 1 as default; in practice this would be configurable.
client.list_cards(1).await
client.list_all_cards().await
} else {
Ok(vec![])
}
@@ -1314,7 +1393,7 @@ mod tests {
fn test_display_ticket_list_table() {
let tickets = vec![
Ticket {
id: "planka:1".to_string(),
id: "p:1".to_string(),
source: Source::Planka,
title: "Fix login".to_string(),
description: String::new(),
@@ -1326,7 +1405,7 @@ mod tests {
url: "https://projects.example.com/cards/1".to_string(),
},
Ticket {
id: "gitea:studio/cli#7".to_string(),
id: "g:studio/cli#7".to_string(),
source: Source::Gitea,
title: "Add tests".to_string(),
description: "We need more tests.".to_string(),
@@ -1353,8 +1432,8 @@ mod tests {
.collect();
let tbl = output::table(&rows, &["ID", "STATUS", "TITLE", "ASSIGNEES", "SOURCE"]);
assert!(tbl.contains("planka:1"));
assert!(tbl.contains("gitea:studio/cli#7"));
assert!(tbl.contains("p:1"));
assert!(tbl.contains("g:studio/cli#7"));
assert!(tbl.contains("open"));
assert!(tbl.contains("in-progress"));
assert!(tbl.contains("Fix login"));
@@ -1379,7 +1458,7 @@ mod tests {
let update = CardUpdate {
name: Some("New name".to_string()),
description: None,
list_id: Some(5),
list_id: Some(serde_json::json!(5)),
};
let json = serde_json::to_value(&update).unwrap();
assert_eq!(json["name"], "New name");
@@ -1411,19 +1490,19 @@ mod tests {
card_labels: vec![],
labels: vec![],
lists: vec![
List { id: 1, name: "To Do".to_string() },
List { id: 2, name: "In Progress".to_string() },
List { id: 3, name: "Done".to_string() },
List { id: 4, name: "Archived / Closed".to_string() },
List { id: serde_json::json!(1), name: "To Do".to_string() },
List { id: serde_json::json!(2), name: "In Progress".to_string() },
List { id: serde_json::json!(3), name: "Done".to_string() },
List { id: serde_json::json!(4), name: "Archived / Closed".to_string() },
],
users: vec![],
};
let make_card = |list_id| Card {
id: 1,
let make_card = |list_id: u64| Card {
id: serde_json::json!(1),
name: "test".to_string(),
description: None,
list_id: Some(list_id),
list_id: Some(serde_json::json!(list_id)),
created_at: None,
updated_at: None,
};
@@ -1466,7 +1545,7 @@ mod tests {
};
let ticket = issue.to_ticket("https://src.example.com/api/v1", "studio", "app");
assert_eq!(ticket.id, "gitea:studio/app#42");
assert_eq!(ticket.id, "g:studio/app#42");
assert_eq!(ticket.source, Source::Gitea);
assert_eq!(ticket.title, "Bug report");
assert_eq!(ticket.description, "Something broke");