1use std::num::NonZeroUsize;
8use std::sync::Arc;
9
10use lys_pass::{Decision, Target};
11use serde_json::{Value, json};
12
13use crate::auth_lys::LysPassConsumer;
14use crate::auth_lys::live::drafts::{
15 AtomicDraftStore, DraftError, DraftExecutor, DraftLimits, PullReport, PullSignal,
16 valid_metadata, validate_bounds,
17};
18use crate::auth_lys::lys_pass_adapter::LysPassAdapter;
19
20pub const PULL_PATH: &str = "/admin/drafts/pull";
22pub const CREATE_PATH: &str = "/admin/agents/create";
24pub const PULL_ACTION: &str = "pull_drafts";
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct AdminLimits {
31 pub credential_bytes: NonZeroUsize,
33 pub body_bytes: NonZeroUsize,
35}
36
37pub struct AdminRequest<'a> {
39 pub method: &'a str,
41 pub path: &'a str,
43 pub authorization: Option<&'a str>,
45 pub body: &'a [u8],
47 pub at_ms: u64,
49}
50
51pub struct AdminResponse {
53 pub status: u16,
55 pub body: Value,
57}
58
59pub struct AdminDrafts<S> {
61 puller: DraftPuller<S>,
62 adapter: Arc<LysPassAdapter>,
63 network: String,
64 limits: AdminLimits,
65 ask: Option<String>,
66}
67
68impl<S: AtomicDraftStore> AdminDrafts<S> {
69 pub(crate) async fn signal(
70 &mut self,
71 connector: &str,
72 signal: PullSignal,
73 at_ms: u64,
74 ) -> Result<PullReport, DraftError> {
75 self.puller.pull(connector, signal, at_ms).await
76 }
77
78 pub fn new(
83 puller: DraftPuller<S>,
84 adapter: Arc<LysPassAdapter>,
85 network: String,
86 limits: AdminLimits,
87 ) -> Result<Self, DraftError> {
88 if network.is_empty()
89 || network.chars().any(char::is_control)
90 || network.len() > limits.credential_bytes.get()
91 {
92 return Err(DraftError::Bound);
93 }
94 Ok(Self {
95 puller,
96 adapter,
97 network,
98 limits,
99 ask: None,
100 })
101 }
102
103 #[must_use]
106 pub fn asking_at(mut self, api_url: Option<String>) -> Self {
107 self.ask = api_url;
108 self
109 }
110
111 pub async fn handle(
120 &mut self,
121 request: AdminRequest<'_>,
122 connector_pass: &str,
123 ) -> Result<AdminResponse, DraftError> {
124 if request.path != PULL_PATH {
125 return Ok(refused(404, "draft_pull_route_unknown"));
126 }
127 if request.method != "POST" {
128 return Ok(refused(405, "draft_pull_method_refused"));
129 }
130 if request.body.len() > self.limits.body_bytes.get() {
131 return Ok(refused(413, "draft_pull_body_bound"));
132 }
133 if !request.body.is_empty() {
134 let body: Value = match serde_json::from_slice(request.body) {
135 Ok(body) => body,
136 Err(_) => return Ok(refused(400, "draft_pull_body_invalid")),
137 };
138 if !body.as_object().is_some_and(serde_json::Map::is_empty) {
139 return Ok(refused(400, "draft_pull_body_invalid"));
140 }
141 }
142 let Some(pass) = request
143 .authorization
144 .and_then(|header| header.strip_prefix("Bearer "))
145 else {
146 return Ok(refused(401, "draft_pull_pass_missing"));
147 };
148 if pass.is_empty()
149 || pass.len() > self.limits.credential_bytes.get()
150 || pass.chars().any(char::is_whitespace)
151 || pass.chars().any(char::is_control)
152 {
153 return Ok(refused(401, "draft_pull_pass_invalid"));
154 }
155 let now = request.at_ms / 1000;
156 let Ok(verified) = self.adapter.verify_offline(pass.as_bytes(), "liminal", now) else {
157 return Ok(refused(401, "draft_pull_pass_invalid"));
158 };
159 match verified
160 .verified()
161 .evaluate("liminal.network", &self.network, PULL_ACTION, now)
162 {
163 Ok(Decision::Allowed { .. }) => {}
164 Ok(Decision::Held { .. } | Decision::Refused) => {
165 return Ok(refused(403, "draft_pull_network_operator_required"));
166 }
167 Err(_) => return Ok(refused(401, "draft_pull_pass_invalid")),
168 }
169 let http = reqwest::Client::builder()
172 .no_proxy()
173 .redirect(reqwest::redirect::Policy::none())
174 .build()
175 .map_err(|_| DraftError::Lys("transport_failed".to_owned()))?;
176 let issuer = crate::auth_lys::live::lys_origin(
177 self.ask.as_deref().unwrap_or_else(|| self.adapter.issuer()),
178 )
179 .map_err(|_| DraftError::Contract)?;
180 let client = lys_pass::Client::new(http, issuer)
181 .map_err(|error| DraftError::Lys(error.name().to_owned()))?;
182 let target = Target::new("liminal.network", &self.network, PULL_ACTION)
183 .map_err(|_| DraftError::Contract)?;
184 let answer = client
185 .check_batch(connector_pass, verified.holder(), &[target], None)
188 .await
189 .map_err(|error| DraftError::Lys(error.name().to_owned()))?;
190 let Some(result) = answer.results.first() else {
191 return Err(DraftError::Contract);
192 };
193 if answer.degraded.is_some() || result.degraded.is_some() {
194 return Ok(refused(503, "draft_pull_permission_degraded"));
195 }
196 if !result.allowed || result.mode != Some(lys_pass::Mode::Outright) {
197 if let Some(name) = &result.refusal {
198 return Ok(AdminResponse {
199 status: 403,
200 body: json!({"refusal":name, "reason":result.reason}),
201 });
202 }
203 return Ok(refused(403, "draft_pull_network_operator_required"));
204 }
205 let report = self
206 .puller
207 .pull(connector_pass, PullSignal::Console, request.at_ms)
208 .await?;
209 Ok(AdminResponse {
210 status: 200,
211 body: serde_json::to_value(report).map_err(|_| DraftError::Store)?,
212 })
213 }
214}
215
216#[path = "admin_outright.rs"]
217mod outright;
218pub use outright::OutrightAsk;
219
220fn refused(status: u16, name: &str) -> AdminResponse {
221 AdminResponse {
222 status,
223 body: json!({"refusal":name}),
224 }
225}
226
227use lys_pass::drafts::{ApprovedDraft, Execution, Executor, execute_once};
228use std::collections::BTreeSet;
229pub struct DraftPuller<S> {
232 client: lys_pass::Client,
233 pub(crate) executor: DraftExecutor<S>,
234 startup_seen: bool,
235}
236
237impl<S> std::fmt::Debug for DraftPuller<S> {
238 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 formatter
240 .debug_struct("DraftPuller")
241 .finish_non_exhaustive()
242 }
243}
244
245impl<S: AtomicDraftStore> DraftPuller<S> {
246 pub fn new(issuer: &str, store: S, limits: DraftLimits) -> Result<Self, DraftError> {
252 let http = reqwest::Client::builder()
253 .no_proxy()
254 .redirect(reqwest::redirect::Policy::none())
255 .build()
256 .map_err(|_| DraftError::Lys("transport_failed".to_owned()))?;
257 let issuer = crate::auth_lys::live::lys_origin(issuer).map_err(|_| DraftError::Contract)?;
258 let client = lys_pass::Client::new(http, issuer)
259 .map_err(|error| DraftError::Lys(error.name().to_owned()))?;
260 Ok(Self {
261 client,
262 executor: DraftExecutor {
263 store,
264 limits,
265 at_ms: 0,
266 },
267 startup_seen: false,
268 })
269 }
270
271 pub async fn pull(
278 &mut self,
279 connector_pass: &str,
280 signal: PullSignal,
281 at_ms: u64,
282 ) -> Result<PullReport, DraftError> {
283 let limits = self.executor.limits;
284 if !valid_metadata(connector_pass, limits.metadata_bytes) {
285 return Err(DraftError::Bound);
286 }
287 if signal == PullSignal::Startup {
288 if self.startup_seen {
289 return Err(DraftError::StartupRepeated);
290 }
291 self.startup_seen = true;
292 }
293 self.executor.at_ms = at_ms;
294 let mut report = PullReport::default();
295 let mut after: Option<String> = None;
296 let mut cursors = BTreeSet::new();
297 let mut ids = BTreeSet::new();
298 let pending = self
299 .acknowledge_pending(connector_pass, &mut report, &mut ids)
300 .await?;
301 loop {
302 if report.pages >= limits.pages.get() {
303 return Err(DraftError::Bound);
304 }
305 let page = self
306 .client
307 .approved_drafts(connector_pass, crate::auth_lys::LYS_APP, after.as_deref())
308 .await
309 .map_err(|error| DraftError::Lys(error.name().to_owned()))?;
310 report.pages += 1;
311 let mut page_ids = BTreeSet::new();
312 for draft in &page.drafts {
313 validate_bounds(draft, limits)?;
314 if !page_ids.insert(&draft.id) {
315 return Err(DraftError::IdRepeated);
316 }
317 }
318 let additional = page
319 .drafts
320 .iter()
321 .filter(|draft| !ids.contains(&draft.id))
322 .count();
323 if ids.len().saturating_add(additional) > limits.drafts.get() {
324 return Err(DraftError::Bound);
325 }
326 if let Some(next) = &page.next {
327 if !valid_metadata(next, limits.metadata_bytes) {
328 return Err(DraftError::Bound);
329 }
330 if !cursors.insert(next.clone()) {
331 return Err(DraftError::CursorRepeated);
332 }
333 }
334 for draft in &page.drafts {
335 if pending.contains(&draft.id) {
336 self.execution(draft)?;
337 continue;
338 }
339 if !ids.insert(draft.id.clone()) {
340 return Err(DraftError::IdRepeated);
341 }
342 let replay = self.executor.receipt(&draft.id)?.is_some();
343 let receipt = self.execution(draft)?;
344 self.executor.store.deliver_outcome(draft, &receipt)?;
345 if replay {
346 report.replayed += 1;
347 } else {
348 match &receipt {
349 Execution::Executed { .. } => report.executed += 1,
350 Execution::RefusedOnExecution { .. } => report.refused += 1,
351 }
352 }
353 if !self.executor.store.acknowledged(&draft.id)? {
354 self.client
355 .record_execution(connector_pass, draft, &receipt)
356 .await
357 .map_err(|error| DraftError::Acknowledgment {
358 draft: draft.id.clone(),
359 name: error.name().to_owned(),
360 })?;
361 self.executor.store.acknowledge(&draft.id, &receipt)?;
362 report.acknowledged += 1;
363 }
364 }
365 match page.next {
366 Some(next) => after = Some(next),
367 None => return Ok(report),
368 }
369 }
370 }
371 fn execution(&mut self, draft: &ApprovedDraft) -> Result<Execution, DraftError> {
372 execute_once(draft, &mut self.executor).map_err(|error| match error {
373 lys_pass::drafts::ExecuteError::DigestMismatch => DraftError::ReceiptMismatch,
374 lys_pass::drafts::ExecuteError::Product(error) => error,
375 lys_pass::drafts::ExecuteError::Contract(_) => DraftError::Contract,
376 })
377 }
378
379 async fn acknowledge_pending(
380 &mut self,
381 pass: &str,
382 report: &mut PullReport,
383 ids: &mut BTreeSet<String>,
384 ) -> Result<BTreeSet<String>, DraftError> {
385 let limits = self.executor.limits;
386 loop {
387 let pending = self.executor.store.pending_acknowledgments(limits.drafts)?;
388 if pending.is_empty() {
389 return Ok(ids.clone());
390 }
391 if ids.len().saturating_add(pending.len()) > limits.drafts.get() {
392 return Err(DraftError::Bound);
393 }
394 for draft in pending {
395 validate_bounds(&draft, limits)?;
396 if !ids.insert(draft.id.clone()) {
397 return Err(DraftError::IdRepeated);
398 }
399 if self.executor.receipt(&draft.id)?.is_none() {
400 return Err(DraftError::ReceiptMismatch);
401 }
402 let receipt = self.execution(&draft)?;
403 self.executor.store.deliver_outcome(&draft, &receipt)?;
404 self.client
405 .record_execution(pass, &draft, &receipt)
406 .await
407 .map_err(|error| DraftError::Acknowledgment {
408 draft: draft.id.clone(),
409 name: error.name().to_owned(),
410 })?;
411 self.executor.store.acknowledge(&draft.id, &receipt)?;
412 report.replayed += 1;
413 report.acknowledged += 1;
414 }
415 }
416 }
417}
418
419#[cfg(test)]
420#[path = "admin_drafts_fixture.rs"]
421mod fixture;
422#[cfg(test)]
423pub(crate) use fixture::fixture_site_drafts;
424
425#[cfg(test)]
426#[path = "admin_drafts_tests.rs"]
427pub(crate) mod tests;
428
429#[cfg(test)]
430mod signal_tests {
431 use super::*;
432 type TestResult = Result<(), Box<dyn std::error::Error>>;
433 use crate::auth_lys::live::drafts::tests::{Issuer, TargetStore, limits};
434 #[tokio::test]
435 async fn startup_is_one_signal_and_unknown_stream_rows_do_not_pull() -> TestResult {
436 let dir = tempfile::tempdir()?;
437 let issuer = Issuer::new(vec![(
438 200,
439 serde_json::json!({"drafts":[],"next":null,"total":0}),
440 )])?;
441 let mut puller = DraftPuller::new(
442 &issuer.api,
443 TargetStore::new(dir.path().join("site.json")),
444 limits()?,
445 )?;
446 assert_eq!(issuer.calls()?, 0);
447 puller
448 .pull("connector-fixture", PullSignal::Startup, 1)
449 .await?;
450 assert!(matches!(
451 puller
452 .pull("connector-fixture", PullSignal::Startup, 2)
453 .await,
454 Err(DraftError::StartupRepeated)
455 ));
456 assert_eq!(
457 crate::auth_lys::live::drafts::stream_signal("placement"),
458 None
459 );
460 assert_eq!(
461 crate::auth_lys::live::drafts::stream_signal("new_unknown_kind"),
462 None
463 );
464 assert_eq!(issuer.calls()?, 1);
465 issuer.finish()
466 }
467}