1use std::collections::{BTreeMap, BTreeSet, HashMap};
10use std::sync::{Arc, Mutex};
11
12use async_trait::async_trait;
13use ikigai_core::{
14 ActionSpec, ArgRef, ArgSpec, Description, Endpoint, EndpointSpace, Error, Exact, Invocation,
15 Iri, ReprType, Representation, Request, Resolution, Result, Scope, Space, SpaceEntry, Topology,
16 UriTemplate, Verb,
17};
18use serde::{Deserialize, Serialize};
19
20use crate::authority::{
21 self, cap, cap_run, principal_of, Act, Ceiling, CeilingPolicy, CAP_ANY, CAP_DELETE, CAP_LISP,
22 CAP_READ, CAP_READ_PUBLIC, CAP_RUN, CAP_RUN_PUBLIC, CAP_WRITE,
23};
24use crate::backend::Backend;
25use crate::model::{
26 self, Event, Head, Language, Outcome, Run, State, Version, MAX_RECORDED_RESULT, SCHEMA,
27};
28use crate::name::{self, CATALOG_IRI, EVAL_IRI};
29use crate::plan;
30use crate::sparql::{self, Analysis, Form, Parameter, SparqlDoor, Value, CAP_READ_GRAPH};
31
32const PLAIN: &str = "text/plain";
33const JSON: &str = "application/json";
34const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
35const XSD_BOOLEAN: &str = "http://www.w3.org/2001/XMLSchema#boolean";
36const XSD_INTEGER: &str = "http://www.w3.org/2001/XMLSchema#integer";
37const TURTLE: &str = "text/turtle";
38const READ_FACES: [&str; 2] = [PLAIN, JSON];
40const RECORD_FACES: [&str; 3] = [PLAIN, JSON, TURTLE];
42
43#[derive(Clone)]
53#[non_exhaustive]
54pub struct SpaceConfig {
55 backend: Arc<dyn Backend>,
56 ceiling: CeilingPolicy,
57 sparql: Option<SparqlDoor>,
58 on_change: Option<ChangeHook>,
59 described: Arc<Mutex<HashMap<String, Option<PerScript>>>>,
62}
63
64pub type ChangeHook = Arc<dyn Fn(&str) + Send + Sync>;
67
68impl SpaceConfig {
69 pub fn new(backend: Arc<dyn Backend>, ceiling: CeilingPolicy) -> SpaceConfig {
71 SpaceConfig {
72 backend,
73 ceiling,
74 sparql: None,
75 on_change: None,
76 described: Arc::new(Mutex::new(HashMap::new())),
77 }
78 }
79
80 pub fn sparql(mut self, door: SparqlDoor) -> SpaceConfig {
84 self.sparql = Some(door);
85 self
86 }
87
88 pub fn on_change(mut self, hook: ChangeHook) -> SpaceConfig {
99 self.on_change = Some(hook);
100 self
101 }
102
103 fn changed(&self, name: &str) {
105 self.described
106 .lock()
107 .expect("the described-script memo")
108 .remove(name);
109 if let Some(hook) = &self.on_change {
110 hook(name);
111 }
112 }
113}
114
115pub fn space(config: SpaceConfig) -> ScriptSpace {
122 let shared = Arc::new(config);
123 let template = |t: &str| UriTemplate::parse(t).expect("a constant template");
124 let inner = EndpointSpace::new()
125 .bind(Exact::new(EVAL_IRI), EvalEndpoint)
126 .bind(
127 Exact::new(CATALOG_IRI),
128 CatalogEndpoint {
129 shared: Arc::clone(&shared),
130 },
131 )
132 .bind(
133 template("urn:script:{name}:version:{digest}"),
134 VersionEndpoint {
135 shared: Arc::clone(&shared),
136 },
137 )
138 .bind(
139 template("urn:script:{name}:run:{id}"),
140 RunEndpoint {
141 shared: Arc::clone(&shared),
142 },
143 )
144 .bind(
145 template("urn:script:{name}:compiled"),
146 CompiledEndpoint {
147 shared: Arc::clone(&shared),
148 },
149 )
150 .bind(
151 template("urn:script:{name}:result"),
152 ResultEndpoint {
153 shared: Arc::clone(&shared),
154 },
155 )
156 .bind(
157 template("urn:script:{name}:runs"),
158 RunsEndpoint {
159 shared: Arc::clone(&shared),
160 },
161 )
162 .bind(
163 template("urn:script:{name}"),
164 ScriptEndpoint {
165 shared: Arc::clone(&shared),
166 },
167 );
168 ScriptSpace { inner, shared }
169}
170
171fn holds(inv: &Invocation<'_>, scope: &str) -> bool {
176 inv.capability.allows(scope)
177}
178
179fn may(inv: &Invocation<'_>, act: Act, name: &str) -> bool {
182 authority::holds(inv.capability, act, name)
183}
184
185fn may_public(inv: &Invocation<'_>, act: Act, public: &str, name: &str) -> bool {
188 holds(inv, public) && !authority::excluded(inv.capability, act, name)
189}
190
191const GRANT_FORMS: &str = "A script grant names one script (`urn:cap:script:{act}:{name}`) or a \
193 namespace of them (`urn:cap:script:{act}:{namespace}-*`)";
194
195fn is_public(head: &Head) -> bool {
197 head.public && head.state == State::Published
198}
199
200fn readable_head(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Result<Option<Head>> {
206 gated_head(inv, shared, name, &[(Act::Read, CAP_READ_PUBLIC)])
207}
208
209fn gated_head(
212 inv: &Invocation<'_>,
213 shared: &SpaceConfig,
214 name: &str,
215 acts: &[(Act, &str)],
216) -> Result<Option<Head>> {
217 if acts.iter().any(|(act, _)| may(inv, *act, name)) {
218 return shared.backend.head(name);
219 }
220 if let Ok(Some(head)) = shared.backend.head(name) {
221 if is_public(&head)
222 && acts
223 .iter()
224 .any(|(act, public)| may_public(inv, *act, public, name))
225 {
226 return Ok(Some(head));
227 }
228 }
229 Err(Error::Denied(format!(
230 "this capability holds none of {} — nor, for a public script, {}. {GRANT_FORMS}, and \
231 an exclusion (`…:-{{name}}`) takes one back out",
232 acts.iter()
233 .map(|(act, _)| cap(*act, name))
234 .collect::<Vec<_>>()
235 .join(", "),
236 acts.iter()
237 .map(|(_, public)| *public)
238 .collect::<Vec<_>>()
239 .join(", ")
240 )))
241}
242
243fn require(inv: &Invocation<'_>, act: Act, name: &str, doing: &str) -> Result<()> {
244 if may(inv, act, name) {
245 Ok(())
246 } else {
247 Err(Error::Denied(format!(
248 "{doing} needs `{}`, which this capability does not hold. {GRANT_FORMS}, and an \
249 exclusion (`…:-{{name}}`) takes one back out",
250 cap(act, name)
251 )))
252 }
253}
254
255fn found(head: Option<Head>, name: &str) -> Result<Head> {
256 head.ok_or_else(|| Error::NotFound(format!("no script is published at urn:script:{name}")))
257}
258
259#[derive(Clone, Copy, Debug, PartialEq, Eq)]
279enum Sight {
280 Visible,
282 Hidden,
285}
286
287fn ever_published(head: &Head, digest: &str) -> bool {
290 (head.version == digest && head.state == State::Published)
291 || head
292 .history
293 .iter()
294 .any(|event| event.action == "publish" && event.version == digest)
295}
296
297fn sight(inv: &Invocation<'_>, head: &Head, digest: &str) -> Sight {
303 if ever_published(head, digest) || inv.capability.is_root() {
304 return Sight::Visible;
305 }
306 let wrote_it = |author: &str| authority::is_identity(author) && inv.capability.acts_as(author);
307 let mine = (head.version == digest && wrote_it(&head.publisher))
308 || head
309 .history
310 .iter()
311 .any(|e| e.action == "draft" && e.version == digest && wrote_it(&e.principal));
312 if mine {
313 Sight::Visible
314 } else {
315 Sight::Hidden
316 }
317}
318
319fn absent(inv: &Invocation<'_>, name: &str) -> Error {
323 let mut message = format!("no script is published at urn:script:{name}");
324 if inv.capability.principal().is_none() && !inv.capability.is_root() {
325 message.push_str(
326 " (this request's capability names no principal (`urn:cap:principal:…`, minted \
327 by the host's door), so it cannot be shown to be a draft's author: a draft is \
328 visible to its author and root alone until it is published)",
329 );
330 }
331 Error::NotFound(message)
332}
333
334fn seen_head(inv: &Invocation<'_>, name: &str, gated: Option<Head>) -> Result<Head> {
337 let Some(head) = gated else {
338 return Err(absent(inv, name));
339 };
340 match sight(inv, &head, &head.version) {
341 Sight::Hidden => Err(absent(inv, name)),
342 Sight::Visible => Ok(head),
343 }
344}
345
346fn head_version(shared: &SpaceConfig, head: &Head) -> Result<Version> {
347 shared
348 .backend
349 .version(&head.name, &head.version)?
350 .ok_or_else(|| {
351 Error::Endpoint(format!(
352 "urn:script:{} points at version {}, which its backend does not hold",
353 head.name, head.version
354 ))
355 })
356}
357
358fn optional<'a>(inv: &'a Invocation<'_>, name: &str) -> Result<Option<&'a str>> {
364 match inv.inline_str(name) {
365 Ok(value) => Ok(Some(value)),
366 Err(Error::MissingArgument(_)) => Ok(None),
367 Err(other) => Err(other),
368 }
369}
370
371fn parse_bool(name: &str, text: &str) -> Result<bool> {
372 match text.trim() {
373 "true" => Ok(true),
374 "false" => Ok(false),
375 other => Err(Error::InvalidArgument {
376 name: name.to_string(),
377 detail: format!("`{other}` is not `true` or `false`"),
378 }),
379 }
380}
381
382fn wanted_face(inv: &Invocation<'_>) -> Result<&'static str> {
385 face_of(inv, &READ_FACES)
386}
387
388fn face_of(inv: &Invocation<'_>, faces: &[&'static str]) -> Result<&'static str> {
390 match optional(inv, "as")? {
391 None => Ok(faces[0]),
392 Some(asked) => {
393 let bare = asked.split(';').next().unwrap_or(asked).trim();
394 faces
395 .iter()
396 .find(|face| **face == bare)
397 .copied()
398 .ok_or_else(|| Error::InvalidArgument {
399 name: "as".to_string(),
400 detail: format!(
401 "`{asked}` is not a face this resource serves; one of {}",
402 faces.join(", ")
403 ),
404 })
405 }
406 }
407}
408
409fn plain(text: impl Into<String>) -> Representation {
410 Representation::new(
411 ReprType::new(PLAIN).with_param("charset", "utf-8"),
412 text.into().into_bytes(),
413 )
414}
415
416fn json<T: Serialize>(value: &T) -> Result<Representation> {
417 let mut bytes = serde_json::to_vec_pretty(value)
418 .map_err(|e| Error::Endpoint(format!("could not serialize a record: {e}")))?;
419 bytes.push(b'\n');
420 Ok(Representation::new(ReprType::new(JSON), bytes))
421}
422
423fn iri(text: &str) -> Result<Iri> {
424 Iri::parse(text).map_err(|e| Error::Endpoint(format!("`{text}` is not an IRI: {e}")))
425}
426
427fn inline(text: &str) -> ArgRef {
428 ArgRef::Inline(text.as_bytes().to_vec())
429}
430
431fn name_arg() -> ArgSpec {
432 ArgSpec::new("name")
433 .summary(
434 "The script's name: one segment of lowercase letters, digits, `-` and `_` \
435 (`eval`, `catalog`, `public`, `outcome` and `principal` are reserved).",
436 )
437 .class(XSD_STRING)
438 .binding()
439}
440
441fn as_arg() -> ArgSpec {
442 ArgSpec::new("as")
443 .summary(
444 "The face: `text/plain` (for people) or `application/json` (the versioned \
445 record, `\"schema\": 1`). Any other is refused, never substituted.",
446 )
447 .class(XSD_STRING)
448 .one_of(READ_FACES)
449 .default_value(PLAIN)
450 .optional()
451}
452
453fn record_as_arg() -> ArgSpec {
455 ArgSpec::new("as")
456 .summary(
457 "The face: `text/plain` (for people), `application/json` (the versioned record, \
458 `\"schema\": 1`) or `text/turtle` (the graph: PROV-O and the shared \
459 vocabulary, no blank nodes). Any other is refused, never substituted.",
460 )
461 .class(XSD_STRING)
462 .one_of(RECORD_FACES)
463 .default_value(PLAIN)
464 .optional()
465}
466
467fn turtle(graph: crate::graph::Graph) -> Result<Representation> {
468 let bytes = graph
469 .turtle()
470 .map_err(|e| Error::Endpoint(format!("could not write the graph face: {e}")))?;
471 Ok(Representation::new(
472 ReprType::new(TURTLE).with_param("charset", "utf-8"),
473 bytes,
474 ))
475}
476
477fn data_arg(arg: &str, summary: &str) -> ArgSpec {
478 ArgSpec::new(arg)
479 .summary(summary)
480 .class(XSD_STRING)
481 .optional()
482}
483
484#[derive(Serialize)]
490struct ScriptDocument<'a> {
491 iri: String,
492 #[serde(flatten)]
493 head: &'a Head,
494 #[serde(rename = "versionIri")]
495 version_iri: String,
496 language: Language,
497 requires: &'a BTreeSet<String>,
498 source: &'a str,
499}
500
501struct ScriptEndpoint {
502 shared: Arc<SpaceConfig>,
503}
504
505#[async_trait]
506impl Endpoint for ScriptEndpoint {
507 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
508 let name = name::from_bindings(inv)?;
509 let shared = &self.shared;
510 match inv.request.verb {
511 Verb::Exists => {
512 let exists = match readable_head(inv, shared, &name)? {
513 None => false,
514 Some(head) => sight(inv, &head, &head.version) == Sight::Visible,
515 };
516 Ok(plain(if exists { "true\n" } else { "false\n" }).cacheable())
517 }
518 Verb::Source => {
519 let want = wanted_face(inv)?;
520 let head = seen_head(inv, &name, readable_head(inv, shared, &name)?)?;
521 let version = head_version(shared, &head)?;
522 let repr = if want == JSON {
523 json(&ScriptDocument {
524 iri: name::script_iri(&name),
525 head: &head,
526 version_iri: name::version_iri(&name, &head.version),
527 language: version.language,
528 requires: &version.requires,
529 source: &version.source,
530 })?
531 } else {
532 plain(version.source)
533 };
534 Ok(repr.cacheable())
535 }
536 Verb::Sink => publish(inv, shared, &name).await,
537 Verb::Delete => retire(inv, shared, &name),
538 other => Err(unsupported("script", other)),
539 }
540 }
541
542 fn name(&self) -> &str {
543 "script"
544 }
545
546 fn describe(&self) -> Description {
547 Description::new("script")
548 .title("A script")
549 .summary(
550 "A script as a resource: fetch its source without running it, publish or \
551 replace it (every write is a new content-addressed version), or retire it.",
552 )
553 .verb(Verb::Meta)
554 .action(
555 ActionSpec::new(Verb::Source)
556 .summary(
557 "The script, WITHOUT running it: its source (`text/plain`), or its head \
558 record (`application/json`): state, public flag, version, what it \
559 declares and what its publisher held. Needs \
560 `urn:cap:script:read:{name}`, or `urn:cap:script:read:public` for a \
561 public published script.",
562 )
563 .input(name_arg())
564 .input(as_arg())
565 .output(PLAIN)
566 .output(JSON)
567 .requires(CAP_READ),
568 )
569 .action(
570 ActionSpec::new(Verb::Exists)
571 .summary("Whether the script exists (to a caller who may read it).")
572 .input(name_arg())
573 .output(PLAIN)
574 .requires(CAP_READ),
575 )
576 .action(
577 ActionSpec::new(Verb::Sink)
578 .summary(
579 "Publish or replace the script; answers the new version's IRI. The \
580 declared `requires` (plus the language's own capability) must all \
581 be held by the publisher, or the publish is refused: a script never \
582 runs with more than its publisher held. Needs \
583 `urn:cap:script:write:{name}`.",
584 )
585 .input(name_arg())
586 .input(
587 ArgSpec::new("content")
588 .summary("The source text.")
589 .class(XSD_STRING),
590 )
591 .input(
592 ArgSpec::new("language")
593 .summary(
594 "The script's language: `lisp`, `sparql` (a query or update, \
595 when the host mounts a SPARQL door), or `plan` (an \
596 ik:Process graph, when the host binds the plan doors).",
597 )
598 .class(XSD_STRING)
599 .one_of(Language::ALL.map(Language::as_str))
600 .default_value("lisp")
601 .optional(),
602 )
603 .input(
604 ArgSpec::new("requires")
605 .summary(
606 "Whitespace-separated `urn:cap:` grants the script needs to \
607 run. Lisp: the language's capability is added; exact grants \
608 only, no wildcards, no exclusions. SPARQL: DERIVED from the \
609 graphs the text names; a plan: DERIVED from its steps' \
610 contracts. For both, omit it: one that says anything else \
611 is refused, naming the derived set.",
612 )
613 .class(XSD_STRING)
614 .optional(),
615 )
616 .input(
617 ArgSpec::new("public")
618 .summary(
619 "Whether holders of `urn:cap:script:run:public` (a host's \
620 anonymous principal) may run it and holders of \
621 `urn:cap:script:read:public` read it.",
622 )
623 .class(XSD_BOOLEAN)
624 .one_of(["true", "false"])
625 .default_value("false")
626 .optional(),
627 )
628 .input(
629 ArgSpec::new("state")
630 .summary(
631 "`published` (runnable) or `draft` (saved, not runnable, \
632 and visible only to its author until published). To \
633 retire, Delete.",
634 )
635 .class(XSD_STRING)
636 .one_of(["published", "draft"])
637 .default_value("published")
638 .optional(),
639 )
640 .input(
641 ArgSpec::new("if-version")
642 .summary(
643 "Write only if the head is still this version (`none`: only \
644 if the script does not exist yet); otherwise `Conflict`.",
645 )
646 .class(XSD_STRING)
647 .optional(),
648 )
649 .output(PLAIN)
650 .requires(CAP_WRITE),
651 )
652 .action(
653 ActionSpec::new(Verb::Delete)
654 .summary(
655 "Retire the script: it stays readable and its versions fetchable, but \
656 it never runs again until republished. Needs \
657 `urn:cap:script:delete:{name}`.",
658 )
659 .input(name_arg())
660 .output(PLAIN)
661 .requires(CAP_DELETE),
662 )
663 }
664}
665
666async fn publish(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Result<Representation> {
667 require(inv, Act::Write, name, "publishing a script")?;
668 let source = inv.inline_str("content")?;
669 let language = optional(inv, "language")?
670 .map(Language::parse)
671 .transpose()?
672 .unwrap_or(Language::Lisp);
673 let requires = match language {
674 Language::Lisp => {
675 let mut requires = optional(inv, "requires")?
676 .map(authority::parse_requires)
677 .transpose()?
678 .unwrap_or_default();
679 requires.insert(CAP_LISP.to_string());
680 requires
681 }
682 Language::Sparql => derived_requires(inv, shared, source)?,
683 Language::Plan => {
684 plan::check_bound(source)?;
688 plan::validate(inv, source).await?;
689 plan::analyze(source)?;
690 plan::derive(inv, source, optional(inv, "requires")?).await?
691 }
692 };
693 let public = optional(inv, "public")?
694 .map(|v| parse_bool("public", v))
695 .transpose()?
696 .unwrap_or(false);
697 let state = match optional(inv, "state")?.map(str::trim) {
698 None | Some("published") => State::Published,
699 Some("draft") => State::Draft,
700 Some(other) => {
701 return Err(Error::InvalidArgument {
702 name: "state".to_string(),
703 detail: format!(
704 "`{other}` is not `published` or `draft`; to retire a script, Delete it"
705 ),
706 })
707 }
708 };
709 let grant = authority::grant_at_publish(inv.capability, &requires)?;
712
713 let version = Version {
714 language,
715 requires,
716 source: source.to_string(),
717 };
718 let digest = version.digest();
719 let current = shared.backend.head(name)?;
720 if let Some(expected) = optional(inv, "if-version")? {
721 let at = current.as_ref().map_or("none", |h| h.version.as_str());
722 if at != expected.trim() {
723 return Err(Error::Conflict(format!(
724 "urn:script:{name} is at {at}, not {}; read it again and retry",
725 expected.trim()
726 )));
727 }
728 }
729 shared.backend.put_version(name, &version)?;
730
731 let principal = principal_of(inv.capability);
732 let at = inv.now().map(|t| t.as_millis());
733 let mut history = current
734 .as_ref()
735 .map(|h| h.history.clone())
736 .unwrap_or_default();
737 history.push(Event {
738 action: if state == State::Draft {
739 "draft"
740 } else {
741 "publish"
742 }
743 .to_string(),
744 version: digest.clone(),
745 state,
746 public,
747 principal: principal.clone(),
748 at,
749 });
750 let head = Head {
751 schema: SCHEMA,
752 name: name.to_string(),
753 version: digest.clone(),
754 state,
755 public,
756 granted: grant.granted,
757 exclusions: grant.exclusions,
758 publisher: principal,
759 updated: at,
760 history,
761 };
762 shared.backend.swap_head(name, current.as_ref(), &head)?;
763 shared.changed(name);
764 Ok(plain(format!("{}\n", name::version_iri(name, &digest))))
765}
766
767fn derived_requires(
771 inv: &Invocation<'_>,
772 shared: &SpaceConfig,
773 source: &str,
774) -> Result<BTreeSet<String>> {
775 let door = door(shared).map_err(|_| Error::InvalidArgument {
776 name: "language".to_string(),
777 detail: "this host takes no SPARQL scripts: it mounts no SPARQL door".to_string(),
778 })?;
779 let derived = sparql::analyze(source, door)?.requires();
780 if let Some(said) = optional(inv, "requires")? {
781 let said: BTreeSet<String> = said.split_whitespace().map(str::to_string).collect();
782 if said != derived {
783 return Err(Error::InvalidArgument {
784 name: "requires".to_string(),
785 detail: format!(
786 "a SPARQL script's authority is derived from the graphs its text names, \
787 not declared, and this one derives {}; the `requires` given says {}. \
788 Omit `requires`",
789 listed(&derived),
790 listed(&said)
791 ),
792 });
793 }
794 }
795 Ok(derived)
796}
797
798fn listed(scopes: &BTreeSet<String>) -> String {
799 if scopes.is_empty() {
800 "nothing".to_string()
801 } else {
802 scopes.iter().cloned().collect::<Vec<_>>().join(" ")
803 }
804}
805
806fn door(shared: &SpaceConfig) -> Result<&SparqlDoor> {
807 shared.sparql.as_ref().ok_or_else(|| {
808 Error::Endpoint(
809 "this host mounts no SPARQL door (`SpaceConfig::sparql`), so it cannot run a \
810 SPARQL script"
811 .to_string(),
812 )
813 })
814}
815
816fn retire(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Result<Representation> {
817 require(inv, Act::Delete, name, "retiring a script")?;
818 let current = found(shared.backend.head(name)?, name)?;
819 let iri = name::script_iri(name);
820 if current.state == State::Retired {
821 return Ok(plain(format!("{iri} was already retired\n")));
822 }
823 let at = inv.now().map(|t| t.as_millis());
824 let mut head = current.clone();
825 head.state = State::Retired;
826 head.updated = at;
827 head.history.push(Event {
828 action: "retire".to_string(),
829 version: current.version.clone(),
830 state: State::Retired,
831 public: current.public,
832 principal: principal_of(inv.capability),
833 at,
834 });
835 shared.backend.swap_head(name, Some(¤t), &head)?;
836 shared.changed(name);
837 Ok(plain(format!("retired {iri}\n")))
838}
839
840struct VersionEndpoint {
845 shared: Arc<SpaceConfig>,
846}
847
848#[async_trait]
849impl Endpoint for VersionEndpoint {
850 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
851 let name = name::from_bindings(inv)?;
852 let digest = inv
853 .bindings
854 .get("digest")
855 .ok_or_else(|| Error::Endpoint("no `digest` captured".to_string()))?
856 .to_string();
857 inv.depends_on(name::script_iri(&name));
871 let head = readable_head(inv, &self.shared, &name)?;
872 let version = self.shared.backend.version(&name, &digest);
873 let seen = match (&version, &head) {
877 (Ok(Some(_)), Some(head)) => sight(inv, head, &digest),
878 (Ok(Some(_)), None) if !inv.capability.is_root() => Sight::Hidden,
879 _ => Sight::Visible,
880 };
881 let no_such = || Error::NotFound(format!("urn:script:{name} has no version {digest}"));
882 match inv.request.verb {
883 Verb::Exists => {
884 let exists = match version {
885 Ok(v) => v.is_some() && seen != Sight::Hidden,
886 Err(Error::InvalidArgument { .. }) => false,
887 Err(other) => return Err(other),
888 };
889 Ok(plain(if exists { "true\n" } else { "false\n" }).cacheable())
890 }
891 Verb::Source => {
892 let want = wanted_face(inv)?;
893 let version = version?.ok_or_else(no_such)?;
894 if seen == Sight::Hidden {
895 return Err(no_such());
896 }
897 let repr = if want == JSON {
898 json(&version.document(&name))?
899 } else {
900 plain(version.source)
901 };
902 Ok(repr.cacheable())
903 }
904 other => Err(unsupported("script-version", other)),
905 }
906 }
907
908 fn name(&self) -> &str {
909 "script-version"
910 }
911
912 fn describe(&self) -> Description {
913 let digest = || {
914 ArgSpec::new("digest")
915 .summary("The version's digest: `sha256:` and 64 hex digits over its content.")
916 .class(XSD_STRING)
917 .binding()
918 };
919 Description::new("script-version")
920 .title("One version of a script")
921 .summary(
922 "An immutable version, named by the sha256 of its content (language, declared \
923 capability, source). Republishing moves the head; every version stays \
924 fetchable here.",
925 )
926 .verb(Verb::Meta)
927 .action(
928 ActionSpec::new(Verb::Source)
929 .summary(
930 "The version's source (`text/plain`) or its record (`application/json`).",
931 )
932 .input(name_arg())
933 .input(digest())
934 .input(as_arg())
935 .output(PLAIN)
936 .output(JSON)
937 .requires(CAP_READ),
938 )
939 .action(
940 ActionSpec::new(Verb::Exists)
941 .summary("Whether the script has this version.")
942 .input(name_arg())
943 .input(digest())
944 .output(PLAIN)
945 .requires(CAP_READ),
946 )
947 }
948}
949
950#[derive(Clone, Debug, Serialize, Deserialize)]
960pub struct Prepared {
961 pub schema: u32,
963 pub name: String,
965 pub iri: String,
967 pub version: String,
969 pub language: Language,
971 pub evaluator: String,
973 pub program: String,
975 pub requires: BTreeSet<String>,
977 pub granted: BTreeSet<String>,
979 pub exclusions: BTreeSet<String>,
981 pub public: bool,
983 pub state: State,
985 #[serde(default, skip_serializing_if = "Option::is_none")]
987 pub sparql: Option<Analysis>,
988 #[serde(default, skip_serializing_if = "Option::is_none")]
990 pub plan: Option<plan::Analysis>,
991}
992
993struct CompiledEndpoint {
994 shared: Arc<SpaceConfig>,
995}
996
997#[async_trait]
998impl Endpoint for CompiledEndpoint {
999 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1000 if inv.request.verb != Verb::Source {
1001 return Err(unsupported("script-compiled", inv.request.verb));
1002 }
1003 let name = name::from_bindings(inv)?;
1004 inv.depends_on(name::script_iri(&name));
1012 let gated = gated_head(
1015 inv,
1016 &self.shared,
1017 &name,
1018 &[(Act::Read, CAP_READ_PUBLIC), (Act::Run, CAP_RUN_PUBLIC)],
1019 )?;
1020 let head = seen_head(inv, &name, gated)?;
1021 let version = head_version(&self.shared, &head)?;
1022 let (evaluator, analysis, plan_analysis) = match version.language {
1023 Language::Lisp => (LISP_EVAL.to_string(), None, None),
1024 Language::Sparql => {
1025 let door = door(&self.shared)?;
1026 let analysis = sparql::analyze(&version.source, door)?;
1027 (door.query_iri(analysis.form), Some(analysis), None)
1028 }
1029 Language::Plan => (
1030 plan::EVAL.to_string(),
1031 None,
1032 Some(plan::analyze(&version.source)?),
1033 ),
1034 };
1035 let prepared = Prepared {
1036 schema: SCHEMA,
1037 iri: name::part_iri(&name, "compiled"),
1038 version: head.version.clone(),
1039 language: version.language,
1040 evaluator,
1041 program: version.source,
1042 requires: version.requires,
1043 granted: head.granted,
1044 exclusions: head.exclusions,
1045 public: head.public,
1046 state: head.state,
1047 sparql: analysis,
1048 plan: plan_analysis,
1049 name: name.clone(),
1050 };
1051 Ok(json(&prepared)?.cacheable())
1052 }
1053
1054 fn name(&self) -> &str {
1055 "script-compiled"
1056 }
1057
1058 fn describe(&self) -> Description {
1059 Description::new("script-compiled")
1060 .title("A script's compiled form")
1061 .summary(
1062 "The head version prepared for its evaluator, with the authority it runs \
1063 under (and, for SPARQL, its form, parameters and graphs; for a plan, its \
1064 steps' verbs and its parameters): cached, and cut \
1065 whenever the script is republished or retired. Readable by a holder of the \
1066 script's read OR run grant (a run reads its program here).",
1067 )
1068 .verb(Verb::Meta)
1069 .action(
1070 ActionSpec::new(Verb::Source)
1071 .summary("The prepared form, as JSON.")
1072 .input(name_arg())
1073 .output(JSON)
1074 .requires(CAP_ANY),
1075 )
1076 }
1077}
1078
1079const LISP_EVAL: &str = "urn:lisp:eval";
1084
1085async fn prepare_run(
1088 inv: &Invocation<'_>,
1089 shared: &SpaceConfig,
1090 name: &str,
1091) -> Result<(Prepared, BTreeSet<String>, Ceiling)> {
1092 let compiled = inv
1095 .issue(Request::new(
1096 Verb::Source,
1097 iri(&name::part_iri(name, "compiled"))?,
1098 ))
1099 .await?;
1100 let prepared: Prepared = serde_json::from_slice(&compiled.bytes).map_err(|e| {
1101 Error::Endpoint(format!(
1102 "urn:script:{name}:compiled did not answer a prepared form: {e}"
1103 ))
1104 })?;
1105 if prepared.name != name {
1106 return Err(Error::Endpoint(format!(
1107 "urn:script:{name}:compiled answered for `{}`",
1108 prepared.name
1109 )));
1110 }
1111 let public = prepared.public && prepared.state == State::Published;
1112 let may_run =
1113 may(inv, Act::Run, name) || (public && may_public(inv, Act::Run, CAP_RUN_PUBLIC, name));
1114 if !may_run {
1115 return Err(Error::Denied(format!(
1116 "running urn:script:{name} needs `{}` or a namespace grant covering it{}. \
1117 {GRANT_FORMS}, and an exclusion (`…:-{{name}}`) takes one back out",
1118 cap_run(name),
1119 if prepared.public {
1120 format!(" (or `{CAP_RUN_PUBLIC}`, since it is public)")
1121 } else {
1122 " — it is not public".to_string()
1123 }
1124 )));
1125 }
1126 match prepared.state {
1127 State::Published => {}
1128 State::Draft => {
1129 return Err(Error::Conflict(format!(
1130 "urn:script:{name} is a draft; publish it (`state=published`) to run it, or \
1131 try the code at urn:script:eval"
1132 )))
1133 }
1134 State::Retired => {
1135 return Err(Error::Conflict(format!(
1136 "urn:script:{name} is retired; publish it again to run it"
1137 )))
1138 }
1139 }
1140 let ceiling = (shared.ceiling)(name);
1141 let keep = authority::effective(
1142 &prepared.requires,
1143 &prepared.granted,
1144 &prepared.exclusions,
1145 &ceiling,
1146 );
1147 Ok((prepared, keep, ceiling))
1148}
1149
1150fn lisp_request(program: &str, data: Option<&str>) -> Result<Request> {
1152 let mut request = Request::new(Verb::Source, iri(LISP_EVAL)?).with_arg("in", inline(program));
1153 if let Some(data) = data {
1154 request = request.with_arg("data", inline(data));
1155 }
1156 Ok(request)
1157}
1158
1159const DATA_SUMMARY: &str = "Optional data the script reads with `(input)` — data, never code.";
1161
1162#[derive(Clone, Copy, PartialEq, Eq)]
1164enum Door {
1165 Result,
1167 Runs,
1169}
1170
1171struct Planned {
1174 request: Request,
1175 keep: BTreeSet<String>,
1176 note: Option<String>,
1179}
1180
1181fn explain(note: Option<&str>, error: Error) -> Error {
1183 match (note, error) {
1184 (Some(note), Error::Denied(message)) => Error::Denied(format!("{message}. {note}")),
1185 (_, error) => error,
1186 }
1187}
1188
1189async fn prepare_request(
1191 inv: &Invocation<'_>,
1192 shared: &SpaceConfig,
1193 prepared: &Prepared,
1194 keep: BTreeSet<String>,
1195 ceiling: &Ceiling,
1196 through: Door,
1197) -> Result<Planned> {
1198 match prepared.language {
1199 Language::Lisp => {
1200 let data = optional(
1201 inv,
1202 match through {
1203 Door::Result => "data",
1204 Door::Runs => "content",
1205 },
1206 )?;
1207 Ok(Planned {
1208 request: lisp_request(&prepared.program, data)?,
1209 keep,
1210 note: None,
1211 })
1212 }
1213 Language::Sparql => plan_sparql(inv, shared, prepared, keep, ceiling, through).await,
1214 Language::Plan => plan_eval_request(inv, prepared, keep, ceiling, through),
1215 }
1216}
1217
1218fn plan_eval_request(
1225 inv: &Invocation<'_>,
1226 prepared: &Prepared,
1227 keep: BTreeSet<String>,
1228 ceiling: &Ceiling,
1229 through: Door,
1230) -> Result<Planned> {
1231 let name = &prepared.name;
1232 let analysis = prepared.plan.as_ref().ok_or_else(|| {
1233 Error::Endpoint(format!(
1234 "urn:script:{name}:compiled carries no plan analysis"
1235 ))
1236 })?;
1237 if through == Door::Result && analysis.mutates() {
1238 let writers: Vec<String> = analysis
1239 .steps
1240 .iter()
1241 .filter(|s| s.mutates())
1242 .map(|s| format!("<{}> ({} <{}>)", s.iri, s.verb, s.resolves))
1243 .collect();
1244 return Err(Error::InvalidArgument {
1245 name: "name".to_string(),
1246 detail: format!(
1247 "urn:script:{name} is a plan that writes ({}), so it is not a read: run it with \
1248 a Sink to urn:script:{name}:runs",
1249 writers.join(", ")
1250 ),
1251 });
1252 }
1253 let declared: Vec<(&str, bool)> = analysis
1254 .parameters
1255 .iter()
1256 .map(|p| (p.name.as_str(), p.must_be_given()))
1257 .collect();
1258 let values = given_parameters(inv, name, &declared, through)?;
1259 let face = optional(inv, "as")?;
1260 let keep = plan::expand_families(keep, inv.capability, ceiling);
1261 let markers = if inv.capability.is_root() {
1266 plan::markers(&keep)
1267 } else {
1268 Vec::new()
1269 };
1270 let note = (!markers.is_empty()).then(|| {
1271 format!(
1272 "urn:script:{name} ran holding {} only as a family: a family held is not a grant, \
1273 so a step whose module checks an exact token under it is refused. Neither the \
1274 runner nor the host's ceiling for this script names a member (a step's contract \
1275 declares the family, never the member, and root holds no list to pick one from). \
1276 Run it under a capability that holds the exact grants, or have the host's \
1277 ceiling for urn:script:{name} name them",
1278 markers
1279 .iter()
1280 .map(|m| format!("`{m}`"))
1281 .collect::<Vec<_>>()
1282 .join(", ")
1283 )
1284 });
1285 Ok(Planned {
1286 request: plan::eval_request(&prepared.program, &values, face),
1287 keep,
1288 note,
1289 })
1290}
1291
1292async fn plan_sparql(
1302 inv: &Invocation<'_>,
1303 shared: &SpaceConfig,
1304 prepared: &Prepared,
1305 mut keep: BTreeSet<String>,
1306 ceiling: &Ceiling,
1307 through: Door,
1308) -> Result<Planned> {
1309 let name = &prepared.name;
1310 let door = door(shared)?;
1311 let analysis = prepared.sparql.as_ref().ok_or_else(|| {
1312 Error::Endpoint(format!(
1313 "urn:script:{name}:compiled carries no SPARQL analysis"
1314 ))
1315 })?;
1316 if through == Door::Result && !analysis.form.is_read() {
1317 return Err(Error::InvalidArgument {
1318 name: "name".to_string(),
1319 detail: format!(
1320 "urn:script:{name} is a SPARQL UPDATE, which is a write: run it with a Sink to \
1321 urn:script:{name}:runs"
1322 ),
1323 });
1324 }
1325 let values = parameter_values(inv, name, &analysis.parameters, through)?;
1326 let face = sparql_face(inv, analysis.form)?;
1327 let bound = sparql::bind(&prepared.program, door, &values)?;
1328
1329 let runner = inv.capability;
1330 let root_marker = keep.remove(CAP_READ_GRAPH);
1333 let family = prepared.requires.contains(CAP_READ_GRAPH);
1334 let by_family = |scope: &str, keep: &BTreeSet<String>| {
1335 family
1336 && runner.allows(scope)
1337 && if root_marker {
1338 ceiling.allows(scope)
1339 } else {
1340 keep.contains(scope)
1341 }
1342 };
1343
1344 if bound.form == Form::Update {
1345 let graph = bound.graphs.iter().next().cloned().ok_or_else(|| {
1348 Error::Endpoint(format!("urn:script:{name} names no graph to update"))
1349 })?;
1350 for scope in &prepared.requires {
1351 may_use(name, &graph, scope, &keep, runner)?;
1352 }
1353 let request = Request::new(Verb::Sink, iri(&door.query_iri(Form::Update))?)
1354 .with_arg("content", inline(&bound.text))
1355 .with_arg("graph", inline(&graph));
1356 return Ok(Planned {
1357 request,
1358 keep,
1359 note: None,
1360 });
1361 }
1362
1363 let mut extra = BTreeSet::new();
1364 for graph in &bound.graphs {
1365 let scope = sparql::cap_read_graph(graph);
1366 if !keep.contains(&scope) && by_family(&scope, &keep) {
1367 extra.insert(scope);
1368 }
1369 }
1370 let mut dataset = bound.graphs.clone();
1371 if bound.any_graph {
1372 if !family {
1373 return Err(Error::Endpoint(format!(
1374 "urn:script:{name} reads a variable graph but its record derives no \
1375 `{CAP_READ_GRAPH}`; republish it"
1376 )));
1377 }
1378 let under = |scopes: &mut dyn Iterator<Item = &String>| -> BTreeSet<String> {
1379 scopes
1380 .filter_map(|s| s.strip_prefix(sparql::READ_GRAPH_PREFIX))
1381 .filter(|g| !g.starts_with('-') && !g.ends_with('*'))
1382 .map(str::to_string)
1383 .collect()
1384 };
1385 let candidates = if !root_marker {
1386 under(&mut keep.iter())
1387 } else if let Some(held) = runner.scopes() {
1388 under(&mut held.iter())
1389 } else {
1390 let listing = inv
1393 .issue_attenuated(
1394 Request::new(Verb::Source, iri(&door.graphs_iri())?),
1395 [sparql::CAP_STORE_READ.to_string()],
1396 )
1397 .await?;
1398 String::from_utf8_lossy(&listing.bytes)
1399 .lines()
1400 .map(str::trim)
1401 .filter(|l| !l.is_empty())
1402 .map(str::to_string)
1403 .collect()
1404 };
1405 for graph in candidates {
1406 let scope = sparql::cap_read_graph(&graph);
1407 if keep.contains(&scope) && runner.allows(&scope) || by_family(&scope, &keep) {
1408 extra.insert(scope);
1409 dataset.insert(graph);
1410 }
1411 }
1412 }
1413 if dataset.is_empty() {
1414 return Err(Error::Denied(format!(
1415 "urn:script:{name} reads the caller's readable graphs, and this run may read none: \
1416 it needs `{}` grants the publisher held, the host allows and the runner holds",
1417 sparql::READ_GRAPH_PREFIX
1418 )));
1419 }
1420 keep.extend(extra);
1421 for graph in &dataset {
1422 may_use(name, graph, &sparql::cap_read_graph(graph), &keep, runner)?;
1423 }
1424 let graphs = dataset.into_iter().collect::<Vec<_>>().join(" ");
1425 let request = Request::new(Verb::Source, iri(&door.query_iri(bound.form))?)
1426 .with_arg("query", inline(&bound.text))
1427 .with_arg("graph", inline(&graphs))
1428 .with_arg("as", inline(face));
1429 Ok(Planned {
1430 request,
1431 keep,
1432 note: None,
1433 })
1434}
1435
1436fn may_use(
1440 name: &str,
1441 graph: &str,
1442 scope: &str,
1443 keep: &BTreeSet<String>,
1444 runner: &ikigai_core::Capability,
1445) -> Result<()> {
1446 if !runner.allows(scope) {
1447 return Err(Error::Denied(format!(
1448 "running urn:script:{name} reads or writes <{graph}>, which needs `{scope}`, and the \
1449 runner does not hold it"
1450 )));
1451 }
1452 if !keep.contains(scope) {
1453 return Err(Error::Denied(format!(
1454 "running urn:script:{name} reads or writes <{graph}>, which needs `{scope}`, and \
1455 this script may not have it: its publisher did not hold it, or the host's ceiling \
1456 for it does not allow it"
1457 )));
1458 }
1459 Ok(())
1460}
1461
1462fn sparql_face(inv: &Invocation<'_>, form: Form) -> Result<&'static str> {
1465 let faces = form.faces();
1466 match optional(inv, "as")? {
1467 None => Ok(faces[0]),
1468 Some(asked) => {
1469 let bare = asked.split(';').next().unwrap_or(asked).trim();
1470 faces
1471 .iter()
1472 .find(|f| **f == bare)
1473 .copied()
1474 .ok_or_else(|| Error::InvalidArgument {
1475 name: "as".to_string(),
1476 detail: format!(
1477 "`{asked}` is not a face a SPARQL {} answers in; one of {}",
1478 form.as_str().to_uppercase(),
1479 faces.join(", ")
1480 ),
1481 })
1482 }
1483 }
1484}
1485
1486fn parameter_values(
1491 inv: &Invocation<'_>,
1492 name: &str,
1493 parameters: &[Parameter],
1494 through: Door,
1495) -> Result<BTreeMap<String, Value>> {
1496 let declared: Vec<(&str, bool)> = parameters
1497 .iter()
1498 .map(|p| (p.name.as_str(), p.required && p.default.is_none()))
1499 .collect();
1500 let mut given = given_parameters(inv, name, &declared, through)?;
1501 let mut values = BTreeMap::new();
1502 for parameter in parameters {
1503 if let Some(lexical) = given
1504 .remove(¶meter.name)
1505 .or_else(|| parameter.default.clone())
1506 {
1507 values.insert(parameter.name.clone(), parameter.value(&lexical)?);
1508 }
1509 }
1510 Ok(values)
1511}
1512
1513pub const TRANSPORT_ARGUMENTS: [&str; 4] = ["received", "client", "principal", "content-type"];
1539
1540fn given_parameters(
1555 inv: &Invocation<'_>,
1556 name: &str,
1557 declared: &[(&str, bool)],
1558 through: Door,
1559) -> Result<BTreeMap<String, String>> {
1560 let listed = || {
1561 if declared.is_empty() {
1562 "it declares none".to_string()
1563 } else {
1564 format!(
1565 "its parameters are {}",
1566 declared
1567 .iter()
1568 .map(|(p, _)| *p)
1569 .collect::<Vec<_>>()
1570 .join(", ")
1571 )
1572 }
1573 };
1574 for arg in inv.request.args.keys() {
1575 let known = arg == "as"
1576 || TRANSPORT_ARGUMENTS.contains(&arg.as_str())
1577 || (through == Door::Runs && arg == "content")
1578 || declared.iter().any(|(p, _)| p == arg);
1579 if !known {
1580 return Err(Error::InvalidArgument {
1581 name: arg.clone(),
1582 detail: format!("is not a parameter of urn:script:{name}; {}", listed()),
1583 });
1584 }
1585 }
1586 let mut piped: BTreeMap<String, String> = BTreeMap::new();
1587 if through == Door::Runs {
1588 if let Some(content) = optional(inv, "content")?.filter(|c| !c.trim().is_empty()) {
1590 let object: serde_json::Map<String, serde_json::Value> = serde_json::from_str(content)
1591 .map_err(|e| Error::InvalidArgument {
1592 name: "content".to_string(),
1593 detail: format!(
1594 "a script's piped content is its parameters as one JSON object ({e})"
1595 ),
1596 })?;
1597 for (key, value) in object {
1598 let lexical = match value {
1599 serde_json::Value::String(s) => s,
1600 serde_json::Value::Number(n) => n.to_string(),
1601 serde_json::Value::Bool(b) => b.to_string(),
1602 other => {
1603 return Err(Error::InvalidArgument {
1604 name: "content".to_string(),
1605 detail: format!(
1606 "`{key}` is {other}; a parameter's value is a string, a number \
1607 or a boolean"
1608 ),
1609 })
1610 }
1611 };
1612 piped.insert(key, lexical);
1613 }
1614 }
1615 }
1616 let mut given = BTreeMap::new();
1617 for (parameter, must) in declared {
1618 let named = optional(inv, parameter)?;
1619 let lexical = match (named, piped.remove(*parameter)) {
1620 (Some(_), Some(_)) => {
1621 return Err(Error::InvalidArgument {
1622 name: parameter.to_string(),
1623 detail: "is given both by name and in the piped content".to_string(),
1624 })
1625 }
1626 (Some(value), None) => Some(value.to_string()),
1627 (None, Some(value)) => Some(value),
1628 (None, None) => None,
1629 };
1630 match lexical {
1631 Some(lexical) => {
1632 given.insert(parameter.to_string(), lexical);
1633 }
1634 None if *must => return Err(Error::MissingArgument(parameter.to_string())),
1635 None => {}
1636 }
1637 }
1638 if let Some(stray) = piped.keys().next() {
1639 return Err(Error::InvalidArgument {
1640 name: "content".to_string(),
1641 detail: format!(
1642 "`{stray}` is not a parameter of urn:script:{name}; {}",
1643 listed()
1644 ),
1645 });
1646 }
1647 Ok(given)
1648}
1649
1650struct ResultEndpoint {
1651 shared: Arc<SpaceConfig>,
1652}
1653
1654#[async_trait]
1655impl Endpoint for ResultEndpoint {
1656 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1657 if inv.request.verb != Verb::Source {
1658 return Err(unsupported("script-result", inv.request.verb));
1659 }
1660 let name = name::from_bindings(inv)?;
1661 let (prepared, keep, ceiling) = prepare_run(inv, &self.shared, &name).await?;
1662 let planned =
1663 prepare_request(inv, &self.shared, &prepared, keep, &ceiling, Door::Result).await?;
1664 let Planned {
1667 request,
1668 keep,
1669 note,
1670 } = planned;
1671 let answer = inv
1672 .issue_attenuated(request, keep)
1673 .await
1674 .map_err(|e| explain(note.as_deref(), e))?;
1675 Ok(Representation::new(answer.repr_type, answer.bytes)
1681 .cacheable()
1682 .depends_on(name::authority_thread(&name)))
1683 }
1684
1685 fn name(&self) -> &str {
1686 "script-result"
1687 }
1688
1689 fn describe(&self) -> Description {
1690 Description::new("script-result")
1691 .title("A script's result")
1692 .summary(
1693 "Run the script as a READ and answer its value. It runs under the runner's \
1694 capability narrowed to what the script declares, its publisher held and \
1695 the host allows it — never more. Cacheable exactly as far as the script's \
1696 own sub-requests are (a Lisp program opts in with `(cacheable …)`), and \
1697 recomputed when the script is republished. No run record: a cached answer \
1698 runs nothing; record a run with `…:runs`. A published SPARQL script is \
1699 its own entry, with its parameters as arguments.",
1700 )
1701 .verb(Verb::Meta)
1702 .action(
1703 ActionSpec::new(Verb::Source)
1704 .summary("The script's answer.")
1705 .input(name_arg())
1706 .input(data_arg("data", DATA_SUMMARY))
1707 .output(PLAIN)
1708 .requires(CAP_RUN)
1709 .requires(CAP_LISP),
1710 )
1711 }
1712}
1713
1714struct RunsEndpoint {
1715 shared: Arc<SpaceConfig>,
1716}
1717
1718#[async_trait]
1719impl Endpoint for RunsEndpoint {
1720 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1721 if inv.request.verb != Verb::Sink {
1722 return Err(unsupported("script-runs", inv.request.verb));
1723 }
1724 let name = name::from_bindings(inv)?;
1725 let shared = &self.shared;
1726 let (prepared, keep, ceiling) = prepare_run(inv, shared, &name).await?;
1727 let planned = prepare_request(inv, shared, &prepared, keep, &ceiling, Door::Runs).await?;
1730 let runs_as = inv.capability.attenuate(planned.keep.iter().cloned());
1731 let mut run = Run {
1732 schema: SCHEMA,
1733 name: name.clone(),
1734 id: 0,
1735 version: prepared.version.clone(),
1736 principal: principal_of(inv.capability),
1737 capability: runs_as.scopes().cloned().unwrap_or_default(),
1738 started: inv.now().map(|t| t.as_millis()),
1739 ended: None,
1740 outcome: Outcome::Running,
1741 result: None,
1742 truncated: false,
1743 trace_span: inv.trace_span(),
1744 };
1745 run.id = shared.backend.start_run(&name, &run)?;
1746 let Planned {
1747 request,
1748 keep,
1749 note,
1750 } = planned;
1751 let answer = inv
1752 .issue_attenuated(request, keep)
1753 .await
1754 .map_err(|e| explain(note.as_deref(), e));
1755 run.ended = inv.now().map(|t| t.as_millis());
1756 match answer {
1757 Ok(repr) => {
1758 let (text, truncated) = model::truncate(
1759 String::from_utf8_lossy(&repr.bytes).into_owned(),
1760 MAX_RECORDED_RESULT,
1761 );
1762 run.outcome = Outcome::Ok;
1763 run.result = Some(text);
1764 run.truncated = truncated;
1765 shared.backend.finish_run(&name, &run)?;
1766 Ok(plain(format!("{}\n", run.iri())))
1767 }
1768 Err(error) => {
1769 run.outcome = Outcome::Failed {
1770 kind: kind(&error).to_string(),
1771 message: error.to_string(),
1772 };
1773 shared.backend.finish_run(&name, &run)?;
1774 Err(annotate(error, &run.iri()))
1775 }
1776 }
1777 }
1778
1779 fn name(&self) -> &str {
1780 "script-runs"
1781 }
1782
1783 fn describe(&self) -> Description {
1784 Description::new("script-runs")
1785 .title("A script's runs")
1786 .summary(
1787 "Run the script for its EFFECTS and record the run: who (the principal \
1788 the capability names), which version, under exactly what capability, when, and the \
1789 outcome. Answers the run's IRI; a run that fails is recorded too, and its \
1790 error names the record. A published SPARQL script is its own entry, with \
1791 its parameters as arguments.",
1792 )
1793 .verb(Verb::Meta)
1794 .action(
1795 ActionSpec::new(Verb::Sink)
1796 .summary("Run the script and record the run.")
1797 .input(name_arg())
1798 .input(data_arg("content", DATA_SUMMARY))
1799 .output(PLAIN)
1800 .requires(CAP_RUN)
1801 .requires(CAP_LISP),
1802 )
1803 }
1804}
1805
1806#[derive(Clone)]
1813struct PerScript {
1814 result: Arc<dyn Endpoint>,
1815 runs: Arc<dyn Endpoint>,
1816 reads: bool,
1818 listed: bool,
1823}
1824
1825struct Described {
1837 inner: Arc<dyn Endpoint>,
1838 id: String,
1839 description: Description,
1840}
1841
1842#[async_trait]
1843impl Endpoint for Described {
1844 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1845 self.inner.invoke(inv).await
1846 }
1847
1848 fn name(&self) -> &str {
1849 &self.id
1850 }
1851
1852 fn describe(&self) -> Description {
1853 self.description.clone()
1854 }
1855}
1856
1857pub struct ScriptSpace {
1860 inner: EndpointSpace,
1861 shared: Arc<SpaceConfig>,
1862}
1863
1864impl ScriptSpace {
1865 fn per_script(&self, name: &str) -> Option<PerScript> {
1870 let shared = &self.shared;
1871 if let Some(known) = shared
1872 .described
1873 .lock()
1874 .expect("the described-script memo")
1875 .get(name)
1876 {
1877 return known.clone();
1878 }
1879 let built = self.build(name);
1880 if built.is_some() || matches!(shared.backend.head(name), Ok(Some(_))) {
1883 shared
1884 .described
1885 .lock()
1886 .expect("the described-script memo")
1887 .insert(name.to_string(), built.clone());
1888 }
1889 built
1890 }
1891
1892 fn build(&self, name: &str) -> Option<PerScript> {
1893 let shared = &self.shared;
1894 let head = shared.backend.head(name).ok()??;
1895 if !ever_published(&head, &head.version) {
1902 return None;
1903 }
1904 let version = shared.backend.version(name, &head.version).ok()??;
1905 match version.language {
1906 Language::Lisp => None,
1907 Language::Sparql => self.build_sparql(name, &head, &version),
1908 Language::Plan => self.build_plan(name, &head, &version),
1909 }
1910 }
1911
1912 fn described(
1914 &self,
1915 inner: Arc<dyn Endpoint>,
1916 id: String,
1917 description: Description,
1918 ) -> Arc<dyn Endpoint> {
1919 Arc::new(Described {
1920 inner,
1921 id,
1922 description,
1923 }) as Arc<dyn Endpoint>
1924 }
1925
1926 fn result_endpoint(&self) -> Arc<dyn Endpoint> {
1927 Arc::new(ResultEndpoint {
1928 shared: Arc::clone(&self.shared),
1929 })
1930 }
1931
1932 fn runs_endpoint(&self) -> Arc<dyn Endpoint> {
1933 Arc::new(RunsEndpoint {
1934 shared: Arc::clone(&self.shared),
1935 })
1936 }
1937
1938 fn build_sparql(&self, name: &str, head: &Head, version: &Version) -> Option<PerScript> {
1939 let door = self.shared.sparql.as_ref()?;
1940 let analysis = sparql::analyze(&version.source, door).ok()?;
1941 let about = about(&version.source);
1942 let inputs: Vec<ArgSpec> = analysis
1943 .parameters
1944 .iter()
1945 .map(|p| {
1946 let mut arg = ArgSpec::new(p.name.clone())
1947 .summary(
1948 p.summary
1949 .clone()
1950 .unwrap_or_else(|| format!("The value of ?{} in the query.", p.name)),
1951 )
1952 .class(p.kind.iri().to_string());
1953 if let Some(default) = &p.default {
1954 arg = arg.default_value(default.clone()).optional();
1955 } else if !p.required {
1956 arg = arg.optional();
1957 }
1958 arg
1959 })
1960 .collect();
1961 let params = |spec: ActionSpec| contracted(spec, &inputs, name, head, version);
1962 let form = analysis.form;
1963 let faces = form.faces();
1964 let as_arg = || {
1965 ArgSpec::new("as")
1966 .summary(format!(
1967 "The face: one of {}. Any other is refused, never substituted.",
1968 faces.join(", ")
1969 ))
1970 .class(XSD_STRING)
1971 .one_of(faces.iter().copied())
1972 .default_value(faces[0])
1973 .optional()
1974 };
1975 let id = format!("script-{name}-result");
1976 let description = if form.is_read() {
1977 let mut spec = params(ActionSpec::new(Verb::Source))
1978 .summary(format!("Run the query {name} and answer its result."))
1979 .input(as_arg());
1980 for face in faces {
1981 spec = spec.output(*face);
1982 }
1983 Description::new(id.clone())
1984 .title(format!("{name}: a SPARQL {}", form.as_str().to_uppercase()))
1985 .summary(format!(
1986 "{about}Runs as a READ under the runner's capability narrowed to the \
1987 graphs the query names; cached as the store's answer is, and \
1988 recomputed after a write to the store or a republish."
1989 ))
1990 .verb(Verb::Meta)
1991 .action(spec)
1992 } else {
1993 Description::new(id.clone())
1996 .title(format!("{name}: a SPARQL UPDATE (run it at …:runs)"))
1997 .summary(format!(
1998 "{about}A write: run it with a Sink to urn:script:{name}:runs."
1999 ))
2000 .verb(Verb::Meta)
2001 };
2002 let result = self.described(self.result_endpoint(), id, description);
2003 let id = format!("script-{name}-runs");
2004 let mut spec = params(ActionSpec::new(Verb::Sink))
2005 .summary(format!(
2006 "Run {name} for its effects and record the run; answers the run's IRI."
2007 ))
2008 .input(piped_parameters_arg())
2009 .output(PLAIN);
2010 if form.is_read() {
2011 spec = spec.input(as_arg());
2012 }
2013 let runs = self.described(
2014 self.runs_endpoint(),
2015 id.clone(),
2016 Description::new(id)
2017 .title(format!("{name}: runs"))
2018 .summary(format!(
2019 "{about}Each run is recorded: who, which version, under exactly what \
2020 capability, when, and the outcome."
2021 ))
2022 .verb(Verb::Meta)
2023 .action(spec),
2024 );
2025 Some(PerScript {
2026 result,
2027 runs,
2028 reads: form.is_read(),
2029 listed: head.state == State::Published,
2030 })
2031 }
2032
2033 fn build_plan(&self, name: &str, head: &Head, version: &Version) -> Option<PerScript> {
2036 let analysis = plan::analyze(&version.source).ok()?;
2037 let about = about(&version.source);
2038 let inputs: Vec<ArgSpec> = analysis
2039 .parameters
2040 .iter()
2041 .map(|p| {
2042 let mut arg = ArgSpec::new(p.name.clone())
2043 .summary(
2044 p.summary
2045 .clone()
2046 .unwrap_or_else(|| format!("The plan's parameter `{}`.", p.name)),
2047 )
2048 .class(p.class.clone().unwrap_or_else(|| XSD_STRING.to_string()));
2049 if let Some(default) = &p.default {
2050 arg = arg.default_value(default.clone()).optional();
2051 } else if !p.required {
2052 arg = arg.optional();
2053 }
2054 arg
2055 })
2056 .collect();
2057 let params = |spec: ActionSpec| contracted(spec, &inputs, name, head, version);
2058 let as_arg = || {
2062 ArgSpec::new("as")
2063 .summary(
2064 "The face of the plan's result: when its result step served another \
2065 media type it is transrepted, and refused when nothing converts it.",
2066 )
2067 .class(XSD_STRING)
2068 .optional()
2069 };
2070 let reads = !analysis.mutates();
2071 let id = format!("script-{name}-result");
2072 let description = if reads {
2073 Description::new(id.clone())
2074 .title(format!("{name}: a plan"))
2075 .summary(format!(
2076 "{about}Runs as a READ (every step reads) under the runner's capability \
2077 narrowed to what the plan's steps require; as cacheable as its least \
2078 cacheable step, and recomputed after a republish."
2079 ))
2080 .verb(Verb::Meta)
2081 .action(
2082 params(ActionSpec::new(Verb::Source))
2083 .summary(format!("Run the plan {name} and answer its result."))
2084 .input(as_arg())
2085 .output(PLAIN),
2086 )
2087 } else {
2088 Description::new(id.clone())
2091 .title(format!("{name}: a plan that writes (run it at …:runs)"))
2092 .summary(format!(
2093 "{about}A write: run it with a Sink to urn:script:{name}:runs."
2094 ))
2095 .verb(Verb::Meta)
2096 };
2097 let result = self.described(self.result_endpoint(), id, description);
2098 let id = format!("script-{name}-runs");
2099 let runs = self.described(
2100 self.runs_endpoint(),
2101 id.clone(),
2102 Description::new(id)
2103 .title(format!("{name}: runs"))
2104 .summary(format!(
2105 "{about}Each run is recorded: who, which version, under exactly what \
2106 capability, when, and the outcome."
2107 ))
2108 .verb(Verb::Meta)
2109 .action(
2110 params(ActionSpec::new(Verb::Sink))
2111 .summary(format!(
2112 "Run the plan {name} and record the run; answers the run's IRI."
2113 ))
2114 .input(piped_parameters_arg())
2115 .input(as_arg())
2116 .output(PLAIN),
2117 ),
2118 );
2119 Some(PerScript {
2120 result,
2121 runs,
2122 reads,
2123 listed: head.state == State::Published,
2124 })
2125 }
2126}
2127
2128fn contracted(
2136 mut spec: ActionSpec,
2137 inputs: &[ArgSpec],
2138 name: &str,
2139 head: &Head,
2140 version: &Version,
2141) -> ActionSpec {
2142 for input in inputs {
2143 spec = spec.input(input.clone());
2144 }
2145 spec = spec.requires(if head.public {
2146 CAP_RUN.to_string()
2147 } else {
2148 authority::run_floor(name)
2149 });
2150 for scope in &version.requires {
2151 spec = spec.requires(scope.clone());
2152 }
2153 spec
2154}
2155
2156fn piped_parameters_arg() -> ArgSpec {
2158 ArgSpec::new("content")
2159 .summary(
2160 "The parameters as one JSON object, for a pipe (a parameter given both here and \
2161 by name is refused).",
2162 )
2163 .class(XSD_STRING)
2164 .optional()
2165}
2166
2167fn about(source: &str) -> String {
2169 let mut lines = Vec::new();
2170 for line in source.lines() {
2171 let trimmed = line.trim();
2172 let Some(body) = trimmed.strip_prefix('#') else {
2173 if trimmed.is_empty() {
2174 continue;
2175 }
2176 break;
2177 };
2178 let body = body.trim();
2179 if !body.is_empty() && !body.starts_with("@param") {
2180 lines.push(body);
2181 }
2182 }
2183 if lines.is_empty() {
2184 String::new()
2185 } else {
2186 format!("{} ", lines.join(" "))
2187 }
2188}
2189
2190impl Space for ScriptSpace {
2191 fn resolve(&self, request: &Request, scope: &Scope) -> Resolution {
2192 let resolution = self.inner.resolve(request, scope);
2193 let (name, part) = match &resolution {
2194 Resolution::Hit(hit) => {
2195 let part = match hit.endpoint.name() {
2196 "script-result" => Door::Result,
2197 "script-runs" => Door::Runs,
2198 _ => return resolution,
2199 };
2200 match hit.bindings.get("name") {
2201 Some(name) if name::validate(name).is_ok() => (name.to_string(), part),
2202 _ => return resolution,
2203 }
2204 }
2205 Resolution::Miss => return resolution,
2206 };
2207 match self.per_script(&name) {
2208 Some(per) => resolution.map_endpoint(|_| match part {
2209 Door::Result => per.result,
2210 Door::Runs => per.runs,
2211 }),
2212 None => resolution,
2213 }
2214 }
2215
2216 fn entries(&self) -> Option<Vec<SpaceEntry>> {
2217 let mut entries = self.inner.entries().unwrap_or_default();
2218 for name in self.shared.backend.names().unwrap_or_default() {
2219 let Some(per) = self.per_script(&name).filter(|per| per.listed) else {
2220 continue;
2221 };
2222 if per.reads {
2223 entries.push(SpaceEntry::new(
2224 name::part_iri(&name, "result"),
2225 per.result.name(),
2226 ));
2227 }
2228 entries.push(SpaceEntry::new(
2229 name::part_iri(&name, "runs"),
2230 per.runs.name(),
2231 ));
2232 }
2233 Some(entries)
2234 }
2235
2236 fn id(&self) -> Option<Iri> {
2237 self.inner.id()
2238 }
2239
2240 fn topology(&self) -> Topology {
2241 self.inner.topology()
2242 }
2243}
2244
2245fn kind(error: &Error) -> &'static str {
2247 match error {
2248 Error::Unresolved(_) => "unresolved",
2249 Error::MissingArgument(_) => "missing-argument",
2250 Error::InvalidArgument { .. } => "invalid-argument",
2251 Error::Endpoint(_) => "endpoint",
2252 Error::Denied(_) => "denied",
2253 Error::NotFound(_) => "not-found",
2254 Error::Conflict(_) => "conflict",
2255 Error::Timeout(_) => "timeout",
2256 Error::Unavailable(_) => "unavailable",
2257 _ => "other",
2258 }
2259}
2260
2261fn annotate(error: Error, run: &str) -> Error {
2263 let note = |message: String| format!("{message} (run recorded at {run})");
2264 match error {
2265 Error::Endpoint(m) => Error::Endpoint(note(m)),
2266 Error::Denied(m) => Error::Denied(note(m)),
2267 Error::NotFound(m) => Error::NotFound(note(m)),
2268 Error::Conflict(m) => Error::Conflict(note(m)),
2269 Error::Timeout(m) => Error::Timeout(note(m)),
2270 Error::Unavailable(m) => Error::Unavailable(note(m)),
2271 Error::InvalidArgument { name, detail } => Error::InvalidArgument {
2272 name,
2273 detail: note(detail),
2274 },
2275 other => other,
2276 }
2277}
2278
2279struct RunEndpoint {
2284 shared: Arc<SpaceConfig>,
2285}
2286
2287#[async_trait]
2288impl Endpoint for RunEndpoint {
2289 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
2290 let name = name::from_bindings(inv)?;
2291 inv.depends_on(name::part_iri(&name, "runs"));
2300 if !may(inv, Act::Read, &name) && !may(inv, Act::Run, &name) {
2303 return Err(Error::Denied(format!(
2304 "a run record of urn:script:{name} needs `{}` or `{}`. {GRANT_FORMS}",
2305 cap(Act::Read, &name),
2306 cap(Act::Run, &name)
2307 )));
2308 }
2309 let id_text = inv
2310 .bindings
2311 .get("id")
2312 .ok_or_else(|| Error::Endpoint("no `id` captured".to_string()))?;
2313 let id = id_text.parse::<u64>().map_err(|_| Error::InvalidArgument {
2314 name: "id".to_string(),
2315 detail: format!("`{id_text}` is not a run number"),
2316 });
2317 let run = match id {
2318 Ok(id) => self.shared.backend.run(&name, id)?,
2319 Err(_) if inv.request.verb == Verb::Exists => None,
2320 Err(e) => return Err(e),
2321 };
2322 match inv.request.verb {
2323 Verb::Exists => Ok(plain(if run.is_some() { "true\n" } else { "false\n" })),
2324 Verb::Source => {
2325 let want = face_of(inv, &RECORD_FACES)?;
2326 let run = run.ok_or_else(|| {
2327 Error::NotFound(format!("urn:script:{name} has no run {id_text}"))
2328 })?;
2329 let finished = run.outcome != Outcome::Running;
2330 let repr = match want {
2331 JSON => json(&run)?,
2332 TURTLE => {
2333 let mut graph = crate::graph::Graph::default();
2334 graph.record(&run);
2335 turtle(graph)?
2336 }
2337 _ => plain(run.render()),
2338 };
2339 Ok(if finished { repr.cacheable() } else { repr })
2341 }
2342 other => Err(unsupported("script-run", other)),
2343 }
2344 }
2345
2346 fn name(&self) -> &str {
2347 "script-run"
2348 }
2349
2350 fn describe(&self) -> Description {
2351 let id = || {
2352 ArgSpec::new("id")
2353 .summary("The run's number, unique per script.")
2354 .class(XSD_INTEGER)
2355 .binding()
2356 };
2357 Description::new("script-run")
2358 .title("One run of a script")
2359 .summary(
2360 "A recorded run: principal, version, capability, start and end, outcome, the \
2361 result (up to 64 KiB) and the host's trace span when it traced. Needs the \
2362 script's read or run grant.",
2363 )
2364 .verb(Verb::Meta)
2365 .action(
2366 ActionSpec::new(Verb::Source)
2367 .summary("The run record, as text, JSON, or a PROV-O graph (Turtle).")
2368 .input(name_arg())
2369 .input(id())
2370 .input(record_as_arg())
2371 .output(PLAIN)
2372 .output(JSON)
2373 .output(TURTLE)
2374 .requires(CAP_ANY),
2375 )
2376 .action(
2377 ActionSpec::new(Verb::Exists)
2378 .summary("Whether the run exists.")
2379 .input(name_arg())
2380 .input(id())
2381 .output(PLAIN)
2382 .requires(CAP_ANY),
2383 )
2384 }
2385}
2386
2387struct EvalEndpoint;
2392
2393#[async_trait]
2394impl Endpoint for EvalEndpoint {
2395 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
2396 if inv.request.verb != Verb::Sink {
2397 return Err(unsupported("script-eval", inv.request.verb));
2398 }
2399 let code = inv.inline_str("content")?;
2400 let language_text = optional(inv, "language")?;
2401 let language = language_text
2402 .map(Language::parse)
2403 .transpose()?
2404 .unwrap_or(Language::Lisp);
2405 if language == Language::Sparql {
2406 return Err(Error::InvalidArgument {
2407 name: "language".to_string(),
2408 detail: "SPARQL through urn:script:eval is the SPARQL Protocol face (ledger \
2409 #955), not in this version: publish the query as a script and read \
2410 its `…:result`"
2411 .to_string(),
2412 });
2413 }
2414 if language == Language::Plan {
2415 return Err(Error::InvalidArgument {
2418 name: "language".to_string(),
2419 detail: format!(
2420 "a plan supplied ad hoc runs at {} (`in` = the plan), under the caller's \
2421 own capability; to keep it, publish it as a script",
2422 plan::EVAL
2423 ),
2424 });
2425 }
2426 if let Some(save) = optional(inv, "save")? {
2427 let save = save.trim();
2428 name::validate(save)?;
2429 let mut request = Request::new(Verb::Sink, iri(&name::script_iri(save))?)
2433 .with_arg("content", inline(code))
2434 .with_arg("language", inline(language.as_str()))
2435 .with_arg("state", inline("draft"));
2436 if let Some(requires) = optional(inv, "requires")? {
2437 request = request.with_arg("requires", inline(requires));
2438 }
2439 inv.issue(request).await?;
2440 }
2441 let answer = inv
2444 .issue(lisp_request(code, optional(inv, "data")?)?)
2445 .await?;
2446 Ok(Representation::new(answer.repr_type, answer.bytes))
2447 }
2448
2449 fn name(&self) -> &str {
2450 "script-eval"
2451 }
2452
2453 fn describe(&self) -> Description {
2454 Description::new("script-eval")
2455 .title("Run code you supply")
2456 .summary(
2457 "Run typed or pasted code as an anonymous script under the caller's OWN \
2458 capability and nothing more; answers its value. `save=` keeps it as a draft \
2459 of that script (which needs that script's write grant).",
2460 )
2461 .verb(Verb::Meta)
2462 .action(
2463 ActionSpec::new(Verb::Sink)
2464 .summary("Run the code.")
2465 .input(
2466 ArgSpec::new("content")
2467 .summary("The code (piped).")
2468 .class(XSD_STRING),
2469 )
2470 .input(
2471 ArgSpec::new("language")
2472 .summary(
2473 "The code's language: `lisp`, the one this door runs, so the \
2474 one its `requires` (`urn:cap:lisp`) is for. A plan runs at \
2475 urn:plan:eval, which declares no language capability; SPARQL \
2476 here is the Protocol face, not in this version.",
2477 )
2478 .class(XSD_STRING)
2479 .one_of([Language::Lisp.as_str()])
2483 .default_value("lisp")
2484 .optional(),
2485 )
2486 .input(data_arg("data", DATA_SUMMARY))
2487 .input(
2488 ArgSpec::new("save")
2489 .summary(
2490 "A script name: also keep the code as a draft at \
2491 `urn:script:{save}`. Needs `urn:cap:script:write:{save}`.",
2492 )
2493 .class(XSD_STRING)
2494 .optional(),
2495 )
2496 .input(
2497 ArgSpec::new("requires")
2498 .summary("With `save=`: the draft's declared capability.")
2499 .class(XSD_STRING)
2500 .optional(),
2501 )
2502 .output(PLAIN)
2503 .requires(CAP_LISP),
2504 )
2505 }
2506}
2507
2508#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2514pub struct CatalogEntry {
2515 pub name: String,
2517 pub iri: String,
2519 pub state: Option<State>,
2521 pub public: Option<bool>,
2523 pub version: Option<String>,
2525 #[serde(rename = "lastRun")]
2527 pub last_run: Option<LastRun>,
2528 pub error: Option<String>,
2530}
2531
2532#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2534pub struct LastRun {
2535 pub iri: String,
2537 pub status: String,
2539 pub ended: Option<u64>,
2541}
2542
2543#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2545pub struct Catalog {
2546 pub schema: u32,
2548 pub scripts: Vec<CatalogEntry>,
2550}
2551
2552struct CatalogEndpoint {
2553 shared: Arc<SpaceConfig>,
2554}
2555
2556#[async_trait]
2557impl Endpoint for CatalogEndpoint {
2558 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
2559 if inv.request.verb != Verb::Source {
2560 return Err(unsupported("script-catalog", inv.request.verb));
2561 }
2562 let want = face_of(inv, &RECORD_FACES)?;
2563 let backend = &self.shared.backend;
2564 let mut scripts = Vec::new();
2565 let mut last_runs = Vec::new();
2567 for name in backend.names()? {
2568 let own = may(inv, Act::Read, &name);
2569 let mut entry = CatalogEntry {
2570 iri: name::script_iri(&name),
2571 name: name.clone(),
2572 state: None,
2573 public: None,
2574 version: None,
2575 last_run: None,
2576 error: None,
2577 };
2578 let mut last = None;
2579 match backend.head(&name) {
2580 Err(e) if own => entry.error = Some(e.to_string()),
2583 Ok(Some(head)) if sight(inv, &head, &head.version) == Sight::Hidden => continue,
2585 Ok(Some(head))
2586 if own
2587 || (is_public(&head)
2588 && may_public(inv, Act::Read, CAP_READ_PUBLIC, &name)) =>
2589 {
2590 entry.state = Some(head.state);
2591 entry.public = Some(head.public);
2592 entry.version = Some(head.version);
2593 last = backend.last_run(&name).ok().flatten();
2594 entry.last_run = last.as_ref().map(|run| LastRun {
2595 iri: run.iri(),
2596 status: match run.outcome {
2597 Outcome::Running => "running",
2598 Outcome::Ok => "ok",
2599 Outcome::Failed { .. } => "failed",
2600 }
2601 .to_string(),
2602 ended: run.ended,
2603 });
2604 }
2605 _ => continue,
2606 }
2607 last_runs.push(last);
2608 scripts.push(entry);
2609 }
2610 if want == TURTLE {
2611 let mut graph = crate::graph::Graph::default();
2612 for (entry, last) in scripts.iter().zip(&last_runs) {
2613 graph.script(&entry.name, entry.version.as_deref(), last.as_ref());
2614 }
2615 return turtle(graph);
2616 }
2617 if want == JSON {
2618 return json(&Catalog {
2621 schema: SCHEMA,
2622 scripts,
2623 });
2624 }
2625 let mut text = String::new();
2626 for entry in &scripts {
2627 match (&entry.error, entry.state) {
2628 (Some(error), _) => text.push_str(&format!("{} BROKEN: {error}\n", entry.iri)),
2629 (None, Some(state)) => {
2630 let version = entry.version.as_deref().unwrap_or("");
2631 text.push_str(&format!(
2632 "{} {}{} {}",
2633 entry.iri,
2634 state.as_str(),
2635 if entry.public == Some(true) {
2636 " public"
2637 } else {
2638 ""
2639 },
2640 &version[..version.len().min(19)],
2641 ));
2642 if let Some(run) = &entry.last_run {
2643 text.push_str(&format!(
2644 " last run {} {} {}",
2645 run.iri,
2646 run.status,
2647 model::when(run.ended)
2648 ));
2649 }
2650 text.push('\n');
2651 }
2652 _ => {}
2653 }
2654 }
2655 if scripts.is_empty() {
2656 text.push_str("no scripts this capability may read\n");
2657 }
2658 Ok(plain(text))
2659 }
2660
2661 fn name(&self) -> &str {
2662 "script-catalog"
2663 }
2664
2665 fn describe(&self) -> Description {
2666 Description::new("script-catalog")
2667 .title("Scripts")
2668 .summary(
2669 "Every script the caller may read: state, public flag, head version and last \
2670 run. Live (never cached). A host that wants `urn:host:scripts` aliases it \
2671 here.",
2672 )
2673 .verb(Verb::Meta)
2674 .action(
2675 ActionSpec::new(Verb::Source)
2676 .summary("The catalog, as text, JSON, or a graph (Turtle).")
2677 .input(record_as_arg())
2678 .output(PLAIN)
2679 .output(JSON)
2680 .output(TURTLE)
2681 .requires(CAP_READ),
2682 )
2683 }
2684}
2685
2686fn unsupported(id: &str, verb: Verb) -> Error {
2687 Error::Endpoint(format!("{id} does not answer {verb:?}"))
2688}