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