Skip to main content

liminal_server/services/site/
server.rs

1//! Construction routes borrow the admitted session and the approval owner.
2
3use super::super::state::FrameAction;
4use liminal::protocol::{CONVERSATION_REPLY_REQUESTED_FLAG, Frame, MessageEnvelope};
5use serde::Deserialize;
6use serde_json::Value;
7use std::sync::Arc;
8
9use super::LiminalConnectionServices;
10use crate::auth_lys::session::LysSession;
11use crate::auth_lys::{AccessAction, AccessResource, HotAction, SubjectBinding};
12use crate::services::lifecycle::{Lifecycle, plan, site};
13use site::{SiteChange, SiteError};
14
15/// The approval owner derives `SiteAct` from verified claims, performs the exact
16///
17/// Lys check with immutable words, persists the prepared request after holding,
18/// and calls the one waiting writer. Outright never executes a construction act.
19pub trait SiteDraftHolder: Send + Sync + std::fmt::Debug {
20    /// Holds one validated act with the caller's own admitted pass.
21    ///
22    /// # Errors
23    /// Missing held-mode right, issuer refusal, or prepared/waiting store failure.
24    fn hold(
25        &self,
26        lifecycle: &Lifecycle,
27        session: &dyn LysSession,
28        operation: &str,
29        target: &str,
30        change: SiteChange,
31        at_ms: u64,
32    ) -> Result<Value, SiteError>;
33}
34
35#[derive(Deserialize)]
36#[serde(deny_unknown_fields)]
37struct Request {
38    operation: String,
39    payload: Value,
40}
41
42#[derive(Deserialize)]
43#[serde(deny_unknown_fields)]
44struct PlanSet {
45    service: String,
46    steps: Vec<Title>,
47}
48#[derive(Deserialize)]
49#[serde(deny_unknown_fields)]
50struct Title {
51    title: String,
52}
53#[derive(Deserialize)]
54#[serde(deny_unknown_fields)]
55struct PlanStep {
56    service: String,
57    step: usize,
58    status: plan::StepStatus,
59    note: Option<String>,
60}
61#[derive(Deserialize)]
62#[serde(deny_unknown_fields)]
63struct Propose {
64    service: String,
65    command: std::path::PathBuf,
66    args: Vec<String>,
67    env: Vec<String>,
68    stop_timeout_ms: u64,
69}
70#[derive(Deserialize)]
71#[serde(deny_unknown_fields)]
72struct Bind {
73    service: String,
74    agent: String,
75}
76#[derive(Deserialize)]
77#[serde(deny_unknown_fields)]
78struct Target {
79    service: String,
80}
81
82/// Closed built-in route inventory, before ordinary worker/channel routing.
83#[must_use]
84pub fn is_site_route(subject: &str) -> bool {
85    matches!(
86        subject,
87        "services.create_site"
88            | "services.bind_builder"
89            | "services.unbind_builder"
90            | "services.plan_set"
91            | "services.plan_step"
92            | "services.propose_template"
93            | "services.complete"
94            | "templates.update"
95            | "templates.retire"
96    )
97}
98
99fn parse<T: serde::de::DeserializeOwned>(value: Value) -> Result<T, SiteError> {
100    serde_json::from_value(value).map_err(|_| SiteError::Invalid("construction payload"))
101}
102
103/// Maps the plan's exact subject to its site's plan-only right. All other
104/// resources remain on the already installed agent/ordinary pass evaluator.
105///
106/// # Errors
107/// The verifier's own expiry, audience or rights error.
108pub fn evaluate_subject<'a>(
109    pass: &'a lys_pass::VerifiedPass,
110    resource: &AccessResource,
111    action: AccessAction,
112    now: u64,
113) -> Result<lys_pass::Decision<'a>, lys_pass::Error> {
114    if resource.kind == crate::auth_lys::ResourceKind::Subject && action == AccessAction::Publish {
115        if let Some(name) = plan_subject(&resource.id) {
116            return pass.evaluate("liminal.service", name, "plan", now);
117        }
118    }
119    crate::agents::binding::evaluate_subject(pass, resource, action, now)
120}
121
122/// Only exact service-name plan subjects are server-owned retained streams.
123#[must_use]
124pub fn plan_subject(subject: &str) -> Option<&str> {
125    let name = subject.strip_prefix("services.")?.strip_suffix(".plan")?;
126    crate::config::templates::is_segment(name).then_some(name)
127}
128
129fn authorize_plan(
130    session: &dyn LysSession,
131    lifecycle: &Lifecycle,
132    service: &str,
133    at_ms: u64,
134) -> Result<(), SiteError> {
135    let record = lifecycle.record(service)?;
136    if session.holder_kind() != Some("agent") {
137        return Err(SiteError::Refused(format!(
138            "pass holder {} is not the builder of {}",
139            session.holder(),
140            record.name
141        )));
142    }
143    lifecycle.check_plan_holder(&record, session.holder())?;
144    let subject = format!("services.{}.plan", record.name);
145    let binding = SubjectBinding::new(lifecycle.network_id().into(), subject.clone(), subject)
146        .map_err(|_| SiteError::Invalid("plan binding"))?;
147    session
148        .decide(&binding, HotAction::Publish, at_ms / 1000)
149        .map_err(|error| SiteError::Pass(error.to_string()))?;
150    Ok(())
151}
152
153fn prepare_change(
154    lifecycle: &Lifecycle,
155    subject: &str,
156    payload: Value,
157    limits: &site::SiteLimits,
158) -> Result<(String, SiteChange), SiteError> {
159    Ok(match subject {
160        "services.create_site" => {
161            let spec: site::SiteSpec = parse(payload)?;
162            site::validate_contracts(&spec, limits)?;
163            (spec.name.clone(), SiteChange::CreateSite(spec))
164        }
165        "services.bind_builder" => {
166            let body: Bind = parse(payload)?;
167            (body.service, SiteChange::BindBuilder { agent: body.agent })
168        }
169        "services.unbind_builder" => {
170            let body: Target = parse(payload)?;
171            (body.service, SiteChange::UnbindBuilder)
172        }
173        "services.complete" => {
174            let body: Target = parse(payload)?;
175            // Refused before a draft is held, so no person is asked to approve what cannot run.
176            let record = lifecycle
177                .record(&body.service)
178                .map_err(|_| SiteError::Refused(format!("unknown_service: {}", body.service)))?;
179            plan::refuse_unfinished(&record.plan)?;
180            (body.service, SiteChange::Complete)
181        }
182        "services.propose_template" | "templates.update" => {
183            let body: Propose = parse(payload)?;
184            let proposal = if subject == "templates.update" {
185                lifecycle.prepare_template_update(
186                    &body.service,
187                    &body.command,
188                    body.args,
189                    body.env,
190                    body.stop_timeout_ms,
191                )?
192            } else {
193                lifecycle.propose_template(
194                    &body.service,
195                    &body.command,
196                    body.args,
197                    body.env,
198                    body.stop_timeout_ms,
199                )?
200            };
201            let target = proposal.name.clone();
202            (
203                target,
204                if subject == "templates.update" {
205                    SiteChange::UpdateTemplate(proposal)
206                } else {
207                    SiteChange::CreateTemplate(proposal)
208                },
209            )
210        }
211        "templates.retire" => {
212            let body: Target = parse(payload)?;
213            (body.service, SiteChange::RetireTemplate)
214        }
215        _ => return Err(SiteError::Invalid("construction route")),
216    })
217}
218
219impl LiminalConnectionServices {
220    /// Installs the lifecycle projection once, using the same channel scheduler
221    /// and the configured agent watcher budgets. No second scheduler is built.
222    ///
223    /// # Errors
224    /// Missing construction bounds, machine pass, agent registry or issuer.
225    pub fn install_construction(
226        &self,
227        lifecycle: Arc<Lifecycle>,
228        lys: &crate::config::types::LysConfig,
229        http: Arc<dyn crate::services::lys_http::LysHttp>,
230        drafts: Arc<dyn SiteDraftHolder>,
231    ) -> Result<(), SiteError> {
232        let config = lys
233            .construction
234            .as_ref()
235            .ok_or(SiteError::Invalid("[auth.lys.construction]"))?;
236        let agents = self
237            .agents
238            .as_ref()
239            .ok_or(SiteError::Invalid("[auth.lys.agents]"))?;
240        let pass = lys
241            .machine
242            .as_ref()
243            .map(crate::link_pass::MachinePass::configured)
244            .transpose()
245            .map_err(|_| SiteError::Invalid("construction machine pass"))?;
246        let effects = Arc::new(site::effects::LysSiteEffects::new(
247            http,
248            pass,
249            Arc::clone(agents),
250            site::effects::GrantSources {
251                placement: lys
252                    .app_credential_path
253                    .clone()
254                    .map(|path| (path, lys.network_id.clone())),
255                ..config.grants.clone()
256            },
257            config.limits.text_bytes,
258        ));
259        let mut bounds = *agents.bounds();
260        bounds.max_event_bytes = config.limits.request_bytes.get();
261        let retained = crate::agents::retained::Retained::new(
262            bounds,
263            self.cluster.supervisor().clone(),
264            self.console.device(),
265        );
266        lifecycle.configure_sites(
267            config.limits.clone(),
268            self.durable_store(),
269            lys.templates.policy.clone(),
270            effects,
271            retained,
272        )?;
273        lifecycle.validate_site_storage()?;
274        lifecycle.bind_site_console(&self.console)?;
275        self.site_drafts
276            .set(drafts)
277            .map_err(|_| SiteError::Invalid("construction drafts already installed"))?;
278        self.site_lifecycle
279            .set(lifecycle)
280            .map_err(|_| SiteError::Invalid("construction already installed"))?;
281        Ok(())
282    }
283
284    /// Executes the ticket route using exactly the connection's admitted session.
285    ///
286    /// # Errors
287    /// Unknown route, invalid boundary, grant refusal or target-store failure.
288    pub fn serve_site_tool(
289        &self,
290        session: Option<&dyn LysSession>,
291        subject: &str,
292        bytes: &[u8],
293        at_ms: u64,
294    ) -> Result<Value, SiteError> {
295        if !is_site_route(subject) {
296            return Err(SiteError::Invalid("construction route"));
297        }
298        let lifecycle = self
299            .site_lifecycle
300            .get()
301            .ok_or(SiteError::Invalid("construction not installed"))?;
302        let session = session.ok_or(SiteError::Invalid(
303            "construction needs an admitted Lys pass",
304        ))?;
305        let limits = &lifecycle.construction_limits()?;
306        if bytes.len() > limits.request_bytes.get() {
307            return Err(SiteError::Bound("request_bytes"));
308        }
309        let request: Request = serde_json::from_slice(bytes)
310            .map_err(|_| SiteError::Invalid("construction request"))?;
311        if request.operation.is_empty()
312            || request.operation.len() > limits.text_bytes.get()
313            || request.operation.chars().any(char::is_control)
314        {
315            return Err(SiteError::Invalid("operation"));
316        }
317        let (target, change) = match subject {
318            "services.plan_set" => {
319                let body: PlanSet = parse(request.payload)?;
320                authorize_plan(session, lifecycle, &body.service, at_ms)?;
321                let titles = body
322                    .steps
323                    .into_iter()
324                    .map(|step| step.title)
325                    .collect::<Vec<_>>();
326                let plan = lifecycle.plan_set(&body.service, session.holder(), &titles, at_ms)?;
327                self.construction_changed()?;
328                return Ok(plan::current_value(&plan));
329            }
330            "services.plan_step" => {
331                let body: PlanStep = parse(request.payload)?;
332                authorize_plan(session, lifecycle, &body.service, at_ms)?;
333                let plan = lifecycle.plan_step(
334                    &body.service,
335                    session.holder(),
336                    body.step,
337                    body.status,
338                    body.note,
339                    at_ms,
340                )?;
341                self.construction_changed()?;
342                return Ok(plan::current_value(&plan));
343            }
344            _ => prepare_change(lifecycle, subject, request.payload, limits)?,
345        };
346        self.site_drafts
347            .get()
348            .ok_or(SiteError::Invalid("construction approval owner absent"))?
349            .hold(
350                lifecycle,
351                session,
352                &request.operation,
353                &target,
354                change,
355                at_ms,
356            )
357    }
358
359    fn construction_changed(&self) -> Result<(), SiteError> {
360        self.site_lifecycle
361            .get()
362            .ok_or(SiteError::Invalid("construction not installed"))?
363            .refresh_sites()
364    }
365
366    pub(super) fn subscribe_site_plan(
367        &self,
368        session: Option<&dyn LysSession>,
369        channel: &str,
370        schemas: &[super::ProtocolSchemaId],
371        install: Option<liminal::channel::InboxInstall>,
372    ) -> Result<super::ConnectionSubscription, crate::ServerError> {
373        let name =
374            plan_subject(channel).ok_or_else(|| site_error(SiteError::Invalid("plan subject")))?;
375        let lifecycle = self
376            .site_lifecycle
377            .get()
378            .ok_or_else(|| site_error(SiteError::Invalid("construction not installed")))?;
379        lifecycle
380            .record(name)
381            .map_err(|error| site_error(error.into()))?;
382        let session =
383            session.ok_or_else(|| site_error(SiteError::Invalid("plan watch needs a pass")))?;
384        let binding = SubjectBinding::new(
385            lifecycle.network_id().into(),
386            channel.into(),
387            channel.into(),
388        )
389        .map_err(|_| site_error(SiteError::Invalid("plan watch binding")))?;
390        session
391            .decide(
392                &binding,
393                HotAction::Subscribe,
394                super::super::apply::unix_seconds_now(),
395            )
396            .map_err(|error| site_error(SiteError::Pass(error.to_string())))?;
397        let id = self
398            .next_subscription_id
399            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
400        lifecycle
401            .subscribe_plan(id, channel, schemas, install)
402            .map_err(site_error)
403    }
404}
405
406/// Maps named construction refusals without retaining their payload bytes.
407pub fn site_error(error: SiteError) -> crate::ServerError {
408    error.into()
409}
410impl From<SiteError> for crate::ServerError {
411    fn from(error: SiteError) -> Self {
412        Self::ListenerAccept {
413            message: error.to_string(),
414        }
415    }
416}
417
418/// Uses the existing conversation reply and refusal shapes for construction.
419pub(in super::super) fn site_frame_response(
420    frame: SiteFrame<'_>,
421) -> Option<super::super::state::FrameAction> {
422    let SiteFrame {
423        services,
424        session,
425        flags,
426        stream_id,
427        conversation_id,
428        subject,
429        envelope,
430        at_ms,
431    } = frame;
432    let answer = services.construction_request(session, subject, &envelope.payload, at_ms)?;
433    let refused = |message| {
434        FrameAction::Respond(Frame::ConversationError {
435            flags: 0,
436            stream_id,
437            conversation_id,
438            reason_code: super::super::service_route::SERVICE_REFUSED_CODE,
439            message: Some(message),
440        })
441    };
442    Some(match answer {
443        Err(crate::ServerError::ListenerAccept { message }) => refused(message),
444        Err(error) => refused(error.to_string()),
445        Ok(answer) => {
446            if flags & CONVERSATION_REPLY_REQUESTED_FLAG == 0 {
447                return Some(FrameAction::NoResponse);
448            }
449            serde_json::to_vec(&answer).map_or_else(
450                |_| refused("construction_reply_encoding_failed".into()),
451                |bytes| {
452                    FrameAction::Respond(Frame::ConversationMessage {
453                        flags: CONVERSATION_REPLY_REQUESTED_FLAG,
454                        stream_id,
455                        conversation_id,
456                        envelope: MessageEnvelope::new(
457                            envelope.schema_id,
458                            envelope.causal_context.clone(),
459                            bytes,
460                        ),
461                    })
462                },
463            )
464        }
465    })
466}
467
468/// Borrowed wire metadata for the existing conversation response shape.
469#[derive(Clone, Copy)]
470pub(in super::super) struct SiteFrame<'a> {
471    pub services: &'a dyn super::ConnectionServices,
472    pub session: Option<&'a dyn LysSession>,
473    pub flags: u8,
474    pub stream_id: u32,
475    pub conversation_id: u64,
476    pub subject: &'a str,
477    pub envelope: &'a liminal::protocol::MessageEnvelope,
478    pub at_ms: u64,
479}