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, Contract>>>,
63}
64
65pub type ChangeHook = Arc<dyn Fn(&str) + Send + Sync>;
68
69impl SpaceConfig {
70 pub fn new(backend: Arc<dyn Backend>, ceiling: CeilingPolicy) -> SpaceConfig {
72 SpaceConfig {
73 backend,
74 ceiling,
75 sparql: None,
76 on_change: None,
77 described: Arc::new(Mutex::new(HashMap::new())),
78 }
79 }
80
81 pub fn sparql(mut self, door: SparqlDoor) -> SpaceConfig {
85 self.sparql = Some(door);
86 self
87 }
88
89 pub fn on_change(mut self, hook: ChangeHook) -> SpaceConfig {
100 self.on_change = Some(hook);
101 self
102 }
103
104 fn changed(&self, name: &str) {
106 self.described
107 .lock()
108 .expect("the described-script memo")
109 .remove(name);
110 if let Some(hook) = &self.on_change {
111 hook(name);
112 }
113 }
114}
115
116pub fn space(config: SpaceConfig) -> ScriptSpace {
123 let shared = Arc::new(config);
124 let template = |t: &str| UriTemplate::parse(t).expect("a constant template");
125 let inner = EndpointSpace::new()
126 .bind(Exact::new(EVAL_IRI), EvalEndpoint)
127 .bind(
128 Exact::new(CATALOG_IRI),
129 CatalogEndpoint {
130 shared: Arc::clone(&shared),
131 },
132 )
133 .bind(
134 template("urn:script:{name}:version:{digest}"),
135 VersionEndpoint {
136 shared: Arc::clone(&shared),
137 },
138 )
139 .bind(
140 template("urn:script:{name}:run:{id}"),
141 RunEndpoint {
142 shared: Arc::clone(&shared),
143 },
144 )
145 .bind(
146 template("urn:script:{name}:compiled"),
147 CompiledEndpoint {
148 shared: Arc::clone(&shared),
149 },
150 )
151 .bind(
152 template("urn:script:{name}:result"),
153 ResultEndpoint {
154 shared: Arc::clone(&shared),
155 },
156 )
157 .bind(
158 template("urn:script:{name}:runs"),
159 RunsEndpoint {
160 shared: Arc::clone(&shared),
161 },
162 )
163 .bind(
164 template("urn:script:{name}"),
165 ScriptEndpoint {
166 shared: Arc::clone(&shared),
167 },
168 );
169 ScriptSpace {
170 inner,
171 lisp: LispDoors::new(&shared),
172 shared,
173 }
174}
175
176fn holds(inv: &Invocation<'_>, scope: &str) -> bool {
181 inv.capability.allows(scope)
182}
183
184fn may(inv: &Invocation<'_>, act: Act, name: &str) -> bool {
187 authority::holds(inv.capability, act, name)
188}
189
190fn may_public(inv: &Invocation<'_>, act: Act, public: &str, name: &str) -> bool {
193 holds(inv, public) && !authority::excluded(inv.capability, act, name)
194}
195
196const GRANT_FORMS: &str = "A script grant names one script (`urn:cap:script:{act}:{name}`) or a \
198 namespace of them (`urn:cap:script:{act}:{namespace}-*`)";
199
200fn is_public(head: &Head) -> bool {
202 head.public && head.state == State::Published
203}
204
205fn readable_head(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Result<Option<Head>> {
211 gated_head(inv, shared, name, &[(Act::Read, CAP_READ_PUBLIC)])
212}
213
214fn gated_head(
217 inv: &Invocation<'_>,
218 shared: &SpaceConfig,
219 name: &str,
220 acts: &[(Act, &str)],
221) -> Result<Option<Head>> {
222 if acts.iter().any(|(act, _)| may(inv, *act, name)) {
223 return shared.backend.head(name);
224 }
225 if let Ok(Some(head)) = shared.backend.head(name) {
226 if is_public(&head)
227 && acts
228 .iter()
229 .any(|(act, public)| may_public(inv, *act, public, name))
230 {
231 return Ok(Some(head));
232 }
233 }
234 Err(Error::Denied(format!(
235 "this capability holds none of {} — nor, for a public script, {}. {GRANT_FORMS}, and \
236 an exclusion (`…:-{{name}}`) takes one back out",
237 acts.iter()
238 .map(|(act, _)| cap(*act, name))
239 .collect::<Vec<_>>()
240 .join(", "),
241 acts.iter()
242 .map(|(_, public)| *public)
243 .collect::<Vec<_>>()
244 .join(", ")
245 )))
246}
247
248fn require(inv: &Invocation<'_>, act: Act, name: &str, doing: &str) -> Result<()> {
249 if may(inv, act, name) {
250 Ok(())
251 } else {
252 Err(Error::Denied(format!(
253 "{doing} needs `{}`, which this capability does not hold. {GRANT_FORMS}, and an \
254 exclusion (`…:-{{name}}`) takes one back out",
255 cap(act, name)
256 )))
257 }
258}
259
260fn found(head: Option<Head>, name: &str) -> Result<Head> {
261 head.ok_or_else(|| Error::NotFound(format!("no script is published at urn:script:{name}")))
262}
263
264#[derive(Clone, Copy, Debug, PartialEq, Eq)]
284enum Sight {
285 Visible,
287 Hidden,
290}
291
292fn ever_published(head: &Head, digest: &str) -> bool {
295 (head.version == digest && head.state == State::Published)
296 || head
297 .history
298 .iter()
299 .any(|event| event.action == "publish" && event.version == digest)
300}
301
302fn sight(inv: &Invocation<'_>, head: &Head, digest: &str) -> Sight {
308 if ever_published(head, digest) || inv.capability.is_root() {
309 return Sight::Visible;
310 }
311 let wrote_it = |author: &str| authority::is_identity(author) && inv.capability.acts_as(author);
312 let mine = (head.version == digest && wrote_it(&head.publisher))
313 || head
314 .history
315 .iter()
316 .any(|e| e.action == "draft" && e.version == digest && wrote_it(&e.principal));
317 if mine {
318 Sight::Visible
319 } else {
320 Sight::Hidden
321 }
322}
323
324fn absent(inv: &Invocation<'_>, name: &str) -> Error {
328 let mut message = format!("no script is published at urn:script:{name}");
329 if inv.capability.principal().is_none() && !inv.capability.is_root() {
330 message.push_str(
331 " (this request's capability names no principal (`urn:cap:principal:…`, minted \
332 by the host's door), so it cannot be shown to be a draft's author: a draft is \
333 visible to its author and root alone until it is published)",
334 );
335 }
336 Error::NotFound(message)
337}
338
339fn seen_head(inv: &Invocation<'_>, name: &str, gated: Option<Head>) -> Result<Head> {
342 let Some(head) = gated else {
343 return Err(absent(inv, name));
344 };
345 match sight(inv, &head, &head.version) {
346 Sight::Hidden => Err(absent(inv, name)),
347 Sight::Visible => Ok(head),
348 }
349}
350
351fn head_version(shared: &SpaceConfig, head: &Head) -> Result<Version> {
352 shared
353 .backend
354 .version(&head.name, &head.version)?
355 .ok_or_else(|| {
356 Error::Endpoint(format!(
357 "urn:script:{} points at version {}, which its backend does not hold",
358 head.name, head.version
359 ))
360 })
361}
362
363fn optional<'a>(inv: &'a Invocation<'_>, name: &str) -> Result<Option<&'a str>> {
369 match inv.inline_str(name) {
370 Ok(value) => Ok(Some(value)),
371 Err(Error::MissingArgument(_)) => Ok(None),
372 Err(other) => Err(other),
373 }
374}
375
376fn parse_bool(name: &str, text: &str) -> Result<bool> {
377 match text.trim() {
378 "true" => Ok(true),
379 "false" => Ok(false),
380 other => Err(Error::InvalidArgument {
381 name: name.to_string(),
382 detail: format!("`{other}` is not `true` or `false`"),
383 }),
384 }
385}
386
387fn wanted_face(inv: &Invocation<'_>) -> Result<&'static str> {
390 face_of(inv, &READ_FACES)
391}
392
393fn face_of(inv: &Invocation<'_>, faces: &[&'static str]) -> Result<&'static str> {
395 match optional(inv, "as")? {
396 None => Ok(faces[0]),
397 Some(asked) => {
398 let bare = asked.split(';').next().unwrap_or(asked).trim();
399 faces
400 .iter()
401 .find(|face| **face == bare)
402 .copied()
403 .ok_or_else(|| Error::InvalidArgument {
404 name: "as".to_string(),
405 detail: format!(
406 "`{asked}` is not a face this resource serves; one of {}",
407 faces.join(", ")
408 ),
409 })
410 }
411 }
412}
413
414fn plain(text: impl Into<String>) -> Representation {
415 Representation::new(
416 ReprType::new(PLAIN).with_param("charset", "utf-8"),
417 text.into().into_bytes(),
418 )
419}
420
421fn json<T: Serialize>(value: &T) -> Result<Representation> {
422 let mut bytes = serde_json::to_vec_pretty(value)
423 .map_err(|e| Error::Endpoint(format!("could not serialize a record: {e}")))?;
424 bytes.push(b'\n');
425 Ok(Representation::new(ReprType::new(JSON), bytes))
426}
427
428fn iri(text: &str) -> Result<Iri> {
429 Iri::parse(text).map_err(|e| Error::Endpoint(format!("`{text}` is not an IRI: {e}")))
430}
431
432fn inline(text: &str) -> ArgRef {
433 ArgRef::Inline(text.as_bytes().to_vec())
434}
435
436fn name_arg() -> ArgSpec {
437 ArgSpec::new("name")
438 .summary(
439 "The script's name: one segment of lowercase letters, digits, `-` and `_` \
440 (`eval`, `catalog`, `public`, `outcome` and `principal` are reserved).",
441 )
442 .class(XSD_STRING)
443 .binding()
444}
445
446fn as_arg() -> ArgSpec {
447 ArgSpec::new("as")
448 .summary(
449 "The face: `text/plain` (for people) or `application/json` (the versioned \
450 record, `\"schema\": 1`). Any other is refused, never substituted.",
451 )
452 .class(XSD_STRING)
453 .one_of(READ_FACES)
454 .default_value(PLAIN)
455 .optional()
456}
457
458fn record_as_arg() -> ArgSpec {
460 ArgSpec::new("as")
461 .summary(
462 "The face: `text/plain` (for people), `application/json` (the versioned record, \
463 `\"schema\": 1`) or `text/turtle` (the graph: PROV-O and the shared \
464 vocabulary, no blank nodes). Any other is refused, never substituted.",
465 )
466 .class(XSD_STRING)
467 .one_of(RECORD_FACES)
468 .default_value(PLAIN)
469 .optional()
470}
471
472fn turtle(graph: crate::graph::Graph) -> Result<Representation> {
473 let bytes = graph
474 .turtle()
475 .map_err(|e| Error::Endpoint(format!("could not write the graph face: {e}")))?;
476 Ok(Representation::new(
477 ReprType::new(TURTLE).with_param("charset", "utf-8"),
478 bytes,
479 ))
480}
481
482fn data_arg(arg: &str, summary: &str) -> ArgSpec {
483 ArgSpec::new(arg)
484 .summary(summary)
485 .class(XSD_STRING)
486 .optional()
487}
488
489#[derive(Serialize)]
495struct ScriptDocument<'a> {
496 iri: String,
497 #[serde(flatten)]
498 head: &'a Head,
499 #[serde(rename = "versionIri")]
500 version_iri: String,
501 language: Language,
502 requires: &'a BTreeSet<String>,
503 source: &'a str,
504}
505
506struct ScriptEndpoint {
507 shared: Arc<SpaceConfig>,
508}
509
510#[async_trait]
511impl Endpoint for ScriptEndpoint {
512 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
513 let name = name::from_bindings(inv)?;
514 let shared = &self.shared;
515 match inv.request.verb {
516 Verb::Exists => {
517 let exists = match readable_head(inv, shared, &name)? {
518 None => false,
519 Some(head) => sight(inv, &head, &head.version) == Sight::Visible,
520 };
521 Ok(plain(if exists { "true\n" } else { "false\n" }).cacheable())
522 }
523 Verb::Source => {
524 let want = wanted_face(inv)?;
525 let head = seen_head(inv, &name, readable_head(inv, shared, &name)?)?;
526 let version = head_version(shared, &head)?;
527 let repr = if want == JSON {
528 json(&ScriptDocument {
529 iri: name::script_iri(&name),
530 head: &head,
531 version_iri: name::version_iri(&name, &head.version),
532 language: version.language,
533 requires: &version.requires,
534 source: &version.source,
535 })?
536 } else {
537 plain(version.source)
538 };
539 Ok(repr.cacheable())
540 }
541 Verb::Sink => publish(inv, shared, &name).await,
542 Verb::Delete => retire(inv, shared, &name),
543 other => Err(unsupported("script", other)),
544 }
545 }
546
547 fn name(&self) -> &str {
548 "script"
549 }
550
551 fn describe(&self) -> Description {
552 Description::new("script")
553 .title("A script")
554 .summary(
555 "A script as a resource: fetch its source without running it, publish or \
556 replace it (every write is a new content-addressed version), or retire it.",
557 )
558 .verb(Verb::Meta)
559 .action(
560 ActionSpec::new(Verb::Source)
561 .summary(
562 "The script, WITHOUT running it: its source (`text/plain`), or its head \
563 record (`application/json`): state, public flag, version, what it \
564 declares and what its publisher held. Needs \
565 `urn:cap:script:read:{name}`, or `urn:cap:script:read:public` for a \
566 public published script.",
567 )
568 .input(name_arg())
569 .input(as_arg())
570 .output(PLAIN)
571 .output(JSON)
572 .requires(CAP_READ),
573 )
574 .action(
575 ActionSpec::new(Verb::Exists)
576 .summary("Whether the script exists (to a caller who may read it).")
577 .input(name_arg())
578 .output(PLAIN)
579 .requires(CAP_READ),
580 )
581 .action(
582 ActionSpec::new(Verb::Sink)
583 .summary(
584 "Publish or replace the script; answers the new version's IRI. The \
585 declared `requires` (plus the language's own capability) must all \
586 be held by the publisher, or the publish is refused: a script never \
587 runs with more than its publisher held. Needs \
588 `urn:cap:script:write:{name}`.",
589 )
590 .input(name_arg())
591 .input(
592 ArgSpec::new("content")
593 .summary("The source text.")
594 .class(XSD_STRING),
595 )
596 .input(
597 ArgSpec::new("language")
598 .summary(
599 "The script's language: `lisp`, `sparql` (a query or update, \
600 when the host mounts a SPARQL door), or `plan` (an \
601 ik:Process graph, when the host binds the plan doors).",
602 )
603 .class(XSD_STRING)
604 .one_of(Language::ALL.map(Language::as_str))
605 .default_value("lisp")
606 .optional(),
607 )
608 .input(
609 ArgSpec::new("requires")
610 .summary(
611 "Whitespace-separated `urn:cap:` grants the script needs to \
612 run. Lisp: the language's capability is added; exact grants \
613 only, no wildcards, no exclusions. SPARQL: DERIVED from the \
614 graphs the text names; a plan: DERIVED from its steps' \
615 contracts. For both, omit it: one that says anything else \
616 is refused, naming the derived set.",
617 )
618 .class(XSD_STRING)
619 .optional(),
620 )
621 .input(
622 ArgSpec::new("public")
623 .summary(
624 "Whether holders of `urn:cap:script:run:public` (a host's \
625 anonymous principal) may run it and holders of \
626 `urn:cap:script:read:public` read it.",
627 )
628 .class(XSD_BOOLEAN)
629 .one_of(["true", "false"])
630 .default_value("false")
631 .optional(),
632 )
633 .input(
634 ArgSpec::new("state")
635 .summary(
636 "`published` (runnable) or `draft` (saved, not runnable, \
637 and visible only to its author until published). To \
638 retire, Delete.",
639 )
640 .class(XSD_STRING)
641 .one_of(["published", "draft"])
642 .default_value("published")
643 .optional(),
644 )
645 .input(
646 ArgSpec::new("if-version")
647 .summary(
648 "Write only if the head is still this version (`none`: only \
649 if the script does not exist yet); otherwise `Conflict`.",
650 )
651 .class(XSD_STRING)
652 .optional(),
653 )
654 .output(PLAIN)
655 .requires(CAP_WRITE),
656 )
657 .action(
658 ActionSpec::new(Verb::Delete)
659 .summary(
660 "Retire the script: it stays readable and its versions fetchable, but \
661 it never runs again until republished. Needs \
662 `urn:cap:script:delete:{name}`.",
663 )
664 .input(name_arg())
665 .output(PLAIN)
666 .requires(CAP_DELETE),
667 )
668 }
669}
670
671async fn publish(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Result<Representation> {
672 require(inv, Act::Write, name, "publishing a script")?;
673 let source = inv.inline_str("content")?;
674 let language = optional(inv, "language")?
675 .map(Language::parse)
676 .transpose()?
677 .unwrap_or(Language::Lisp);
678 let requires = match language {
679 Language::Lisp => {
680 let mut requires = optional(inv, "requires")?
681 .map(authority::parse_requires)
682 .transpose()?
683 .unwrap_or_default();
684 requires.insert(CAP_LISP.to_string());
685 requires
686 }
687 Language::Sparql => derived_requires(inv, shared, source)?,
688 Language::Plan => {
689 plan::check_bound(source)?;
693 plan::validate(inv, source).await?;
694 plan::analyze(source)?;
695 plan::derive(inv, source, optional(inv, "requires")?).await?
696 }
697 };
698 let public = optional(inv, "public")?
699 .map(|v| parse_bool("public", v))
700 .transpose()?
701 .unwrap_or(false);
702 let state = match optional(inv, "state")?.map(str::trim) {
703 None | Some("published") => State::Published,
704 Some("draft") => State::Draft,
705 Some(other) => {
706 return Err(Error::InvalidArgument {
707 name: "state".to_string(),
708 detail: format!(
709 "`{other}` is not `published` or `draft`; to retire a script, Delete it"
710 ),
711 })
712 }
713 };
714 let grant = authority::grant_at_publish(inv.capability, &requires)?;
717
718 let version = Version {
719 language,
720 requires,
721 source: source.to_string(),
722 };
723 let digest = version.digest();
724 let current = shared.backend.head(name)?;
725 if let Some(expected) = optional(inv, "if-version")? {
726 let at = current.as_ref().map_or("none", |h| h.version.as_str());
727 if at != expected.trim() {
728 return Err(Error::Conflict(format!(
729 "urn:script:{name} is at {at}, not {}; read it again and retry",
730 expected.trim()
731 )));
732 }
733 }
734 shared.backend.put_version(name, &version)?;
735
736 let principal = principal_of(inv.capability);
737 let at = inv.now().map(|t| t.as_millis());
738 let mut history = current
739 .as_ref()
740 .map(|h| h.history.clone())
741 .unwrap_or_default();
742 history.push(Event {
743 action: if state == State::Draft {
744 "draft"
745 } else {
746 "publish"
747 }
748 .to_string(),
749 version: digest.clone(),
750 state,
751 public,
752 principal: principal.clone(),
753 at,
754 });
755 let head = Head {
756 schema: SCHEMA,
757 name: name.to_string(),
758 version: digest.clone(),
759 state,
760 public,
761 granted: grant.granted,
762 exclusions: grant.exclusions,
763 publisher: principal,
764 updated: at,
765 history,
766 };
767 shared.backend.swap_head(name, current.as_ref(), &head)?;
768 shared.changed(name);
769 Ok(plain(format!("{}\n", name::version_iri(name, &digest))))
770}
771
772fn derived_requires(
776 inv: &Invocation<'_>,
777 shared: &SpaceConfig,
778 source: &str,
779) -> Result<BTreeSet<String>> {
780 let door = door(shared).map_err(|_| Error::InvalidArgument {
781 name: "language".to_string(),
782 detail: "this host takes no SPARQL scripts: it mounts no SPARQL door".to_string(),
783 })?;
784 let derived = sparql::analyze(source, door)?.requires();
785 if let Some(said) = optional(inv, "requires")? {
786 let said: BTreeSet<String> = said.split_whitespace().map(str::to_string).collect();
787 if said != derived {
788 return Err(Error::InvalidArgument {
789 name: "requires".to_string(),
790 detail: format!(
791 "a SPARQL script's authority is derived from the graphs its text names, \
792 not declared, and this one derives {}; the `requires` given says {}. \
793 Omit `requires`",
794 listed(&derived),
795 listed(&said)
796 ),
797 });
798 }
799 }
800 Ok(derived)
801}
802
803fn listed(scopes: &BTreeSet<String>) -> String {
804 if scopes.is_empty() {
805 "nothing".to_string()
806 } else {
807 scopes.iter().cloned().collect::<Vec<_>>().join(" ")
808 }
809}
810
811fn door(shared: &SpaceConfig) -> Result<&SparqlDoor> {
812 shared.sparql.as_ref().ok_or_else(|| {
813 Error::Endpoint(
814 "this host mounts no SPARQL door (`SpaceConfig::sparql`), so it cannot run a \
815 SPARQL script"
816 .to_string(),
817 )
818 })
819}
820
821fn retire(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Result<Representation> {
822 require(inv, Act::Delete, name, "retiring a script")?;
823 let current = found(shared.backend.head(name)?, name)?;
824 let iri = name::script_iri(name);
825 if current.state == State::Retired {
826 return Ok(plain(format!("{iri} was already retired\n")));
827 }
828 let at = inv.now().map(|t| t.as_millis());
829 let mut head = current.clone();
830 head.state = State::Retired;
831 head.updated = at;
832 head.history.push(Event {
833 action: "retire".to_string(),
834 version: current.version.clone(),
835 state: State::Retired,
836 public: current.public,
837 principal: principal_of(inv.capability),
838 at,
839 });
840 shared.backend.swap_head(name, Some(¤t), &head)?;
841 shared.changed(name);
842 Ok(plain(format!("retired {iri}\n")))
843}
844
845struct VersionEndpoint {
850 shared: Arc<SpaceConfig>,
851}
852
853#[async_trait]
854impl Endpoint for VersionEndpoint {
855 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
856 let name = name::from_bindings(inv)?;
857 let digest = inv
858 .bindings
859 .get("digest")
860 .ok_or_else(|| Error::Endpoint("no `digest` captured".to_string()))?
861 .to_string();
862 inv.depends_on(name::script_iri(&name));
876 let head = readable_head(inv, &self.shared, &name)?;
877 let version = self.shared.backend.version(&name, &digest);
878 let seen = match (&version, &head) {
882 (Ok(Some(_)), Some(head)) => sight(inv, head, &digest),
883 (Ok(Some(_)), None) if !inv.capability.is_root() => Sight::Hidden,
884 _ => Sight::Visible,
885 };
886 let no_such = || Error::NotFound(format!("urn:script:{name} has no version {digest}"));
887 match inv.request.verb {
888 Verb::Exists => {
889 let exists = match version {
890 Ok(v) => v.is_some() && seen != Sight::Hidden,
891 Err(Error::InvalidArgument { .. }) => false,
892 Err(other) => return Err(other),
893 };
894 Ok(plain(if exists { "true\n" } else { "false\n" }).cacheable())
895 }
896 Verb::Source => {
897 let want = wanted_face(inv)?;
898 let version = version?.ok_or_else(no_such)?;
899 if seen == Sight::Hidden {
900 return Err(no_such());
901 }
902 let repr = if want == JSON {
903 json(&version.document(&name))?
904 } else {
905 plain(version.source)
906 };
907 Ok(repr.cacheable())
908 }
909 other => Err(unsupported("script-version", other)),
910 }
911 }
912
913 fn name(&self) -> &str {
914 "script-version"
915 }
916
917 fn describe(&self) -> Description {
918 let digest = || {
919 ArgSpec::new("digest")
920 .summary("The version's digest: `sha256:` and 64 hex digits over its content.")
921 .class(XSD_STRING)
922 .binding()
923 };
924 Description::new("script-version")
925 .title("One version of a script")
926 .summary(
927 "An immutable version, named by the sha256 of its content (language, declared \
928 capability, source). Republishing moves the head; every version stays \
929 fetchable here.",
930 )
931 .verb(Verb::Meta)
932 .action(
933 ActionSpec::new(Verb::Source)
934 .summary(
935 "The version's source (`text/plain`) or its record (`application/json`).",
936 )
937 .input(name_arg())
938 .input(digest())
939 .input(as_arg())
940 .output(PLAIN)
941 .output(JSON)
942 .requires(CAP_READ),
943 )
944 .action(
945 ActionSpec::new(Verb::Exists)
946 .summary("Whether the script has this version.")
947 .input(name_arg())
948 .input(digest())
949 .output(PLAIN)
950 .requires(CAP_READ),
951 )
952 }
953}
954
955#[derive(Clone, Debug, Serialize, Deserialize)]
965pub struct Prepared {
966 pub schema: u32,
968 pub name: String,
970 pub iri: String,
972 pub version: String,
974 pub language: Language,
976 pub evaluator: String,
978 pub program: String,
980 pub requires: BTreeSet<String>,
982 pub granted: BTreeSet<String>,
984 pub exclusions: BTreeSet<String>,
986 pub public: bool,
988 pub state: State,
990 #[serde(default, skip_serializing_if = "Option::is_none")]
992 pub sparql: Option<Analysis>,
993 #[serde(default, skip_serializing_if = "Option::is_none")]
995 pub plan: Option<plan::Analysis>,
996}
997
998struct CompiledEndpoint {
999 shared: Arc<SpaceConfig>,
1000}
1001
1002#[async_trait]
1003impl Endpoint for CompiledEndpoint {
1004 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1005 if inv.request.verb != Verb::Source {
1006 return Err(unsupported("script-compiled", inv.request.verb));
1007 }
1008 let name = name::from_bindings(inv)?;
1009 inv.depends_on(name::script_iri(&name));
1017 let gated = gated_head(
1020 inv,
1021 &self.shared,
1022 &name,
1023 &[(Act::Read, CAP_READ_PUBLIC), (Act::Run, CAP_RUN_PUBLIC)],
1024 )?;
1025 let head = seen_head(inv, &name, gated)?;
1026 let version = head_version(&self.shared, &head)?;
1027 let (evaluator, analysis, plan_analysis) = match version.language {
1028 Language::Lisp => (LISP_EVAL.to_string(), None, None),
1029 Language::Sparql => {
1030 let door = door(&self.shared)?;
1031 let analysis = sparql::analyze(&version.source, door)?;
1032 (door.query_iri(analysis.form), Some(analysis), None)
1033 }
1034 Language::Plan => (
1035 plan::EVAL.to_string(),
1036 None,
1037 Some(plan::analyze(&version.source)?),
1038 ),
1039 };
1040 let prepared = Prepared {
1041 schema: SCHEMA,
1042 iri: name::part_iri(&name, "compiled"),
1043 version: head.version.clone(),
1044 language: version.language,
1045 evaluator,
1046 program: version.source,
1047 requires: version.requires,
1048 granted: head.granted,
1049 exclusions: head.exclusions,
1050 public: head.public,
1051 state: head.state,
1052 sparql: analysis,
1053 plan: plan_analysis,
1054 name: name.clone(),
1055 };
1056 Ok(json(&prepared)?.cacheable())
1057 }
1058
1059 fn name(&self) -> &str {
1060 "script-compiled"
1061 }
1062
1063 fn describe(&self) -> Description {
1064 Description::new("script-compiled")
1065 .title("A script's compiled form")
1066 .summary(
1067 "The head version prepared for its evaluator, with the authority it runs \
1068 under (and, for SPARQL, its form, parameters and graphs; for a plan, its \
1069 steps' verbs and its parameters): cached, and cut \
1070 whenever the script is republished or retired. Readable by a holder of the \
1071 script's read OR run grant (a run reads its program here).",
1072 )
1073 .verb(Verb::Meta)
1074 .action(
1075 ActionSpec::new(Verb::Source)
1076 .summary("The prepared form, as JSON.")
1077 .input(name_arg())
1078 .output(JSON)
1079 .requires(CAP_ANY),
1080 )
1081 }
1082}
1083
1084const LISP_EVAL: &str = "urn:lisp:eval";
1089
1090async fn prepare_run(
1093 inv: &Invocation<'_>,
1094 shared: &SpaceConfig,
1095 name: &str,
1096) -> Result<(Prepared, BTreeSet<String>, Ceiling)> {
1097 let compiled = inv
1100 .issue(Request::new(
1101 Verb::Source,
1102 iri(&name::part_iri(name, "compiled"))?,
1103 ))
1104 .await?;
1105 let prepared: Prepared = serde_json::from_slice(&compiled.bytes).map_err(|e| {
1106 Error::Endpoint(format!(
1107 "urn:script:{name}:compiled did not answer a prepared form: {e}"
1108 ))
1109 })?;
1110 if prepared.name != name {
1111 return Err(Error::Endpoint(format!(
1112 "urn:script:{name}:compiled answered for `{}`",
1113 prepared.name
1114 )));
1115 }
1116 let public = prepared.public && prepared.state == State::Published;
1117 let may_run =
1118 may(inv, Act::Run, name) || (public && may_public(inv, Act::Run, CAP_RUN_PUBLIC, name));
1119 let run_grant = || {
1120 format!(
1121 "`{}` or a namespace grant covering it{}",
1122 cap_run(name),
1123 if prepared.public {
1124 format!(" (or `{CAP_RUN_PUBLIC}`, since it is public)")
1125 } else {
1126 " — it is not public".to_string()
1127 }
1128 )
1129 };
1130 let also = || {
1135 if may_run {
1136 String::new()
1137 } else {
1138 format!("; running it will also need {}", run_grant())
1139 }
1140 };
1141 match prepared.state {
1142 State::Published => {}
1143 State::Draft => {
1144 return Err(Error::Conflict(format!(
1145 "urn:script:{name} is a draft; publish it (`state=published`) to run it{}{}",
1146 ad_hoc(prepared.language),
1147 also()
1148 )))
1149 }
1150 State::Retired => {
1151 return Err(Error::Conflict(format!(
1152 "urn:script:{name} is retired; publish it again to run it{}",
1153 also()
1154 )))
1155 }
1156 }
1157 if !may_run {
1158 return Err(Error::Denied(format!(
1159 "running urn:script:{name} needs {}. {GRANT_FORMS}, and an exclusion \
1160 (`…:-{{name}}`) takes one back out",
1161 run_grant()
1162 )));
1163 }
1164 let ceiling = (shared.ceiling)(name);
1165 let keep = authority::effective(
1166 &prepared.requires,
1167 &prepared.granted,
1168 &prepared.exclusions,
1169 &ceiling,
1170 );
1171 Ok((prepared, keep, ceiling))
1172}
1173
1174fn ad_hoc(language: Language) -> String {
1177 match language {
1178 Language::Lisp => format!(", or try the code at {EVAL_IRI}"),
1179 Language::Plan => format!(
1180 ", or run the plan itself at {} (`in` = the plan), under your own capability",
1181 plan::EVAL
1182 ),
1183 Language::Sparql => String::new(),
1184 }
1185}
1186
1187fn lisp_request(program: &str, data: Option<&str>) -> Result<Request> {
1189 let mut request = Request::new(Verb::Source, iri(LISP_EVAL)?).with_arg("in", inline(program));
1190 if let Some(data) = data {
1191 request = request.with_arg("data", inline(data));
1192 }
1193 Ok(request)
1194}
1195
1196const DATA_SUMMARY: &str = "Optional data the script reads with `(input)` — data, never code.";
1198
1199#[derive(Clone, Copy, PartialEq, Eq)]
1201enum Door {
1202 Result,
1204 Runs,
1206}
1207
1208struct Planned {
1211 request: Request,
1212 keep: BTreeSet<String>,
1213 note: Option<String>,
1216}
1217
1218fn explain(note: Option<&str>, error: Error) -> Error {
1220 match (note, error) {
1221 (Some(note), Error::Denied(message)) => Error::Denied(format!("{message}. {note}")),
1222 (_, error) => error,
1223 }
1224}
1225
1226async fn prepare_request(
1228 inv: &Invocation<'_>,
1229 shared: &SpaceConfig,
1230 prepared: &Prepared,
1231 keep: BTreeSet<String>,
1232 ceiling: &Ceiling,
1233 through: Door,
1234) -> Result<Planned> {
1235 match prepared.language {
1236 Language::Lisp => {
1237 let data = optional(
1238 inv,
1239 match through {
1240 Door::Result => "data",
1241 Door::Runs => "content",
1242 },
1243 )?;
1244 Ok(Planned {
1245 request: lisp_request(&prepared.program, data)?,
1246 keep,
1247 note: None,
1248 })
1249 }
1250 Language::Sparql => plan_sparql(inv, shared, prepared, keep, ceiling, through).await,
1251 Language::Plan => plan_eval_request(inv, prepared, keep, ceiling, through),
1252 }
1253}
1254
1255fn plan_eval_request(
1262 inv: &Invocation<'_>,
1263 prepared: &Prepared,
1264 keep: BTreeSet<String>,
1265 ceiling: &Ceiling,
1266 through: Door,
1267) -> Result<Planned> {
1268 let name = &prepared.name;
1269 let analysis = prepared.plan.as_ref().ok_or_else(|| {
1270 Error::Endpoint(format!(
1271 "urn:script:{name}:compiled carries no plan analysis"
1272 ))
1273 })?;
1274 if through == Door::Result && analysis.mutates() {
1275 let writers: Vec<String> = analysis
1276 .steps
1277 .iter()
1278 .filter(|s| s.mutates())
1279 .map(|s| format!("<{}> ({} <{}>)", s.iri, s.verb, s.resolves))
1280 .collect();
1281 return Err(Error::InvalidArgument {
1282 name: "name".to_string(),
1283 detail: format!(
1284 "urn:script:{name} is a plan that writes ({}), so it is not a read: run it with \
1285 a Sink to urn:script:{name}:runs",
1286 writers.join(", ")
1287 ),
1288 });
1289 }
1290 let declared: Vec<(&str, bool)> = analysis
1291 .parameters
1292 .iter()
1293 .map(|p| (p.name.as_str(), p.must_be_given()))
1294 .collect();
1295 let values = given_parameters(inv, name, &declared, through)?;
1296 let face = optional(inv, "as")?;
1297 let keep = plan::expand_families(keep, inv.capability, ceiling);
1298 let markers = if inv.capability.is_root() {
1303 plan::markers(&keep)
1304 } else {
1305 Vec::new()
1306 };
1307 let note = (!markers.is_empty()).then(|| {
1308 format!(
1309 "urn:script:{name} ran holding {} only as a family: a family held is not a grant, \
1310 so a step whose module checks an exact token under it is refused. Neither the \
1311 runner nor the host's ceiling for this script names a member (a step's contract \
1312 declares the family, never the member, and root holds no list to pick one from). \
1313 Run it under a capability that holds the exact grants, or have the host's \
1314 ceiling for urn:script:{name} name them",
1315 markers
1316 .iter()
1317 .map(|m| format!("`{m}`"))
1318 .collect::<Vec<_>>()
1319 .join(", ")
1320 )
1321 });
1322 Ok(Planned {
1323 request: plan::eval_request(&prepared.program, &values, face),
1324 keep,
1325 note,
1326 })
1327}
1328
1329async fn plan_sparql(
1339 inv: &Invocation<'_>,
1340 shared: &SpaceConfig,
1341 prepared: &Prepared,
1342 mut keep: BTreeSet<String>,
1343 ceiling: &Ceiling,
1344 through: Door,
1345) -> Result<Planned> {
1346 let name = &prepared.name;
1347 let door = door(shared)?;
1348 let analysis = prepared.sparql.as_ref().ok_or_else(|| {
1349 Error::Endpoint(format!(
1350 "urn:script:{name}:compiled carries no SPARQL analysis"
1351 ))
1352 })?;
1353 if through == Door::Result && !analysis.form.is_read() {
1354 return Err(Error::InvalidArgument {
1355 name: "name".to_string(),
1356 detail: format!(
1357 "urn:script:{name} is a SPARQL UPDATE, which is a write: run it with a Sink to \
1358 urn:script:{name}:runs"
1359 ),
1360 });
1361 }
1362 let values = parameter_values(inv, name, &analysis.parameters, through)?;
1363 let face = sparql_face(inv, analysis.form)?;
1364 let bound = sparql::bind(&prepared.program, door, &values)?;
1365
1366 let runner = inv.capability;
1367 let root_marker = keep.remove(CAP_READ_GRAPH);
1370 let family = prepared.requires.contains(CAP_READ_GRAPH);
1371 let by_family = |scope: &str, keep: &BTreeSet<String>| {
1372 family
1373 && runner.allows(scope)
1374 && if root_marker {
1375 ceiling.allows(scope)
1376 } else {
1377 keep.contains(scope)
1378 }
1379 };
1380
1381 if bound.form == Form::Update {
1382 let graph = bound.graphs.iter().next().cloned().ok_or_else(|| {
1385 Error::Endpoint(format!("urn:script:{name} names no graph to update"))
1386 })?;
1387 for scope in &prepared.requires {
1388 may_use(name, &graph, scope, &keep, runner)?;
1389 }
1390 let request = Request::new(Verb::Sink, iri(&door.query_iri(Form::Update))?)
1391 .with_arg("content", inline(&bound.text))
1392 .with_arg("graph", inline(&graph));
1393 return Ok(Planned {
1394 request,
1395 keep,
1396 note: None,
1397 });
1398 }
1399
1400 let mut extra = BTreeSet::new();
1401 for graph in &bound.graphs {
1402 let scope = sparql::cap_read_graph(graph);
1403 if !keep.contains(&scope) && by_family(&scope, &keep) {
1404 extra.insert(scope);
1405 }
1406 }
1407 let mut dataset = bound.graphs.clone();
1408 if bound.any_graph {
1409 if !family {
1410 return Err(Error::Endpoint(format!(
1411 "urn:script:{name} reads a variable graph but its record derives no \
1412 `{CAP_READ_GRAPH}`; republish it"
1413 )));
1414 }
1415 let under = |scopes: &mut dyn Iterator<Item = &String>| -> BTreeSet<String> {
1416 scopes
1417 .filter_map(|s| s.strip_prefix(sparql::READ_GRAPH_PREFIX))
1418 .filter(|g| !g.starts_with('-') && !g.ends_with('*'))
1419 .map(str::to_string)
1420 .collect()
1421 };
1422 let candidates = if !root_marker {
1423 under(&mut keep.iter())
1424 } else if let Some(held) = runner.scopes() {
1425 under(&mut held.iter())
1426 } else {
1427 let listing = inv
1430 .issue_attenuated(
1431 Request::new(Verb::Source, iri(&door.graphs_iri())?),
1432 [sparql::CAP_STORE_READ.to_string()],
1433 )
1434 .await?;
1435 String::from_utf8_lossy(&listing.bytes)
1436 .lines()
1437 .map(str::trim)
1438 .filter(|l| !l.is_empty())
1439 .map(str::to_string)
1440 .collect()
1441 };
1442 for graph in candidates {
1443 let scope = sparql::cap_read_graph(&graph);
1444 if keep.contains(&scope) && runner.allows(&scope) || by_family(&scope, &keep) {
1445 extra.insert(scope);
1446 dataset.insert(graph);
1447 }
1448 }
1449 }
1450 if dataset.is_empty() {
1451 return Err(Error::Denied(format!(
1452 "urn:script:{name} reads the caller's readable graphs, and this run may read none: \
1453 it needs `{}` grants the publisher held, the host allows and the runner holds",
1454 sparql::READ_GRAPH_PREFIX
1455 )));
1456 }
1457 keep.extend(extra);
1458 for graph in &dataset {
1459 may_use(name, graph, &sparql::cap_read_graph(graph), &keep, runner)?;
1460 }
1461 let graphs = dataset.into_iter().collect::<Vec<_>>().join(" ");
1462 let request = Request::new(Verb::Source, iri(&door.query_iri(bound.form))?)
1463 .with_arg("query", inline(&bound.text))
1464 .with_arg("graph", inline(&graphs))
1465 .with_arg("as", inline(face));
1466 Ok(Planned {
1467 request,
1468 keep,
1469 note: None,
1470 })
1471}
1472
1473fn may_use(
1477 name: &str,
1478 graph: &str,
1479 scope: &str,
1480 keep: &BTreeSet<String>,
1481 runner: &ikigai_core::Capability,
1482) -> Result<()> {
1483 if !runner.allows(scope) {
1484 return Err(Error::Denied(format!(
1485 "running urn:script:{name} reads or writes <{graph}>, which needs `{scope}`, and the \
1486 runner does not hold it"
1487 )));
1488 }
1489 if !keep.contains(scope) {
1490 return Err(Error::Denied(format!(
1491 "running urn:script:{name} reads or writes <{graph}>, which needs `{scope}`, and \
1492 this script may not have it: its publisher did not hold it, or the host's ceiling \
1493 for it does not allow it"
1494 )));
1495 }
1496 Ok(())
1497}
1498
1499fn sparql_face(inv: &Invocation<'_>, form: Form) -> Result<&'static str> {
1502 let faces = form.faces();
1503 match optional(inv, "as")? {
1504 None => Ok(faces[0]),
1505 Some(asked) => {
1506 let bare = asked.split(';').next().unwrap_or(asked).trim();
1507 faces
1508 .iter()
1509 .find(|f| **f == bare)
1510 .copied()
1511 .ok_or_else(|| Error::InvalidArgument {
1512 name: "as".to_string(),
1513 detail: format!(
1514 "`{asked}` is not a face a SPARQL {} answers in; one of {}",
1515 form.as_str().to_uppercase(),
1516 faces.join(", ")
1517 ),
1518 })
1519 }
1520 }
1521}
1522
1523fn parameter_values(
1528 inv: &Invocation<'_>,
1529 name: &str,
1530 parameters: &[Parameter],
1531 through: Door,
1532) -> Result<BTreeMap<String, Value>> {
1533 let declared: Vec<(&str, bool)> = parameters
1534 .iter()
1535 .map(|p| (p.name.as_str(), p.required && p.default.is_none()))
1536 .collect();
1537 let mut given = given_parameters(inv, name, &declared, through)?;
1538 let mut values = BTreeMap::new();
1539 for parameter in parameters {
1540 if let Some(lexical) = given
1541 .remove(¶meter.name)
1542 .or_else(|| parameter.default.clone())
1543 {
1544 values.insert(parameter.name.clone(), parameter.value(&lexical)?);
1545 }
1546 }
1547 Ok(values)
1548}
1549
1550pub const TRANSPORT_ARGUMENTS: [&str; 4] = ["received", "client", "principal", "content-type"];
1576
1577fn given_parameters(
1592 inv: &Invocation<'_>,
1593 name: &str,
1594 declared: &[(&str, bool)],
1595 through: Door,
1596) -> Result<BTreeMap<String, String>> {
1597 let listed = || {
1598 if declared.is_empty() {
1599 "it declares none".to_string()
1600 } else {
1601 format!(
1602 "its parameters are {}",
1603 declared
1604 .iter()
1605 .map(|(p, _)| *p)
1606 .collect::<Vec<_>>()
1607 .join(", ")
1608 )
1609 }
1610 };
1611 for arg in inv.request.args.keys() {
1612 let known = arg == "as"
1613 || TRANSPORT_ARGUMENTS.contains(&arg.as_str())
1614 || (through == Door::Runs && arg == "content")
1615 || declared.iter().any(|(p, _)| p == arg);
1616 if !known {
1617 return Err(Error::InvalidArgument {
1618 name: arg.clone(),
1619 detail: format!("is not a parameter of urn:script:{name}; {}", listed()),
1620 });
1621 }
1622 }
1623 let mut piped: BTreeMap<String, String> = BTreeMap::new();
1624 if through == Door::Runs {
1625 if let Some(content) = optional(inv, "content")?.filter(|c| !c.trim().is_empty()) {
1627 let object: serde_json::Map<String, serde_json::Value> = serde_json::from_str(content)
1628 .map_err(|e| Error::InvalidArgument {
1629 name: "content".to_string(),
1630 detail: format!(
1631 "a script's piped content is its parameters as one JSON object ({e})"
1632 ),
1633 })?;
1634 for (key, value) in object {
1635 let lexical = match value {
1636 serde_json::Value::String(s) => s,
1637 serde_json::Value::Number(n) => n.to_string(),
1638 serde_json::Value::Bool(b) => b.to_string(),
1639 other => {
1640 return Err(Error::InvalidArgument {
1641 name: "content".to_string(),
1642 detail: format!(
1643 "`{key}` is {other}; a parameter's value is a string, a number \
1644 or a boolean"
1645 ),
1646 })
1647 }
1648 };
1649 piped.insert(key, lexical);
1650 }
1651 }
1652 }
1653 let mut given = BTreeMap::new();
1654 for (parameter, must) in declared {
1655 let named = optional(inv, parameter)?;
1656 let lexical = match (named, piped.remove(*parameter)) {
1657 (Some(_), Some(_)) => {
1658 return Err(Error::InvalidArgument {
1659 name: parameter.to_string(),
1660 detail: "is given both by name and in the piped content".to_string(),
1661 })
1662 }
1663 (Some(value), None) => Some(value.to_string()),
1664 (None, Some(value)) => Some(value),
1665 (None, None) => None,
1666 };
1667 match lexical {
1668 Some(lexical) => {
1669 given.insert(parameter.to_string(), lexical);
1670 }
1671 None if *must => return Err(Error::MissingArgument(parameter.to_string())),
1672 None => {}
1673 }
1674 }
1675 if let Some(stray) = piped.keys().next() {
1676 return Err(Error::InvalidArgument {
1677 name: "content".to_string(),
1678 detail: format!(
1679 "`{stray}` is not a parameter of urn:script:{name}; {}",
1680 listed()
1681 ),
1682 });
1683 }
1684 Ok(given)
1685}
1686
1687struct ResultEndpoint {
1688 shared: Arc<SpaceConfig>,
1689}
1690
1691#[async_trait]
1692impl Endpoint for ResultEndpoint {
1693 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1694 if inv.request.verb != Verb::Source {
1695 return Err(unsupported("script-result", inv.request.verb));
1696 }
1697 let name = name::from_bindings(inv)?;
1698 let (prepared, keep, ceiling) = prepare_run(inv, &self.shared, &name).await?;
1699 let planned =
1700 prepare_request(inv, &self.shared, &prepared, keep, &ceiling, Door::Result).await?;
1701 let Planned {
1704 request,
1705 keep,
1706 note,
1707 } = planned;
1708 let answer = inv
1709 .issue_attenuated(request, keep)
1710 .await
1711 .map_err(|e| explain(note.as_deref(), e))?;
1712 Ok(Representation::new(answer.repr_type, answer.bytes)
1718 .cacheable()
1719 .depends_on(name::authority_thread(&name)))
1720 }
1721
1722 fn name(&self) -> &str {
1723 "script-result"
1724 }
1725
1726 fn describe(&self) -> Description {
1727 Description::new("script-result")
1728 .title("A script's result")
1729 .summary(
1730 "Run the script as a READ and answer its value. It runs under the runner's \
1731 capability narrowed to what the script declares, its publisher held and \
1732 the host allows it — never more. Cacheable exactly as far as the script's \
1733 own sub-requests are (a Lisp program opts in with `(cacheable …)`), and \
1734 recomputed when the script is republished. No run record: a cached answer \
1735 runs nothing; record a run with `…:runs`. A published SPARQL script or \
1736 plan is its own entry, with its parameters as arguments; a published Lisp \
1737 script's door also requires `urn:cap:lisp`. A run of a script that is not \
1738 published is refused for its state (to a caller who may see it) or as \
1739 absent.",
1740 )
1741 .verb(Verb::Meta)
1742 .action(
1743 ActionSpec::new(Verb::Source)
1744 .summary("The script's answer.")
1745 .input(name_arg())
1746 .input(data_arg("data", DATA_SUMMARY))
1747 .output(PLAIN)
1748 .requires(CAP_RUN),
1749 )
1750 }
1751}
1752
1753struct RunsEndpoint {
1754 shared: Arc<SpaceConfig>,
1755}
1756
1757#[async_trait]
1758impl Endpoint for RunsEndpoint {
1759 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1760 if inv.request.verb != Verb::Sink {
1761 return Err(unsupported("script-runs", inv.request.verb));
1762 }
1763 let name = name::from_bindings(inv)?;
1764 let shared = &self.shared;
1765 let (prepared, keep, ceiling) = prepare_run(inv, shared, &name).await?;
1766 let planned = prepare_request(inv, shared, &prepared, keep, &ceiling, Door::Runs).await?;
1769 let runs_as = inv.capability.attenuate(planned.keep.iter().cloned());
1770 let mut run = Run {
1771 schema: SCHEMA,
1772 name: name.clone(),
1773 id: 0,
1774 version: prepared.version.clone(),
1775 principal: principal_of(inv.capability),
1776 capability: runs_as.scopes().cloned().unwrap_or_default(),
1777 started: inv.now().map(|t| t.as_millis()),
1778 ended: None,
1779 outcome: Outcome::Running,
1780 result: None,
1781 truncated: false,
1782 trace_span: inv.trace_span(),
1783 };
1784 run.id = shared.backend.start_run(&name, &run)?;
1785 let Planned {
1786 request,
1787 keep,
1788 note,
1789 } = planned;
1790 let answer = inv
1791 .issue_attenuated(request, keep)
1792 .await
1793 .map_err(|e| explain(note.as_deref(), e));
1794 run.ended = inv.now().map(|t| t.as_millis());
1795 match answer {
1796 Ok(repr) => {
1797 let (text, truncated) = model::truncate(
1798 String::from_utf8_lossy(&repr.bytes).into_owned(),
1799 MAX_RECORDED_RESULT,
1800 );
1801 run.outcome = Outcome::Ok;
1802 run.result = Some(text);
1803 run.truncated = truncated;
1804 shared.backend.finish_run(&name, &run)?;
1805 Ok(plain(format!("{}\n", run.iri())))
1806 }
1807 Err(error) => {
1808 run.outcome = Outcome::Failed {
1809 kind: kind(&error).to_string(),
1810 message: error.to_string(),
1811 };
1812 shared.backend.finish_run(&name, &run)?;
1813 Err(annotate(error, &run.iri()))
1814 }
1815 }
1816 }
1817
1818 fn name(&self) -> &str {
1819 "script-runs"
1820 }
1821
1822 fn describe(&self) -> Description {
1823 Description::new("script-runs")
1824 .title("A script's runs")
1825 .summary(
1826 "Run the script for its EFFECTS and record the run: who (the principal \
1827 the capability names), which version, under exactly what capability, when, and the \
1828 outcome. Answers the run's IRI; a run that fails is recorded too, and its \
1829 error names the record. A published SPARQL script or plan is its own \
1830 entry, with its parameters as arguments; a published Lisp script's door also \
1831 requires `urn:cap:lisp`. A script that is not published is refused before \
1832 any run is recorded.",
1833 )
1834 .verb(Verb::Meta)
1835 .action(
1836 ActionSpec::new(Verb::Sink)
1837 .summary("Run the script and record the run.")
1838 .input(name_arg())
1839 .input(data_arg("content", DATA_SUMMARY))
1840 .output(PLAIN)
1841 .requires(CAP_RUN),
1842 )
1843 }
1844}
1845
1846#[derive(Clone)]
1853struct PerScript {
1854 result: Arc<dyn Endpoint>,
1855 runs: Arc<dyn Endpoint>,
1856 reads: bool,
1858 listed: bool,
1863}
1864
1865#[derive(Clone)]
1867enum Contract {
1868 Own(PerScript),
1871 Lisp,
1873 Template,
1876}
1877
1878#[derive(Clone)]
1902struct LispDoors {
1903 result: Arc<dyn Endpoint>,
1904 runs: Arc<dyn Endpoint>,
1905}
1906
1907impl LispDoors {
1908 fn new(shared: &Arc<SpaceConfig>) -> LispDoors {
1910 let door = |inner: Arc<dyn Endpoint>| -> Arc<dyn Endpoint> {
1911 let description = needs_lisp(inner.describe());
1912 Arc::new(Described {
1913 id: description.id.clone(),
1914 description,
1915 inner,
1916 })
1917 };
1918 LispDoors {
1919 result: door(Arc::new(ResultEndpoint {
1920 shared: Arc::clone(shared),
1921 })),
1922 runs: door(Arc::new(RunsEndpoint {
1923 shared: Arc::clone(shared),
1924 })),
1925 }
1926 }
1927}
1928
1929fn needs_lisp(mut template: Description) -> Description {
1932 for action in &mut template.actions {
1933 action.requires.push(CAP_LISP.to_string());
1934 }
1935 template
1936}
1937
1938struct Described {
1949 inner: Arc<dyn Endpoint>,
1950 id: String,
1951 description: Description,
1952}
1953
1954#[async_trait]
1955impl Endpoint for Described {
1956 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1957 self.inner.invoke(inv).await
1958 }
1959
1960 fn name(&self) -> &str {
1961 &self.id
1962 }
1963
1964 fn describe(&self) -> Description {
1965 self.description.clone()
1966 }
1967}
1968
1969pub struct ScriptSpace {
1972 inner: EndpointSpace,
1973 lisp: LispDoors,
1974 shared: Arc<SpaceConfig>,
1975}
1976
1977impl ScriptSpace {
1978 fn contract(&self, name: &str) -> Contract {
1985 let shared = &self.shared;
1986 if let Some(known) = shared
1987 .described
1988 .lock()
1989 .expect("the described-script memo")
1990 .get(name)
1991 {
1992 return known.clone();
1993 }
1994 let Ok(Some(head)) = shared.backend.head(name) else {
1995 return Contract::Template;
1998 };
1999 let built = self.build(name, &head);
2000 shared
2001 .described
2002 .lock()
2003 .expect("the described-script memo")
2004 .insert(name.to_string(), built.clone());
2005 built
2006 }
2007
2008 fn build(&self, name: &str, head: &Head) -> Contract {
2009 if !ever_published(head, &head.version) {
2016 return Contract::Template;
2017 }
2018 let Ok(Some(version)) = self.shared.backend.version(name, &head.version) else {
2021 return Contract::Lisp;
2022 };
2023 let own = match version.language {
2024 Language::Lisp if head.state == State::Published => return Contract::Lisp,
2025 Language::Lisp => None,
2026 Language::Sparql => self.build_sparql(name, head, &version),
2027 Language::Plan => self.build_plan(name, head, &version),
2028 };
2029 own.map_or(Contract::Template, Contract::Own)
2032 }
2033
2034 fn described(
2036 &self,
2037 inner: Arc<dyn Endpoint>,
2038 id: String,
2039 mut description: Description,
2040 head: &Head,
2041 ) -> Arc<dyn Endpoint> {
2042 description.summary.push_str(&run_gate_note(head));
2043 Arc::new(Described {
2044 inner,
2045 id,
2046 description,
2047 }) as Arc<dyn Endpoint>
2048 }
2049
2050 fn result_endpoint(&self) -> Arc<dyn Endpoint> {
2051 Arc::new(ResultEndpoint {
2052 shared: Arc::clone(&self.shared),
2053 })
2054 }
2055
2056 fn runs_endpoint(&self) -> Arc<dyn Endpoint> {
2057 Arc::new(RunsEndpoint {
2058 shared: Arc::clone(&self.shared),
2059 })
2060 }
2061
2062 fn build_sparql(&self, name: &str, head: &Head, version: &Version) -> Option<PerScript> {
2063 let door = self.shared.sparql.as_ref()?;
2064 let analysis = sparql::analyze(&version.source, door).ok()?;
2065 let about = about(&version.source);
2066 let inputs: Vec<ArgSpec> = analysis
2067 .parameters
2068 .iter()
2069 .map(|p| {
2070 let mut arg = ArgSpec::new(p.name.clone())
2071 .summary(
2072 p.summary
2073 .clone()
2074 .unwrap_or_else(|| format!("The value of ?{} in the query.", p.name)),
2075 )
2076 .class(p.kind.iri().to_string());
2077 if let Some(default) = &p.default {
2078 arg = arg.default_value(default.clone()).optional();
2079 } else if !p.required {
2080 arg = arg.optional();
2081 }
2082 arg
2083 })
2084 .collect();
2085 let params = |spec: ActionSpec| contracted(spec, &inputs, name, head, version);
2086 let form = analysis.form;
2087 let faces = form.faces();
2088 let as_arg = || {
2089 ArgSpec::new("as")
2090 .summary(format!(
2091 "The face: one of {}. Any other is refused, never substituted.",
2092 faces.join(", ")
2093 ))
2094 .class(XSD_STRING)
2095 .one_of(faces.iter().copied())
2096 .default_value(faces[0])
2097 .optional()
2098 };
2099 let id = format!("script-{name}-result");
2100 let description = if form.is_read() {
2101 let mut spec = params(ActionSpec::new(Verb::Source))
2102 .summary(format!("Run the query {name} and answer its result."))
2103 .input(as_arg());
2104 for face in faces {
2105 spec = spec.output(*face);
2106 }
2107 Description::new(id.clone())
2108 .title(format!("{name}: a SPARQL {}", form.as_str().to_uppercase()))
2109 .summary(format!(
2110 "{about}Runs as a READ under the runner's capability narrowed to the \
2111 graphs the query names; cached as the store's answer is, and \
2112 recomputed after a write to the store or a republish."
2113 ))
2114 .verb(Verb::Meta)
2115 .action(spec)
2116 } else {
2117 Description::new(id.clone())
2120 .title(format!("{name}: a SPARQL UPDATE (run it at …:runs)"))
2121 .summary(format!(
2122 "{about}A write: run it with a Sink to urn:script:{name}:runs."
2123 ))
2124 .verb(Verb::Meta)
2125 };
2126 let result = self.described(self.result_endpoint(), id, description, head);
2127 let id = format!("script-{name}-runs");
2128 let mut spec = params(ActionSpec::new(Verb::Sink))
2129 .summary(format!(
2130 "Run {name} for its effects and record the run; answers the run's IRI."
2131 ))
2132 .input(piped_parameters_arg())
2133 .output(PLAIN);
2134 if form.is_read() {
2135 spec = spec.input(as_arg());
2136 }
2137 let runs = self.described(
2138 self.runs_endpoint(),
2139 id.clone(),
2140 Description::new(id)
2141 .title(format!("{name}: runs"))
2142 .summary(format!(
2143 "{about}Each run is recorded: who, which version, under exactly what \
2144 capability, when, and the outcome."
2145 ))
2146 .verb(Verb::Meta)
2147 .action(spec),
2148 head,
2149 );
2150 Some(PerScript {
2151 result,
2152 runs,
2153 reads: form.is_read(),
2154 listed: head.state == State::Published,
2155 })
2156 }
2157
2158 fn build_plan(&self, name: &str, head: &Head, version: &Version) -> Option<PerScript> {
2161 let analysis = plan::analyze(&version.source).ok()?;
2162 let about = about(&version.source);
2163 let inputs: Vec<ArgSpec> = analysis
2164 .parameters
2165 .iter()
2166 .map(|p| {
2167 let mut arg = ArgSpec::new(p.name.clone())
2168 .summary(
2169 p.summary
2170 .clone()
2171 .unwrap_or_else(|| format!("The plan's parameter `{}`.", p.name)),
2172 )
2173 .class(p.class.clone().unwrap_or_else(|| XSD_STRING.to_string()));
2174 if let Some(default) = &p.default {
2175 arg = arg.default_value(default.clone()).optional();
2176 } else if !p.required {
2177 arg = arg.optional();
2178 }
2179 arg
2180 })
2181 .collect();
2182 let params = |spec: ActionSpec| contracted(spec, &inputs, name, head, version);
2183 let as_arg = || {
2187 ArgSpec::new("as")
2188 .summary(
2189 "The face of the plan's result: when its result step served another \
2190 media type it is transrepted, and refused when nothing converts it.",
2191 )
2192 .class(XSD_STRING)
2193 .optional()
2194 };
2195 let reads = !analysis.mutates();
2196 let id = format!("script-{name}-result");
2197 let description = if reads {
2198 Description::new(id.clone())
2199 .title(format!("{name}: a plan"))
2200 .summary(format!(
2201 "{about}Runs as a READ (every step reads) under the runner's capability \
2202 narrowed to what the plan's steps require; as cacheable as its least \
2203 cacheable step, and recomputed after a republish."
2204 ))
2205 .verb(Verb::Meta)
2206 .action(
2207 params(ActionSpec::new(Verb::Source))
2208 .summary(format!("Run the plan {name} and answer its result."))
2209 .input(as_arg())
2210 .output(PLAIN),
2211 )
2212 } else {
2213 Description::new(id.clone())
2216 .title(format!("{name}: a plan that writes (run it at …:runs)"))
2217 .summary(format!(
2218 "{about}A write: run it with a Sink to urn:script:{name}:runs."
2219 ))
2220 .verb(Verb::Meta)
2221 };
2222 let result = self.described(self.result_endpoint(), id, description, head);
2223 let id = format!("script-{name}-runs");
2224 let runs = self.described(
2225 self.runs_endpoint(),
2226 id.clone(),
2227 Description::new(id)
2228 .title(format!("{name}: runs"))
2229 .summary(format!(
2230 "{about}Each run is recorded: who, which version, under exactly what \
2231 capability, when, and the outcome."
2232 ))
2233 .verb(Verb::Meta)
2234 .action(
2235 params(ActionSpec::new(Verb::Sink))
2236 .summary(format!(
2237 "Run the plan {name} and record the run; answers the run's IRI."
2238 ))
2239 .input(piped_parameters_arg())
2240 .input(as_arg())
2241 .output(PLAIN),
2242 ),
2243 head,
2244 );
2245 Some(PerScript {
2246 result,
2247 runs,
2248 reads,
2249 listed: head.state == State::Published,
2250 })
2251 }
2252}
2253
2254fn run_gate_note(head: &Head) -> String {
2263 let floor = authority::run_floor(&head.name);
2264 if head.public || floor == cap_run(&head.name) {
2265 return String::new();
2266 }
2267 format!(
2268 " Running it needs `{}` or a namespace grant covering it. A name with a `-` is in a \
2269 namespace, and a contract cannot depend on who asks, so the kernel's floor checks the \
2270 family of its top-level namespace, `{floor}`: any grant in it passes there, and the \
2271 exact rule is checked when the run starts.",
2272 cap_run(&head.name)
2273 )
2274}
2275
2276fn contracted(
2284 mut spec: ActionSpec,
2285 inputs: &[ArgSpec],
2286 name: &str,
2287 head: &Head,
2288 version: &Version,
2289) -> ActionSpec {
2290 for input in inputs {
2291 spec = spec.input(input.clone());
2292 }
2293 spec = spec.requires(if head.public {
2294 CAP_RUN.to_string()
2295 } else {
2296 authority::run_floor(name)
2297 });
2298 for scope in &version.requires {
2299 spec = spec.requires(scope.clone());
2300 }
2301 spec
2302}
2303
2304fn piped_parameters_arg() -> ArgSpec {
2306 ArgSpec::new("content")
2307 .summary(
2308 "The parameters as one JSON object, for a pipe (a parameter given both here and \
2309 by name is refused).",
2310 )
2311 .class(XSD_STRING)
2312 .optional()
2313}
2314
2315fn about(source: &str) -> String {
2317 let mut lines = Vec::new();
2318 for line in source.lines() {
2319 let trimmed = line.trim();
2320 let Some(body) = trimmed.strip_prefix('#') else {
2321 if trimmed.is_empty() {
2322 continue;
2323 }
2324 break;
2325 };
2326 let body = body.trim();
2327 if !body.is_empty() && !body.starts_with("@param") {
2328 lines.push(body);
2329 }
2330 }
2331 if lines.is_empty() {
2332 String::new()
2333 } else {
2334 format!("{} ", lines.join(" "))
2335 }
2336}
2337
2338impl Space for ScriptSpace {
2339 fn resolve(&self, request: &Request, scope: &Scope) -> Resolution {
2340 let resolution = self.inner.resolve(request, scope);
2341 let (name, part) = match &resolution {
2342 Resolution::Hit(hit) => {
2343 let part = match hit.endpoint.name() {
2344 "script-result" => Door::Result,
2345 "script-runs" => Door::Runs,
2346 _ => return resolution,
2347 };
2348 match hit.bindings.get("name") {
2349 Some(name) if name::validate(name).is_ok() => (name.to_string(), part),
2350 _ => return resolution,
2351 }
2352 }
2353 Resolution::Miss => return resolution,
2354 };
2355 let (result, runs) = match self.contract(&name) {
2356 Contract::Own(per) => (per.result, per.runs),
2357 Contract::Template => return resolution,
2358 Contract::Lisp => (Arc::clone(&self.lisp.result), Arc::clone(&self.lisp.runs)),
2359 };
2360 resolution.map_endpoint(|_| match part {
2361 Door::Result => result,
2362 Door::Runs => runs,
2363 })
2364 }
2365
2366 fn entries(&self) -> Option<Vec<SpaceEntry>> {
2367 let mut entries = self.inner.entries().unwrap_or_default();
2368 for name in self.shared.backend.names().unwrap_or_default() {
2369 let Contract::Own(per) = self.contract(&name) else {
2370 continue;
2371 };
2372 if !per.listed {
2373 continue;
2374 }
2375 if per.reads {
2376 entries.push(SpaceEntry::new(
2377 name::part_iri(&name, "result"),
2378 per.result.name(),
2379 ));
2380 }
2381 entries.push(SpaceEntry::new(
2382 name::part_iri(&name, "runs"),
2383 per.runs.name(),
2384 ));
2385 }
2386 Some(entries)
2387 }
2388
2389 fn id(&self) -> Option<Iri> {
2390 self.inner.id()
2391 }
2392
2393 fn topology(&self) -> Topology {
2394 self.inner.topology()
2395 }
2396}
2397
2398fn kind(error: &Error) -> &'static str {
2400 match error {
2401 Error::Unresolved(_) => "unresolved",
2402 Error::MissingArgument(_) => "missing-argument",
2403 Error::InvalidArgument { .. } => "invalid-argument",
2404 Error::Endpoint(_) => "endpoint",
2405 Error::Denied(_) => "denied",
2406 Error::NotFound(_) => "not-found",
2407 Error::Conflict(_) => "conflict",
2408 Error::Timeout(_) => "timeout",
2409 Error::Unavailable(_) => "unavailable",
2410 _ => "other",
2411 }
2412}
2413
2414fn annotate(error: Error, run: &str) -> Error {
2416 let note = |message: String| format!("{message} (run recorded at {run})");
2417 match error {
2418 Error::Endpoint(m) => Error::Endpoint(note(m)),
2419 Error::Denied(m) => Error::Denied(note(m)),
2420 Error::NotFound(m) => Error::NotFound(note(m)),
2421 Error::Conflict(m) => Error::Conflict(note(m)),
2422 Error::Timeout(m) => Error::Timeout(note(m)),
2423 Error::Unavailable(m) => Error::Unavailable(note(m)),
2424 Error::InvalidArgument { name, detail } => Error::InvalidArgument {
2425 name,
2426 detail: note(detail),
2427 },
2428 other => other,
2429 }
2430}
2431
2432struct RunEndpoint {
2437 shared: Arc<SpaceConfig>,
2438}
2439
2440#[async_trait]
2441impl Endpoint for RunEndpoint {
2442 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
2443 let name = name::from_bindings(inv)?;
2444 inv.depends_on(name::part_iri(&name, "runs"));
2453 if !may(inv, Act::Read, &name) && !may(inv, Act::Run, &name) {
2456 return Err(Error::Denied(format!(
2457 "a run record of urn:script:{name} needs `{}` or `{}`. {GRANT_FORMS}",
2458 cap(Act::Read, &name),
2459 cap(Act::Run, &name)
2460 )));
2461 }
2462 let id_text = inv
2463 .bindings
2464 .get("id")
2465 .ok_or_else(|| Error::Endpoint("no `id` captured".to_string()))?;
2466 let id = id_text.parse::<u64>().map_err(|_| Error::InvalidArgument {
2467 name: "id".to_string(),
2468 detail: format!("`{id_text}` is not a run number"),
2469 });
2470 let run = match id {
2471 Ok(id) => self.shared.backend.run(&name, id)?,
2472 Err(_) if inv.request.verb == Verb::Exists => None,
2473 Err(e) => return Err(e),
2474 };
2475 match inv.request.verb {
2476 Verb::Exists => Ok(plain(if run.is_some() { "true\n" } else { "false\n" })),
2477 Verb::Source => {
2478 let want = face_of(inv, &RECORD_FACES)?;
2479 let run = run.ok_or_else(|| {
2480 Error::NotFound(format!("urn:script:{name} has no run {id_text}"))
2481 })?;
2482 let finished = run.outcome != Outcome::Running;
2483 let repr = match want {
2484 JSON => json(&run)?,
2485 TURTLE => {
2486 let mut graph = crate::graph::Graph::default();
2487 graph.record(&run);
2488 turtle(graph)?
2489 }
2490 _ => plain(run.render()),
2491 };
2492 Ok(if finished { repr.cacheable() } else { repr })
2494 }
2495 other => Err(unsupported("script-run", other)),
2496 }
2497 }
2498
2499 fn name(&self) -> &str {
2500 "script-run"
2501 }
2502
2503 fn describe(&self) -> Description {
2504 let id = || {
2505 ArgSpec::new("id")
2506 .summary("The run's number, unique per script.")
2507 .class(XSD_INTEGER)
2508 .binding()
2509 };
2510 Description::new("script-run")
2511 .title("One run of a script")
2512 .summary(
2513 "A recorded run: principal, version, capability, start and end, outcome, the \
2514 result (up to 64 KiB) and the host's trace span when it traced. Needs the \
2515 script's read or run grant.",
2516 )
2517 .verb(Verb::Meta)
2518 .action(
2519 ActionSpec::new(Verb::Source)
2520 .summary("The run record, as text, JSON, or a PROV-O graph (Turtle).")
2521 .input(name_arg())
2522 .input(id())
2523 .input(record_as_arg())
2524 .output(PLAIN)
2525 .output(JSON)
2526 .output(TURTLE)
2527 .requires(CAP_ANY),
2528 )
2529 .action(
2530 ActionSpec::new(Verb::Exists)
2531 .summary("Whether the run exists.")
2532 .input(name_arg())
2533 .input(id())
2534 .output(PLAIN)
2535 .requires(CAP_ANY),
2536 )
2537 }
2538}
2539
2540struct EvalEndpoint;
2545
2546#[async_trait]
2547impl Endpoint for EvalEndpoint {
2548 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
2549 if inv.request.verb != Verb::Sink {
2550 return Err(unsupported("script-eval", inv.request.verb));
2551 }
2552 let code = inv.inline_str("content")?;
2553 let language_text = optional(inv, "language")?;
2554 let language = language_text
2555 .map(Language::parse)
2556 .transpose()?
2557 .unwrap_or(Language::Lisp);
2558 if language == Language::Sparql {
2559 return Err(Error::InvalidArgument {
2560 name: "language".to_string(),
2561 detail: "SPARQL through urn:script:eval is the SPARQL Protocol face (ledger \
2562 #955), not in this version: publish the query as a script and read \
2563 its `…:result`"
2564 .to_string(),
2565 });
2566 }
2567 if language == Language::Plan {
2568 return Err(Error::InvalidArgument {
2571 name: "language".to_string(),
2572 detail: format!(
2573 "a plan supplied ad hoc runs at {} (`in` = the plan), under the caller's \
2574 own capability; to keep it, publish it as a script",
2575 plan::EVAL
2576 ),
2577 });
2578 }
2579 if let Some(save) = optional(inv, "save")? {
2580 let save = save.trim();
2581 name::validate(save)?;
2582 let mut request = Request::new(Verb::Sink, iri(&name::script_iri(save))?)
2586 .with_arg("content", inline(code))
2587 .with_arg("language", inline(language.as_str()))
2588 .with_arg("state", inline("draft"));
2589 if let Some(requires) = optional(inv, "requires")? {
2590 request = request.with_arg("requires", inline(requires));
2591 }
2592 inv.issue(request).await?;
2593 }
2594 let answer = inv
2597 .issue(lisp_request(code, optional(inv, "data")?)?)
2598 .await?;
2599 Ok(Representation::new(answer.repr_type, answer.bytes))
2600 }
2601
2602 fn name(&self) -> &str {
2603 "script-eval"
2604 }
2605
2606 fn describe(&self) -> Description {
2607 Description::new("script-eval")
2608 .title("Run code you supply")
2609 .summary(
2610 "Run typed or pasted code as an anonymous script under the caller's OWN \
2611 capability and nothing more; answers its value. `save=` keeps it as a draft \
2612 of that script (which needs that script's write grant).",
2613 )
2614 .verb(Verb::Meta)
2615 .action(
2616 ActionSpec::new(Verb::Sink)
2617 .summary("Run the code.")
2618 .input(
2619 ArgSpec::new("content")
2620 .summary("The code (piped).")
2621 .class(XSD_STRING),
2622 )
2623 .input(
2624 ArgSpec::new("language")
2625 .summary(
2626 "The code's language: `lisp`, the one this door runs, so the \
2627 one its `requires` (`urn:cap:lisp`) is for. A plan runs at \
2628 urn:plan:eval, which declares no language capability; SPARQL \
2629 here is the Protocol face, not in this version.",
2630 )
2631 .class(XSD_STRING)
2632 .one_of([Language::Lisp.as_str()])
2636 .default_value("lisp")
2637 .optional(),
2638 )
2639 .input(data_arg("data", DATA_SUMMARY))
2640 .input(
2641 ArgSpec::new("save")
2642 .summary(
2643 "A script name: also keep the code as a draft at \
2644 `urn:script:{save}`. Needs `urn:cap:script:write:{save}`.",
2645 )
2646 .class(XSD_STRING)
2647 .optional(),
2648 )
2649 .input(
2650 ArgSpec::new("requires")
2651 .summary("With `save=`: the draft's declared capability.")
2652 .class(XSD_STRING)
2653 .optional(),
2654 )
2655 .output(PLAIN)
2656 .requires(CAP_LISP),
2657 )
2658 }
2659}
2660
2661#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2667pub struct CatalogEntry {
2668 pub name: String,
2670 pub iri: String,
2672 pub state: Option<State>,
2674 pub public: Option<bool>,
2676 pub version: Option<String>,
2678 #[serde(rename = "lastRun")]
2680 pub last_run: Option<LastRun>,
2681 pub error: Option<String>,
2683}
2684
2685#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2687pub struct LastRun {
2688 pub iri: String,
2690 pub status: String,
2692 pub ended: Option<u64>,
2694}
2695
2696#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2698pub struct Catalog {
2699 pub schema: u32,
2701 pub scripts: Vec<CatalogEntry>,
2703}
2704
2705struct CatalogEndpoint {
2706 shared: Arc<SpaceConfig>,
2707}
2708
2709#[async_trait]
2710impl Endpoint for CatalogEndpoint {
2711 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
2712 if inv.request.verb != Verb::Source {
2713 return Err(unsupported("script-catalog", inv.request.verb));
2714 }
2715 let want = face_of(inv, &RECORD_FACES)?;
2716 let backend = &self.shared.backend;
2717 let mut scripts = Vec::new();
2718 let mut last_runs = Vec::new();
2720 for name in backend.names()? {
2721 let own = may(inv, Act::Read, &name);
2722 let mut entry = CatalogEntry {
2723 iri: name::script_iri(&name),
2724 name: name.clone(),
2725 state: None,
2726 public: None,
2727 version: None,
2728 last_run: None,
2729 error: None,
2730 };
2731 let mut last = None;
2732 match backend.head(&name) {
2733 Err(e) if own => entry.error = Some(e.to_string()),
2736 Ok(Some(head)) if sight(inv, &head, &head.version) == Sight::Hidden => continue,
2738 Ok(Some(head))
2739 if own
2740 || (is_public(&head)
2741 && may_public(inv, Act::Read, CAP_READ_PUBLIC, &name)) =>
2742 {
2743 entry.state = Some(head.state);
2744 entry.public = Some(head.public);
2745 entry.version = Some(head.version);
2746 last = backend.last_run(&name).ok().flatten();
2747 entry.last_run = last.as_ref().map(|run| LastRun {
2748 iri: run.iri(),
2749 status: match run.outcome {
2750 Outcome::Running => "running",
2751 Outcome::Ok => "ok",
2752 Outcome::Failed { .. } => "failed",
2753 }
2754 .to_string(),
2755 ended: run.ended,
2756 });
2757 }
2758 _ => continue,
2759 }
2760 last_runs.push(last);
2761 scripts.push(entry);
2762 }
2763 if want == TURTLE {
2764 let mut graph = crate::graph::Graph::default();
2765 for (entry, last) in scripts.iter().zip(&last_runs) {
2766 graph.script(&entry.name, entry.version.as_deref(), last.as_ref());
2767 }
2768 return turtle(graph);
2769 }
2770 if want == JSON {
2771 return json(&Catalog {
2774 schema: SCHEMA,
2775 scripts,
2776 });
2777 }
2778 let mut text = String::new();
2779 for entry in &scripts {
2780 match (&entry.error, entry.state) {
2781 (Some(error), _) => text.push_str(&format!("{} BROKEN: {error}\n", entry.iri)),
2782 (None, Some(state)) => {
2783 let version = entry.version.as_deref().unwrap_or("");
2784 text.push_str(&format!(
2785 "{} {}{} {}",
2786 entry.iri,
2787 state.as_str(),
2788 if entry.public == Some(true) {
2789 " public"
2790 } else {
2791 ""
2792 },
2793 &version[..version.len().min(19)],
2794 ));
2795 if let Some(run) = &entry.last_run {
2796 text.push_str(&format!(
2797 " last run {} {} {}",
2798 run.iri,
2799 run.status,
2800 model::when(run.ended)
2801 ));
2802 }
2803 text.push('\n');
2804 }
2805 _ => {}
2806 }
2807 }
2808 if scripts.is_empty() {
2809 text.push_str("no scripts this capability may read\n");
2810 }
2811 Ok(plain(text))
2812 }
2813
2814 fn name(&self) -> &str {
2815 "script-catalog"
2816 }
2817
2818 fn describe(&self) -> Description {
2819 Description::new("script-catalog")
2820 .title("Scripts")
2821 .summary(
2822 "Every script the caller may read: state, public flag, head version and last \
2823 run. Live (never cached). A host that wants `urn:host:scripts` aliases it \
2824 here.",
2825 )
2826 .verb(Verb::Meta)
2827 .action(
2828 ActionSpec::new(Verb::Source)
2829 .summary("The catalog, as text, JSON, or a graph (Turtle).")
2830 .input(record_as_arg())
2831 .output(PLAIN)
2832 .output(JSON)
2833 .output(TURTLE)
2834 .requires(CAP_READ),
2835 )
2836 }
2837}
2838
2839fn unsupported(id: &str, verb: Verb) -> Error {
2840 Error::Endpoint(format!("{id} does not answer {verb:?}"))
2841}