use crate::serve::storage::Failure;
use lys_pass::{KeySet, VerifiedPass};
use serde_json::{Value, json};
use std::time::Duration;
const RESPONSE_BYTES: usize = 1_048_576;
const DRAFT_PAGES: usize = 64;
async fn deadline<T>(
within: Duration,
work: impl std::future::Future<Output = Result<T, Failure>>,
) -> Result<T, Failure> {
tokio::time::timeout(within,work).await.map_err(|_|Failure::new("lys_draft_deadline","Lys did not return a receipt within the configured sign-in request deadline; check the draft in Lys"))?
}
pub(super) struct Client {
pub issuer: String,
pub http: reqwest::Client,
}
impl Client {
pub async fn identity(
&self,
agent: &str,
pass: &str,
store: &str,
now: u64,
) -> Result<String, Failure> {
let client = lys_pass::Client::new(
self.http.clone(),
url::Url::parse(&self.issuer).map_err(|_| unavailable())?,
)
.map_err(|error| named(&error))?;
let keys = client.fetch_keys().await.map_err(|error| named(&error))?;
identity(&keys, &self.issuer, agent, pass, store, now)
}
async fn read(&self, request: reqwest::RequestBuilder) -> Result<Value, Failure> {
let mut response = request.send().await.map_err(|_| unavailable())?;
let status = response.status();
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|_| unavailable())? {
if chunk.len() > RESPONSE_BYTES.saturating_sub(bytes.len()) {
return Err(Failure::new(
"lys_response_too_large",
format!(
"lys_response_bytes={RESPONSE_BYTES}; count={}",
bytes.len().saturating_add(chunk.len())
),
));
}
bytes.extend_from_slice(&chunk);
}
let value: Value = serde_json::from_slice(&bytes).map_err(|_| unavailable())?;
if !status.is_success() {
let code = value["refusal"]
.as_str()
.or_else(|| value["code"].as_str())
.filter(|code| {
!code.is_empty()
&& code.len() <= 128
&& code.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
})
.ok_or_else(unavailable)?;
let words = value["reason"]
.as_str()
.or_else(|| value["message"].as_str())
.filter(|words| !words.is_empty())
.ok_or_else(unavailable)?;
return Err(Failure::new(code, words));
}
Ok(value)
}
pub async fn draft(&self, pass: &str, id: &str, within: Duration) -> Result<Value, Failure> {
deadline(within, self.draft_pages(pass, id)).await
}
async fn draft_pages(&self, pass: &str, id: &str) -> Result<Value, Failure> {
let mut after: Option<String> = None;
for _ in 0..DRAFT_PAGES {
let mut url = url::Url::parse(&format!(
"{}/api/product-drafts",
self.issuer.trim_end_matches('/')
))
.map_err(|_| unavailable())?;
url.query_pairs_mut().append_pair("limit", "64");
if let Some(after) = &after {
url.query_pairs_mut().append_pair("after", after);
}
let page = self.read(self.http.get(url).bearer_auth(pass)).await?;
let drafts = page["drafts"]
.as_array()
.filter(|drafts| drafts.len() <= 64)
.ok_or_else(unavailable)?;
if let Some(held) = drafts.iter().find(|held| held["id"] == id) {
let digest = held["request_digest"].as_str().ok_or_else(unavailable)?;
if digest.len() != 64
|| !digest.bytes().all(|b| b.is_ascii_hexdigit())
|| !held["app"].is_string()
|| !held["holder"].is_string()
|| !held["responsible"].is_string()
|| !held["target"]["kind"].is_string()
|| !held["target"]["id"].is_string()
|| !held["target"]["action"].is_string()
{
return Err(unavailable());
}
return Ok(held.clone());
}
let next = page.get("next").ok_or_else(unavailable)?;
if next.is_null() {
return Err(Failure::new(
"draft_not_found",
"Lys returned no visible draft by that id",
));
}
let next = next
.as_str()
.filter(|next| !next.is_empty() && after.as_deref() != Some(next))
.ok_or_else(unavailable)?;
after = Some(next.to_owned());
}
Err(Failure::new(
"lys_draft_pages_exceeded",
format!("lys_draft_pages={DRAFT_PAGES}; use the draft's Lys link to find it"),
))
}
pub async fn decide_held(
&self,
pass: &str,
held: &Value,
decision: &str,
within: Duration,
) -> Result<Value, Failure> {
deadline(within, self.post_decision(pass, held, decision)).await
}
async fn post_decision(
&self,
pass: &str,
held: &Value,
decision: &str,
) -> Result<Value, Failure> {
let id = held["id"].as_str().ok_or_else(unavailable)?;
let digest = held["request_digest"].as_str().ok_or_else(unavailable)?;
let random = super::session::session_id()?;
let operation = format!("op-{}", random.get(..32).ok_or_else(unavailable)?);
let mut body = json!({"operation":operation,"request_digest":digest});
if decision == "refuse" {
body["reason"] = json!("The person refused this assistant permission.");
}
let mut url = url::Url::parse(&format!(
"{}/api/product-drafts",
self.issuer.trim_end_matches('/')
))
.map_err(|_| unavailable())?;
url.path_segments_mut()
.map_err(|()| unavailable())?
.push(id)
.push(decision);
let answer = self
.read(self.http.post(url).bearer_auth(pass).json(&body))
.await?;
if answer["draft"] != id
|| !matches!(
answer["state"].as_str(),
Some("waiting" | "approved" | "refused")
)
{
return Err(unavailable());
}
Ok(answer)
}
#[cfg(test)]
pub async fn decide(&self, pass: &str, id: &str, decision: &str) -> Result<Value, Failure> {
let within = Duration::from_millis(super::config::SignInBounds::default().total_ms.get());
let started = std::time::Instant::now();
let held = self.draft(pass, id, within).await?;
self.decide_held(
pass,
&held,
decision,
within.saturating_sub(started.elapsed()),
)
.await
}
}
pub(super) fn identity(
keys: &KeySet,
issuer: &str,
agent: &str,
pass: &str,
store: &str,
now: u64,
) -> Result<String, Failure> {
let verified = VerifiedPass::verify(pass, keys, issuer, "haematite", now)
.map_err(|error| named(&error))?;
let holder = &verified.claims().holder;
if holder.kind != "agent" || holder.id != agent {
return Err(Failure::new(
"assistant_agent_mismatch",
"the pass holder is not the named agent",
));
}
if matches!(
verified
.evaluate("haematite.database", store, "agent_permission", now)
.map_err(|error| named(&error))?,
lys_pass::Decision::Refused
) {
return Err(Failure::new(
"seat_not_yours",
"the signed assistant profile grant does not name this store",
));
}
holder
.responsible
.as_ref()
.filter(|person| super::assistant_wire::identifier(person))
.cloned()
.ok_or_else(|| {
Failure::new(
"assistant_responsible_missing",
"the agent pass must name its responsible person",
)
})
}
fn named(error: &lys_pass::Error) -> Failure {
Failure::new(error.name(), "Lys refused the assistant's identity")
}
fn unavailable() -> Failure {
Failure::new(
"lys_unavailable",
"Lys could not be reached or did not return a complete receipt; check the draft in Lys",
)
}
#[cfg(test)]
#[path = "assistant_lys_deadline_tests.rs"]
mod deadline_tests;