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 XSD_POSITIVE_INTEGER: &str = "http://www.w3.org/2001/XMLSchema#positiveInteger";
38const TURTLE: &str = "text/turtle";
39const READ_FACES: [&str; 2] = [PLAIN, JSON];
41const RECORD_FACES: [&str; 3] = [PLAIN, JSON, TURTLE];
43
44#[derive(Clone)]
54#[non_exhaustive]
55pub struct SpaceConfig {
56 backend: Arc<dyn Backend>,
57 ceiling: CeilingPolicy,
58 sparql: Option<SparqlDoor>,
59 on_change: Option<ChangeHook>,
60 described: Arc<Mutex<HashMap<String, Contract>>>,
64}
65
66pub type ChangeHook = Arc<dyn Fn(&str) + Send + Sync>;
69
70impl SpaceConfig {
71 pub fn new(backend: Arc<dyn Backend>, ceiling: CeilingPolicy) -> SpaceConfig {
73 SpaceConfig {
74 backend,
75 ceiling,
76 sparql: None,
77 on_change: None,
78 described: Arc::new(Mutex::new(HashMap::new())),
79 }
80 }
81
82 pub fn sparql(mut self, door: SparqlDoor) -> SpaceConfig {
86 self.sparql = Some(door);
87 self
88 }
89
90 pub fn on_change(mut self, hook: ChangeHook) -> SpaceConfig {
101 self.on_change = Some(hook);
102 self
103 }
104
105 fn changed(&self, name: &str) {
107 self.described
108 .lock()
109 .expect("the described-script memo")
110 .remove(name);
111 if let Some(hook) = &self.on_change {
112 hook(name);
113 }
114 }
115}
116
117pub fn space(config: SpaceConfig) -> ScriptSpace {
124 let shared = Arc::new(config);
125 let template = |t: &str| UriTemplate::parse(t).expect("a constant template");
126 let inner = EndpointSpace::new()
127 .bind(Exact::new(EVAL_IRI), EvalEndpoint)
128 .bind(
129 Exact::new(CATALOG_IRI),
130 CatalogEndpoint {
131 shared: Arc::clone(&shared),
132 },
133 )
134 .bind(
135 template("urn:script:{name}:version:{digest}"),
136 VersionEndpoint {
137 shared: Arc::clone(&shared),
138 },
139 )
140 .bind(
141 template("urn:script:{name}:run:{id}"),
142 RunEndpoint {
143 shared: Arc::clone(&shared),
144 },
145 )
146 .bind(
147 template("urn:script:{name}:compiled"),
148 CompiledEndpoint {
149 shared: Arc::clone(&shared),
150 },
151 )
152 .bind(
153 template("urn:script:{name}:result"),
154 ResultEndpoint {
155 shared: Arc::clone(&shared),
156 },
157 )
158 .bind(
159 template("urn:script:{name}:runs"),
160 RunsEndpoint {
161 shared: Arc::clone(&shared),
162 },
163 )
164 .bind(
165 template("urn:script:{name}"),
166 ScriptEndpoint {
167 shared: Arc::clone(&shared),
168 },
169 );
170 ScriptSpace {
171 inner,
172 lisp: LispDoors::new(&shared),
173 shared,
174 }
175}
176
177fn holds(inv: &Invocation<'_>, scope: &str) -> bool {
182 inv.capability.allows(scope)
183}
184
185fn may(inv: &Invocation<'_>, act: Act, name: &str) -> bool {
188 authority::holds(inv.capability, act, name)
189}
190
191fn may_public(inv: &Invocation<'_>, act: Act, public: &str, name: &str) -> bool {
194 holds(inv, public) && !authority::excluded(inv.capability, act, name)
195}
196
197const GRANT_FORMS: &str = "A script grant names one script (`urn:cap:script:{act}:{name}`) or a \
199 namespace of them (`urn:cap:script:{act}:{namespace}-*`)";
200
201fn is_public(head: &Head) -> bool {
203 head.public && head.state == State::Published
204}
205
206fn readable_head(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Result<Option<Head>> {
212 gated_head(inv, shared, name, &[(Act::Read, CAP_READ_PUBLIC)])
213}
214
215fn gated_head(
218 inv: &Invocation<'_>,
219 shared: &SpaceConfig,
220 name: &str,
221 acts: &[(Act, &str)],
222) -> Result<Option<Head>> {
223 if acts.iter().any(|(act, _)| may(inv, *act, name)) {
224 return shared.backend.head(name);
225 }
226 if let Ok(Some(head)) = shared.backend.head(name) {
227 if is_public(&head)
228 && acts
229 .iter()
230 .any(|(act, public)| may_public(inv, *act, public, name))
231 {
232 return Ok(Some(head));
233 }
234 }
235 Err(Error::Denied(format!(
236 "this capability holds none of {} — nor, for a public script, {}. {GRANT_FORMS}, and \
237 an exclusion (`…:-{{name}}`) takes one back out",
238 acts.iter()
239 .map(|(act, _)| cap(*act, name))
240 .collect::<Vec<_>>()
241 .join(", "),
242 acts.iter()
243 .map(|(_, public)| *public)
244 .collect::<Vec<_>>()
245 .join(", ")
246 )))
247}
248
249fn require(inv: &Invocation<'_>, act: Act, name: &str, doing: &str) -> Result<()> {
250 if may(inv, act, name) {
251 Ok(())
252 } else {
253 Err(Error::Denied(format!(
254 "{doing} needs `{}`, which this capability does not hold. {GRANT_FORMS}, and an \
255 exclusion (`…:-{{name}}`) takes one back out",
256 cap(act, name)
257 )))
258 }
259}
260
261fn found(head: Option<Head>, name: &str) -> Result<Head> {
262 head.ok_or_else(|| Error::NotFound(format!("no script is published at urn:script:{name}")))
263}
264
265#[derive(Clone, Copy, Debug, PartialEq, Eq)]
285enum Sight {
286 Visible,
288 Hidden,
291}
292
293fn ever_published(head: &Head, digest: &str) -> bool {
296 (head.version == digest && head.state == State::Published)
297 || head
298 .history
299 .iter()
300 .any(|event| event.action == "publish" && event.version == digest)
301}
302
303fn sight(inv: &Invocation<'_>, head: &Head, digest: &str) -> Sight {
309 if ever_published(head, digest) || inv.capability.is_root() {
310 return Sight::Visible;
311 }
312 let wrote_it = |author: &str| authority::is_identity(author) && inv.capability.acts_as(author);
313 let mine = (head.version == digest && wrote_it(&head.publisher))
314 || head
315 .history
316 .iter()
317 .any(|e| e.action == "draft" && e.version == digest && wrote_it(&e.principal));
318 if mine {
319 Sight::Visible
320 } else {
321 Sight::Hidden
322 }
323}
324
325fn absent(inv: &Invocation<'_>, name: &str) -> Error {
329 let mut message = format!("no script is published at urn:script:{name}");
330 if inv.capability.principal().is_none() && !inv.capability.is_root() {
331 message.push_str(
332 " (this request's capability names no principal (`urn:cap:principal:…`, minted \
333 by the host's door), so it cannot be shown to be a draft's author: a draft is \
334 visible to its author and root alone until it is published)",
335 );
336 }
337 Error::NotFound(message)
338}
339
340fn seen_head(inv: &Invocation<'_>, name: &str, gated: Option<Head>) -> Result<Head> {
343 let Some(head) = gated else {
344 return Err(absent(inv, name));
345 };
346 match sight(inv, &head, &head.version) {
347 Sight::Hidden => Err(absent(inv, name)),
348 Sight::Visible => Ok(head),
349 }
350}
351
352fn head_version(shared: &SpaceConfig, head: &Head) -> Result<Version> {
353 shared
354 .backend
355 .version(&head.name, &head.version)?
356 .ok_or_else(|| {
357 Error::Endpoint(format!(
358 "urn:script:{} points at version {}, which its backend does not hold",
359 head.name, head.version
360 ))
361 })
362}
363
364fn optional<'a>(inv: &'a Invocation<'_>, name: &str) -> Result<Option<&'a str>> {
370 match inv.inline_str(name) {
371 Ok(value) => Ok(Some(value)),
372 Err(Error::MissingArgument(_)) => Ok(None),
373 Err(other) => Err(other),
374 }
375}
376
377fn parse_bool(name: &str, text: &str) -> Result<bool> {
378 match text.trim() {
379 "true" => Ok(true),
380 "false" => Ok(false),
381 other => Err(Error::InvalidArgument {
382 name: name.to_string(),
383 detail: format!("`{other}` is not `true` or `false`"),
384 }),
385 }
386}
387
388fn wanted_face(inv: &Invocation<'_>) -> Result<&'static str> {
391 face_of(inv, &READ_FACES)
392}
393
394fn face_of(inv: &Invocation<'_>, faces: &[&'static str]) -> Result<&'static str> {
396 match optional(inv, "as")? {
397 None => Ok(faces[0]),
398 Some(asked) => {
399 let bare = asked.split(';').next().unwrap_or(asked).trim();
400 faces
401 .iter()
402 .find(|face| **face == bare)
403 .copied()
404 .ok_or_else(|| Error::InvalidArgument {
405 name: "as".to_string(),
406 detail: format!(
407 "`{asked}` is not a face this resource serves; one of {}",
408 faces.join(", ")
409 ),
410 })
411 }
412 }
413}
414
415fn plain(text: impl Into<String>) -> Representation {
416 Representation::new(
417 ReprType::new(PLAIN).with_param("charset", "utf-8"),
418 text.into().into_bytes(),
419 )
420}
421
422fn json<T: Serialize>(value: &T) -> Result<Representation> {
423 let mut bytes = serde_json::to_vec_pretty(value)
424 .map_err(|e| Error::Endpoint(format!("could not serialize a record: {e}")))?;
425 bytes.push(b'\n');
426 Ok(Representation::new(ReprType::new(JSON), bytes))
427}
428
429fn iri(text: &str) -> Result<Iri> {
430 Iri::parse(text).map_err(|e| Error::Endpoint(format!("`{text}` is not an IRI: {e}")))
431}
432
433fn inline(text: &str) -> ArgRef {
434 ArgRef::Inline(text.as_bytes().to_vec())
435}
436
437fn name_arg() -> ArgSpec {
438 ArgSpec::new("name")
439 .summary(
440 "The script's name: one segment of lowercase letters, digits, `-` and `_` \
441 (`eval`, `catalog`, `public`, `outcome` and `principal` are reserved).",
442 )
443 .class(XSD_STRING)
444 .binding()
445}
446
447fn as_arg() -> ArgSpec {
448 ArgSpec::new("as")
449 .summary(
450 "The face: `text/plain` (for people) or `application/json` (the versioned \
451 record, `\"schema\": 1`). Any other is refused, never substituted.",
452 )
453 .class(XSD_STRING)
454 .one_of(READ_FACES)
455 .default_value(PLAIN)
456 .optional()
457}
458
459fn record_as_arg() -> ArgSpec {
461 ArgSpec::new("as")
462 .summary(
463 "The face: `text/plain` (for people), `application/json` (the versioned record, \
464 `\"schema\": 1`) or `text/turtle` (the graph: PROV-O and the shared \
465 vocabulary, no blank nodes). Any other is refused, never substituted.",
466 )
467 .class(XSD_STRING)
468 .one_of(RECORD_FACES)
469 .default_value(PLAIN)
470 .optional()
471}
472
473fn turtle(graph: crate::graph::Graph) -> Result<Representation> {
474 let bytes = graph
475 .turtle()
476 .map_err(|e| Error::Endpoint(format!("could not write the graph face: {e}")))?;
477 Ok(Representation::new(
478 ReprType::new(TURTLE).with_param("charset", "utf-8"),
479 bytes,
480 ))
481}
482
483fn data_arg(arg: &str, summary: &str) -> ArgSpec {
484 ArgSpec::new(arg)
485 .summary(summary)
486 .class(XSD_STRING)
487 .optional()
488}
489
490#[derive(Serialize)]
496struct ScriptDocument<'a> {
497 iri: String,
498 #[serde(flatten)]
499 head: &'a Head,
500 #[serde(rename = "versionIri")]
501 version_iri: String,
502 language: Language,
503 requires: &'a BTreeSet<String>,
504 #[serde(skip_serializing_if = "Option::is_none")]
505 budget: Option<u64>,
506 source: &'a str,
507}
508
509struct ScriptEndpoint {
510 shared: Arc<SpaceConfig>,
511}
512
513#[async_trait]
514impl Endpoint for ScriptEndpoint {
515 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
516 let name = name::from_bindings(inv)?;
517 let shared = &self.shared;
518 match inv.request.verb {
519 Verb::Exists => {
520 let exists = match readable_head(inv, shared, &name)? {
521 None => false,
522 Some(head) => sight(inv, &head, &head.version) == Sight::Visible,
523 };
524 Ok(plain(if exists { "true\n" } else { "false\n" }).cacheable())
525 }
526 Verb::Source => {
527 let want = wanted_face(inv)?;
528 let head = seen_head(inv, &name, readable_head(inv, shared, &name)?)?;
529 let version = head_version(shared, &head)?;
530 let repr = if want == JSON {
531 json(&ScriptDocument {
532 iri: name::script_iri(&name),
533 head: &head,
534 version_iri: name::version_iri(&name, &head.version),
535 language: version.language,
536 requires: &version.requires,
537 budget: version.budget,
538 source: &version.source,
539 })?
540 } else {
541 plain(version.source)
542 };
543 Ok(repr.cacheable())
544 }
545 Verb::Sink => publish(inv, shared, &name).await,
546 Verb::Delete => retire(inv, shared, &name),
547 other => Err(unsupported("script", other)),
548 }
549 }
550
551 fn name(&self) -> &str {
552 "script"
553 }
554
555 fn describe(&self) -> Description {
556 Description::new("script")
557 .title("A script")
558 .summary(
559 "A script as a resource: fetch its source without running it, publish or \
560 replace it (every write is a new content-addressed version), or retire it.",
561 )
562 .verb(Verb::Meta)
563 .action(
564 ActionSpec::new(Verb::Source)
565 .summary(
566 "The script, WITHOUT running it: its source (`text/plain`), or its head \
567 record (`application/json`): state, public flag, version, what it \
568 declares and what its publisher held. Needs \
569 `urn:cap:script:read:{name}`, or `urn:cap:script:read:public` for a \
570 public published script.",
571 )
572 .input(name_arg())
573 .input(as_arg())
574 .output(PLAIN)
575 .output(JSON)
576 .requires(CAP_READ),
577 )
578 .action(
579 ActionSpec::new(Verb::Exists)
580 .summary("Whether the script exists (to a caller who may read it).")
581 .input(name_arg())
582 .output(PLAIN)
583 .requires(CAP_READ),
584 )
585 .action(
586 ActionSpec::new(Verb::Sink)
587 .summary(
588 "Publish or replace the script; answers the new version's IRI. The \
589 declared `requires` (plus the language's own capability) must all \
590 be held by the publisher, or the publish is refused: a script never \
591 runs with more than its publisher held. Needs \
592 `urn:cap:script:write:{name}`.",
593 )
594 .input(name_arg())
595 .input(
596 ArgSpec::new("content")
597 .summary("The source text.")
598 .class(XSD_STRING),
599 )
600 .input(
601 ArgSpec::new("language")
602 .summary(
603 "The script's language: `lisp`, `sparql` (a query or update, \
604 when the host mounts a SPARQL door), or `plan` (an \
605 ik:Process graph, when the host binds the plan doors).",
606 )
607 .class(XSD_STRING)
608 .one_of(Language::ALL.map(Language::as_str))
609 .default_value("lisp")
610 .optional(),
611 )
612 .input(
613 ArgSpec::new("requires")
614 .summary(
615 "Whitespace-separated `urn:cap:` grants the script needs to \
616 run. Lisp: the language's capability is added; exact grants \
617 only, no wildcards, no exclusions. SPARQL: DERIVED from the \
618 graphs the text names; a plan: DERIVED from its steps' \
619 contracts. For both, omit it: one that says anything else \
620 is refused, naming the derived set.",
621 )
622 .class(XSD_STRING)
623 .optional(),
624 )
625 .input(
626 ArgSpec::new("budget")
627 .summary(
628 "The store time budget the script's runs may have, in \
629 milliseconds (at most 120000). Refused unless the publisher \
630 holds `urn:cap:store:budget:<m>` with m at least this (root \
631 holds 120000). A run gets the least of this, what the \
632 publisher held, the host's ceiling and the runner's own \
633 budget grant; omitted, every run gets the store's base. Any \
634 language.",
635 )
636 .class(XSD_POSITIVE_INTEGER)
637 .optional(),
638 )
639 .input(
640 ArgSpec::new("public")
641 .summary(
642 "Whether holders of `urn:cap:script:run:public` (a host's \
643 anonymous principal) may run it and holders of \
644 `urn:cap:script:read:public` read it.",
645 )
646 .class(XSD_BOOLEAN)
647 .one_of(["true", "false"])
648 .default_value("false")
649 .optional(),
650 )
651 .input(
652 ArgSpec::new("state")
653 .summary(
654 "`published` (runnable) or `draft` (saved, not runnable, \
655 and visible only to its author until published). To \
656 retire, Delete.",
657 )
658 .class(XSD_STRING)
659 .one_of(["published", "draft"])
660 .default_value("published")
661 .optional(),
662 )
663 .input(
664 ArgSpec::new("if-version")
665 .summary(
666 "Write only if the head is still this version (`none`: only \
667 if the script does not exist yet); otherwise `Conflict`.",
668 )
669 .class(XSD_STRING)
670 .optional(),
671 )
672 .output(PLAIN)
673 .requires(CAP_WRITE),
674 )
675 .action(
676 ActionSpec::new(Verb::Delete)
677 .summary(
678 "Retire the script: it stays readable and its versions fetchable, but \
679 it never runs again until republished. Needs \
680 `urn:cap:script:delete:{name}`.",
681 )
682 .input(name_arg())
683 .output(PLAIN)
684 .requires(CAP_DELETE),
685 )
686 }
687}
688
689async fn publish(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Result<Representation> {
690 require(inv, Act::Write, name, "publishing a script")?;
691 let source = inv.inline_str("content")?;
692 let language = optional(inv, "language")?
693 .map(Language::parse)
694 .transpose()?
695 .unwrap_or(Language::Lisp);
696 let requires = match language {
697 Language::Lisp => {
698 let mut requires = optional(inv, "requires")?
699 .map(authority::parse_requires)
700 .transpose()?
701 .unwrap_or_default();
702 requires.insert(CAP_LISP.to_string());
703 requires
704 }
705 Language::Sparql => derived_requires(inv, shared, source)?,
706 Language::Plan => {
707 plan::check_bound(source)?;
711 plan::validate(inv, source).await?;
712 plan::analyze(source)?;
713 plan::derive(inv, source, optional(inv, "requires")?).await?
714 }
715 };
716 let budget = optional(inv, "budget")?
717 .map(authority::parse_budget)
718 .transpose()?;
719 let public = optional(inv, "public")?
720 .map(|v| parse_bool("public", v))
721 .transpose()?
722 .unwrap_or(false);
723 let state = match optional(inv, "state")?.map(str::trim) {
724 None | Some("published") => State::Published,
725 Some("draft") => State::Draft,
726 Some(other) => {
727 return Err(Error::InvalidArgument {
728 name: "state".to_string(),
729 detail: format!(
730 "`{other}` is not `published` or `draft`; to retire a script, Delete it"
731 ),
732 })
733 }
734 };
735 let grant = authority::grant_at_publish(inv.capability, &requires)?;
738 let granted_budget = budget
741 .map(|millis| authority::budget_at_publish(inv.capability, millis))
742 .transpose()?;
743
744 let version = Version {
745 language,
746 requires,
747 budget,
748 source: source.to_string(),
749 };
750 let digest = version.digest();
751 let current = shared.backend.head(name)?;
752 if let Some(expected) = optional(inv, "if-version")? {
753 let at = current.as_ref().map_or("none", |h| h.version.as_str());
754 if at != expected.trim() {
755 return Err(Error::Conflict(format!(
756 "urn:script:{name} is at {at}, not {}; read it again and retry",
757 expected.trim()
758 )));
759 }
760 }
761 shared.backend.put_version(name, &version)?;
762
763 let principal = principal_of(inv.capability);
764 let at = inv.now().map(|t| t.as_millis());
765 let mut history = current
766 .as_ref()
767 .map(|h| h.history.clone())
768 .unwrap_or_default();
769 history.push(Event {
770 action: if state == State::Draft {
771 "draft"
772 } else {
773 "publish"
774 }
775 .to_string(),
776 version: digest.clone(),
777 state,
778 public,
779 principal: principal.clone(),
780 at,
781 });
782 let head = Head {
783 schema: SCHEMA,
784 name: name.to_string(),
785 version: digest.clone(),
786 state,
787 public,
788 granted: grant.granted,
789 exclusions: grant.exclusions,
790 granted_budget,
791 publisher: principal,
792 updated: at,
793 history,
794 };
795 shared.backend.swap_head(name, current.as_ref(), &head)?;
796 shared.changed(name);
797 Ok(plain(format!("{}\n", name::version_iri(name, &digest))))
798}
799
800fn derived_requires(
804 inv: &Invocation<'_>,
805 shared: &SpaceConfig,
806 source: &str,
807) -> Result<BTreeSet<String>> {
808 let door = door(shared).map_err(|_| Error::InvalidArgument {
809 name: "language".to_string(),
810 detail: "this host takes no SPARQL scripts: it mounts no SPARQL door".to_string(),
811 })?;
812 let derived = sparql::analyze(source, door)?.requires();
813 if let Some(said) = optional(inv, "requires")? {
814 let said: BTreeSet<String> = said.split_whitespace().map(str::to_string).collect();
815 if said != derived {
816 return Err(Error::InvalidArgument {
817 name: "requires".to_string(),
818 detail: format!(
819 "a SPARQL script's authority is derived from the graphs its text names, \
820 not declared, and this one derives {}; the `requires` given says {}. \
821 Omit `requires`",
822 listed(&derived),
823 listed(&said)
824 ),
825 });
826 }
827 }
828 Ok(derived)
829}
830
831fn listed(scopes: &BTreeSet<String>) -> String {
832 if scopes.is_empty() {
833 "nothing".to_string()
834 } else {
835 scopes.iter().cloned().collect::<Vec<_>>().join(" ")
836 }
837}
838
839fn door(shared: &SpaceConfig) -> Result<&SparqlDoor> {
840 shared.sparql.as_ref().ok_or_else(|| {
841 Error::Endpoint(
842 "this host mounts no SPARQL door (`SpaceConfig::sparql`), so it cannot run a \
843 SPARQL script"
844 .to_string(),
845 )
846 })
847}
848
849fn retire(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Result<Representation> {
850 require(inv, Act::Delete, name, "retiring a script")?;
851 let current = found(shared.backend.head(name)?, name)?;
852 let iri = name::script_iri(name);
853 if current.state == State::Retired {
854 return Ok(plain(format!("{iri} was already retired\n")));
855 }
856 let at = inv.now().map(|t| t.as_millis());
857 let mut head = current.clone();
858 head.state = State::Retired;
859 head.updated = at;
860 head.history.push(Event {
861 action: "retire".to_string(),
862 version: current.version.clone(),
863 state: State::Retired,
864 public: current.public,
865 principal: principal_of(inv.capability),
866 at,
867 });
868 shared.backend.swap_head(name, Some(¤t), &head)?;
869 shared.changed(name);
870 Ok(plain(format!("retired {iri}\n")))
871}
872
873struct VersionEndpoint {
878 shared: Arc<SpaceConfig>,
879}
880
881#[async_trait]
882impl Endpoint for VersionEndpoint {
883 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
884 let name = name::from_bindings(inv)?;
885 let digest = inv
886 .bindings
887 .get("digest")
888 .ok_or_else(|| Error::Endpoint("no `digest` captured".to_string()))?
889 .to_string();
890 inv.depends_on(name::script_iri(&name));
904 let head = readable_head(inv, &self.shared, &name)?;
905 let version = self.shared.backend.version(&name, &digest);
906 let seen = match (&version, &head) {
910 (Ok(Some(_)), Some(head)) => sight(inv, head, &digest),
911 (Ok(Some(_)), None) if !inv.capability.is_root() => Sight::Hidden,
912 _ => Sight::Visible,
913 };
914 let no_such = || Error::NotFound(format!("urn:script:{name} has no version {digest}"));
915 match inv.request.verb {
916 Verb::Exists => {
917 let exists = match version {
918 Ok(v) => v.is_some() && seen != Sight::Hidden,
919 Err(Error::InvalidArgument { .. }) => false,
920 Err(other) => return Err(other),
921 };
922 Ok(plain(if exists { "true\n" } else { "false\n" }).cacheable())
923 }
924 Verb::Source => {
925 let want = wanted_face(inv)?;
926 let version = version?.ok_or_else(no_such)?;
927 if seen == Sight::Hidden {
928 return Err(no_such());
929 }
930 let repr = if want == JSON {
931 json(&version.document(&name))?
932 } else {
933 plain(version.source)
934 };
935 Ok(repr.cacheable())
936 }
937 other => Err(unsupported("script-version", other)),
938 }
939 }
940
941 fn name(&self) -> &str {
942 "script-version"
943 }
944
945 fn describe(&self) -> Description {
946 let digest = || {
947 ArgSpec::new("digest")
948 .summary("The version's digest: `sha256:` and 64 hex digits over its content.")
949 .class(XSD_STRING)
950 .binding()
951 };
952 Description::new("script-version")
953 .title("One version of a script")
954 .summary(
955 "An immutable version, named by the sha256 of its content (language, declared \
956 capability, source). Republishing moves the head; every version stays \
957 fetchable here.",
958 )
959 .verb(Verb::Meta)
960 .action(
961 ActionSpec::new(Verb::Source)
962 .summary(
963 "The version's source (`text/plain`) or its record (`application/json`).",
964 )
965 .input(name_arg())
966 .input(digest())
967 .input(as_arg())
968 .output(PLAIN)
969 .output(JSON)
970 .requires(CAP_READ),
971 )
972 .action(
973 ActionSpec::new(Verb::Exists)
974 .summary("Whether the script has this version.")
975 .input(name_arg())
976 .input(digest())
977 .output(PLAIN)
978 .requires(CAP_READ),
979 )
980 }
981}
982
983#[derive(Clone, Debug, Serialize, Deserialize)]
993pub struct Prepared {
994 pub schema: u32,
996 pub name: String,
998 pub iri: String,
1000 pub version: String,
1002 pub language: Language,
1004 pub evaluator: String,
1006 pub program: String,
1008 pub requires: BTreeSet<String>,
1010 pub granted: BTreeSet<String>,
1012 pub exclusions: BTreeSet<String>,
1014 #[serde(default, skip_serializing_if = "Option::is_none")]
1016 pub budget: Option<u64>,
1017 #[serde(default, skip_serializing_if = "Option::is_none")]
1019 pub granted_budget: Option<u64>,
1020 pub public: bool,
1022 pub state: State,
1024 #[serde(default, skip_serializing_if = "Option::is_none")]
1026 pub sparql: Option<Analysis>,
1027 #[serde(default, skip_serializing_if = "Option::is_none")]
1029 pub plan: Option<plan::Analysis>,
1030}
1031
1032struct CompiledEndpoint {
1033 shared: Arc<SpaceConfig>,
1034}
1035
1036#[async_trait]
1037impl Endpoint for CompiledEndpoint {
1038 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1039 if inv.request.verb != Verb::Source {
1040 return Err(unsupported("script-compiled", inv.request.verb));
1041 }
1042 let name = name::from_bindings(inv)?;
1043 inv.depends_on(name::script_iri(&name));
1051 let gated = gated_head(
1054 inv,
1055 &self.shared,
1056 &name,
1057 &[(Act::Read, CAP_READ_PUBLIC), (Act::Run, CAP_RUN_PUBLIC)],
1058 )?;
1059 let head = seen_head(inv, &name, gated)?;
1060 let version = head_version(&self.shared, &head)?;
1061 let (evaluator, analysis, plan_analysis) = match version.language {
1062 Language::Lisp => (LISP_EVAL.to_string(), None, None),
1063 Language::Sparql => {
1064 let door = door(&self.shared)?;
1065 let analysis = sparql::analyze(&version.source, door)?;
1066 (door.query_iri(analysis.form), Some(analysis), None)
1067 }
1068 Language::Plan => (
1069 plan::EVAL.to_string(),
1070 None,
1071 Some(plan::analyze(&version.source)?),
1072 ),
1073 };
1074 let prepared = Prepared {
1075 schema: SCHEMA,
1076 iri: name::part_iri(&name, "compiled"),
1077 version: head.version.clone(),
1078 language: version.language,
1079 evaluator,
1080 program: version.source,
1081 requires: version.requires,
1082 granted: head.granted,
1083 exclusions: head.exclusions,
1084 budget: version.budget,
1085 granted_budget: head.granted_budget,
1086 public: head.public,
1087 state: head.state,
1088 sparql: analysis,
1089 plan: plan_analysis,
1090 name: name.clone(),
1091 };
1092 Ok(json(&prepared)?.cacheable())
1093 }
1094
1095 fn name(&self) -> &str {
1096 "script-compiled"
1097 }
1098
1099 fn describe(&self) -> Description {
1100 Description::new("script-compiled")
1101 .title("A script's compiled form")
1102 .summary(
1103 "The head version prepared for its evaluator, with the authority it runs \
1104 under (and, for SPARQL, its form, parameters and graphs; for a plan, its \
1105 steps' verbs and its parameters): cached, and cut \
1106 whenever the script is republished or retired. Readable by a holder of the \
1107 script's read OR run grant (a run reads its program here).",
1108 )
1109 .verb(Verb::Meta)
1110 .action(
1111 ActionSpec::new(Verb::Source)
1112 .summary("The prepared form, as JSON.")
1113 .input(name_arg())
1114 .output(JSON)
1115 .requires(CAP_ANY),
1116 )
1117 }
1118}
1119
1120const LISP_EVAL: &str = "urn:lisp:eval";
1125
1126async fn prepare_run(
1129 inv: &Invocation<'_>,
1130 shared: &SpaceConfig,
1131 name: &str,
1132) -> Result<(Prepared, BTreeSet<String>, Ceiling)> {
1133 let compiled = inv
1136 .issue(Request::new(
1137 Verb::Source,
1138 iri(&name::part_iri(name, "compiled"))?,
1139 ))
1140 .await?;
1141 let prepared: Prepared = serde_json::from_slice(&compiled.bytes).map_err(|e| {
1142 Error::Endpoint(format!(
1143 "urn:script:{name}:compiled did not answer a prepared form: {e}"
1144 ))
1145 })?;
1146 if prepared.name != name {
1147 return Err(Error::Endpoint(format!(
1148 "urn:script:{name}:compiled answered for `{}`",
1149 prepared.name
1150 )));
1151 }
1152 let public = prepared.public && prepared.state == State::Published;
1153 let may_run =
1154 may(inv, Act::Run, name) || (public && may_public(inv, Act::Run, CAP_RUN_PUBLIC, name));
1155 let run_grant = || {
1156 format!(
1157 "`{}` or a namespace grant covering it{}",
1158 cap_run(name),
1159 if prepared.public {
1160 format!(" (or `{CAP_RUN_PUBLIC}`, since it is public)")
1161 } else {
1162 " — it is not public".to_string()
1163 }
1164 )
1165 };
1166 let also = || {
1171 if may_run {
1172 String::new()
1173 } else {
1174 format!("; running it will also need {}", run_grant())
1175 }
1176 };
1177 match prepared.state {
1178 State::Published => {}
1179 State::Draft => {
1180 return Err(Error::Conflict(format!(
1181 "urn:script:{name} is a draft; publish it (`state=published`) to run it{}{}",
1182 ad_hoc(prepared.language),
1183 also()
1184 )))
1185 }
1186 State::Retired => {
1187 return Err(Error::Conflict(format!(
1188 "urn:script:{name} is retired; publish it again to run it{}",
1189 also()
1190 )))
1191 }
1192 }
1193 if !may_run {
1194 return Err(Error::Denied(format!(
1195 "running urn:script:{name} needs {}. {GRANT_FORMS}, and an exclusion \
1196 (`…:-{{name}}`) takes one back out",
1197 run_grant()
1198 )));
1199 }
1200 let ceiling = (shared.ceiling)(name);
1201 let keep = authority::effective(
1202 &prepared.requires,
1203 &prepared.granted,
1204 &prepared.exclusions,
1205 &ceiling,
1206 );
1207 Ok((prepared, keep, ceiling))
1208}
1209
1210fn ad_hoc(language: Language) -> String {
1213 match language {
1214 Language::Lisp => format!(", or try the code at {EVAL_IRI}"),
1215 Language::Plan => format!(
1216 ", or run the plan itself at {} (`in` = the plan), under your own capability",
1217 plan::EVAL
1218 ),
1219 Language::Sparql => String::new(),
1220 }
1221}
1222
1223fn lisp_request(program: &str, data: Option<&str>) -> Result<Request> {
1225 let mut request = Request::new(Verb::Source, iri(LISP_EVAL)?).with_arg("in", inline(program));
1226 if let Some(data) = data {
1227 request = request.with_arg("data", inline(data));
1228 }
1229 Ok(request)
1230}
1231
1232const DATA_SUMMARY: &str = "Optional data the script reads with `(input)` — data, never code.";
1234
1235#[derive(Clone, Copy, PartialEq, Eq)]
1237enum Door {
1238 Result,
1240 Runs,
1242}
1243
1244struct Planned {
1247 request: Request,
1248 keep: BTreeSet<String>,
1249 note: Option<String>,
1252}
1253
1254fn explain(note: Option<&str>, error: Error) -> Error {
1256 match (note, error) {
1257 (Some(note), Error::Denied(message)) => Error::Denied(format!("{message}. {note}")),
1258 (_, error) => error,
1259 }
1260}
1261
1262async fn prepare_request(
1264 inv: &Invocation<'_>,
1265 shared: &SpaceConfig,
1266 prepared: &Prepared,
1267 keep: BTreeSet<String>,
1268 ceiling: &Ceiling,
1269 through: Door,
1270) -> Result<Planned> {
1271 let planned = match prepared.language {
1272 Language::Lisp => {
1273 let data = optional(
1274 inv,
1275 match through {
1276 Door::Result => "data",
1277 Door::Runs => "content",
1278 },
1279 )?;
1280 Planned {
1281 request: lisp_request(&prepared.program, data)?,
1282 keep,
1283 note: None,
1284 }
1285 }
1286 Language::Sparql => plan_sparql(inv, shared, prepared, keep, ceiling, through).await?,
1287 Language::Plan => plan_eval_request(inv, prepared, keep, ceiling, through)?,
1288 };
1289 Ok(with_budget(inv, prepared, ceiling, planned))
1290}
1291
1292fn with_budget(
1307 inv: &Invocation<'_>,
1308 prepared: &Prepared,
1309 ceiling: &Ceiling,
1310 mut planned: Planned,
1311) -> Planned {
1312 let Some(bound) = authority::budget_bound(prepared.budget, prepared.granted_budget, ceiling)
1313 else {
1314 return planned;
1315 };
1316 let runner = inv.capability;
1317 let grant = match prepared.language {
1318 Language::Sparql => {
1319 planned.request = planned
1320 .request
1321 .with_arg("budget", inline(&bound.to_string()));
1322 authority::budget_grant_lowered(runner, bound)
1323 }
1324 Language::Lisp | Language::Plan => {
1325 authority::effective_budget(prepared.budget, prepared.granted_budget, ceiling, runner)
1326 .and_then(|millis| authority::budget_grant(runner, millis))
1327 }
1328 };
1329 planned.keep.extend(grant);
1330 planned
1331}
1332
1333fn plan_eval_request(
1340 inv: &Invocation<'_>,
1341 prepared: &Prepared,
1342 keep: BTreeSet<String>,
1343 ceiling: &Ceiling,
1344 through: Door,
1345) -> Result<Planned> {
1346 let name = &prepared.name;
1347 let analysis = prepared.plan.as_ref().ok_or_else(|| {
1348 Error::Endpoint(format!(
1349 "urn:script:{name}:compiled carries no plan analysis"
1350 ))
1351 })?;
1352 if through == Door::Result && analysis.mutates() {
1353 let writers: Vec<String> = analysis
1354 .steps
1355 .iter()
1356 .filter(|s| s.mutates())
1357 .map(|s| format!("<{}> ({} <{}>)", s.iri, s.verb, s.resolves))
1358 .collect();
1359 return Err(Error::InvalidArgument {
1360 name: "name".to_string(),
1361 detail: format!(
1362 "urn:script:{name} is a plan that writes ({}), so it is not a read: run it with \
1363 a Sink to urn:script:{name}:runs",
1364 writers.join(", ")
1365 ),
1366 });
1367 }
1368 let declared: Vec<(&str, bool)> = analysis
1369 .parameters
1370 .iter()
1371 .map(|p| (p.name.as_str(), p.must_be_given()))
1372 .collect();
1373 let values = given_parameters(inv, name, &declared, through)?;
1374 let face = optional(inv, "as")?;
1375 let keep = plan::expand_families(keep, inv.capability, ceiling);
1376 let markers = if inv.capability.is_root() {
1381 plan::markers(&keep)
1382 } else {
1383 Vec::new()
1384 };
1385 let note = (!markers.is_empty()).then(|| {
1386 format!(
1387 "urn:script:{name} ran holding {} only as a family: a family held is not a grant, \
1388 so a step whose module checks an exact token under it is refused. Neither the \
1389 runner nor the host's ceiling for this script names a member (a step's contract \
1390 declares the family, never the member, and root holds no list to pick one from). \
1391 Run it under a capability that holds the exact grants, or have the host's \
1392 ceiling for urn:script:{name} name them",
1393 markers
1394 .iter()
1395 .map(|m| format!("`{m}`"))
1396 .collect::<Vec<_>>()
1397 .join(", ")
1398 )
1399 });
1400 Ok(Planned {
1401 request: plan::eval_request(&prepared.program, &values, face),
1402 keep,
1403 note,
1404 })
1405}
1406
1407async fn plan_sparql(
1417 inv: &Invocation<'_>,
1418 shared: &SpaceConfig,
1419 prepared: &Prepared,
1420 mut keep: BTreeSet<String>,
1421 ceiling: &Ceiling,
1422 through: Door,
1423) -> Result<Planned> {
1424 let name = &prepared.name;
1425 let door = door(shared)?;
1426 let analysis = prepared.sparql.as_ref().ok_or_else(|| {
1427 Error::Endpoint(format!(
1428 "urn:script:{name}:compiled carries no SPARQL analysis"
1429 ))
1430 })?;
1431 if through == Door::Result && !analysis.form.is_read() {
1432 return Err(Error::InvalidArgument {
1433 name: "name".to_string(),
1434 detail: format!(
1435 "urn:script:{name} is a SPARQL UPDATE, which is a write: run it with a Sink to \
1436 urn:script:{name}:runs"
1437 ),
1438 });
1439 }
1440 let values = parameter_values(inv, name, &analysis.parameters, through)?;
1441 let face = sparql_face(inv, analysis.form)?;
1442 let bound = sparql::bind(&prepared.program, door, &values)?;
1443
1444 let runner = inv.capability;
1445 let root_marker = keep.remove(CAP_READ_GRAPH);
1448 let family = prepared.requires.contains(CAP_READ_GRAPH);
1449 let by_family = |scope: &str, keep: &BTreeSet<String>| {
1450 family
1451 && runner.allows(scope)
1452 && if root_marker {
1453 ceiling.allows(scope)
1454 } else {
1455 keep.contains(scope)
1456 }
1457 };
1458
1459 if bound.form == Form::Update {
1460 let graph = bound.graphs.iter().next().cloned().ok_or_else(|| {
1463 Error::Endpoint(format!("urn:script:{name} names no graph to update"))
1464 })?;
1465 for scope in &prepared.requires {
1466 may_use(name, &graph, scope, &keep, runner)?;
1467 }
1468 let request = Request::new(Verb::Sink, iri(&door.query_iri(Form::Update))?)
1469 .with_arg("content", inline(&bound.text))
1470 .with_arg("graph", inline(&graph));
1471 return Ok(Planned {
1472 request,
1473 keep,
1474 note: None,
1475 });
1476 }
1477
1478 let mut extra = BTreeSet::new();
1479 for graph in &bound.graphs {
1480 let scope = sparql::cap_read_graph(graph);
1481 if !keep.contains(&scope) && by_family(&scope, &keep) {
1482 extra.insert(scope);
1483 }
1484 }
1485 let mut dataset = bound.graphs.clone();
1486 if bound.any_graph {
1487 if !family {
1488 return Err(Error::Endpoint(format!(
1489 "urn:script:{name} reads a variable graph but its record derives no \
1490 `{CAP_READ_GRAPH}`; republish it"
1491 )));
1492 }
1493 let under = |scopes: &mut dyn Iterator<Item = &String>| -> BTreeSet<String> {
1494 scopes
1495 .filter_map(|s| s.strip_prefix(sparql::READ_GRAPH_PREFIX))
1496 .filter(|g| !g.starts_with('-') && !g.ends_with('*'))
1497 .map(str::to_string)
1498 .collect()
1499 };
1500 let candidates = if !root_marker {
1501 under(&mut keep.iter())
1502 } else if let Some(held) = runner.scopes() {
1503 under(&mut held.iter())
1504 } else {
1505 let listing = inv
1508 .issue_attenuated(
1509 Request::new(Verb::Source, iri(&door.graphs_iri())?),
1510 [sparql::CAP_STORE_READ.to_string()],
1511 )
1512 .await?;
1513 String::from_utf8_lossy(&listing.bytes)
1514 .lines()
1515 .map(str::trim)
1516 .filter(|l| !l.is_empty())
1517 .map(str::to_string)
1518 .collect()
1519 };
1520 for graph in candidates {
1521 let scope = sparql::cap_read_graph(&graph);
1522 if keep.contains(&scope) && runner.allows(&scope) || by_family(&scope, &keep) {
1523 extra.insert(scope);
1524 dataset.insert(graph);
1525 }
1526 }
1527 }
1528 if dataset.is_empty() {
1529 return Err(Error::Denied(format!(
1530 "urn:script:{name} reads the caller's readable graphs, and this run may read none: \
1531 it needs `{}` grants the publisher held, the host allows and the runner holds",
1532 sparql::READ_GRAPH_PREFIX
1533 )));
1534 }
1535 keep.extend(extra);
1536 for graph in &dataset {
1537 may_use(name, graph, &sparql::cap_read_graph(graph), &keep, runner)?;
1538 }
1539 let graphs = dataset.into_iter().collect::<Vec<_>>().join(" ");
1540 let request = Request::new(Verb::Source, iri(&door.query_iri(bound.form))?)
1541 .with_arg("query", inline(&bound.text))
1542 .with_arg("graph", inline(&graphs))
1543 .with_arg("as", inline(face));
1544 Ok(Planned {
1545 request,
1546 keep,
1547 note: None,
1548 })
1549}
1550
1551fn may_use(
1555 name: &str,
1556 graph: &str,
1557 scope: &str,
1558 keep: &BTreeSet<String>,
1559 runner: &ikigai_core::Capability,
1560) -> Result<()> {
1561 if !runner.allows(scope) {
1562 return Err(Error::Denied(format!(
1563 "running urn:script:{name} reads or writes <{graph}>, which needs `{scope}`, and the \
1564 runner does not hold it"
1565 )));
1566 }
1567 if !keep.contains(scope) {
1568 return Err(Error::Denied(format!(
1569 "running urn:script:{name} reads or writes <{graph}>, which needs `{scope}`, and \
1570 this script may not have it: its publisher did not hold it, or the host's ceiling \
1571 for it does not allow it"
1572 )));
1573 }
1574 Ok(())
1575}
1576
1577fn sparql_face(inv: &Invocation<'_>, form: Form) -> Result<&'static str> {
1580 let faces = form.faces();
1581 match optional(inv, "as")? {
1582 None => Ok(faces[0]),
1583 Some(asked) => {
1584 let bare = asked.split(';').next().unwrap_or(asked).trim();
1585 faces
1586 .iter()
1587 .find(|f| **f == bare)
1588 .copied()
1589 .ok_or_else(|| Error::InvalidArgument {
1590 name: "as".to_string(),
1591 detail: format!(
1592 "`{asked}` is not a face a SPARQL {} answers in; one of {}",
1593 form.as_str().to_uppercase(),
1594 faces.join(", ")
1595 ),
1596 })
1597 }
1598 }
1599}
1600
1601fn parameter_values(
1606 inv: &Invocation<'_>,
1607 name: &str,
1608 parameters: &[Parameter],
1609 through: Door,
1610) -> Result<BTreeMap<String, Value>> {
1611 let declared: Vec<(&str, bool)> = parameters
1612 .iter()
1613 .map(|p| (p.name.as_str(), p.required && p.default.is_none()))
1614 .collect();
1615 let mut given = given_parameters(inv, name, &declared, through)?;
1616 let mut values = BTreeMap::new();
1617 for parameter in parameters {
1618 if let Some(lexical) = given
1619 .remove(¶meter.name)
1620 .or_else(|| parameter.default.clone())
1621 {
1622 values.insert(parameter.name.clone(), parameter.value(&lexical)?);
1623 }
1624 }
1625 Ok(values)
1626}
1627
1628pub const TRANSPORT_ARGUMENTS: [&str; 4] = ["received", "client", "principal", "content-type"];
1654
1655fn given_parameters(
1670 inv: &Invocation<'_>,
1671 name: &str,
1672 declared: &[(&str, bool)],
1673 through: Door,
1674) -> Result<BTreeMap<String, String>> {
1675 let listed = || {
1676 if declared.is_empty() {
1677 "it declares none".to_string()
1678 } else {
1679 format!(
1680 "its parameters are {}",
1681 declared
1682 .iter()
1683 .map(|(p, _)| *p)
1684 .collect::<Vec<_>>()
1685 .join(", ")
1686 )
1687 }
1688 };
1689 for arg in inv.request.args.keys() {
1690 let known = arg == "as"
1691 || TRANSPORT_ARGUMENTS.contains(&arg.as_str())
1692 || (through == Door::Runs && arg == "content")
1693 || declared.iter().any(|(p, _)| p == arg);
1694 if !known {
1695 return Err(Error::InvalidArgument {
1696 name: arg.clone(),
1697 detail: format!("is not a parameter of urn:script:{name}; {}", listed()),
1698 });
1699 }
1700 }
1701 let mut piped: BTreeMap<String, String> = BTreeMap::new();
1702 if through == Door::Runs {
1703 if let Some(content) = optional(inv, "content")?.filter(|c| !c.trim().is_empty()) {
1705 let object: serde_json::Map<String, serde_json::Value> = serde_json::from_str(content)
1706 .map_err(|e| Error::InvalidArgument {
1707 name: "content".to_string(),
1708 detail: format!(
1709 "a script's piped content is its parameters as one JSON object ({e})"
1710 ),
1711 })?;
1712 for (key, value) in object {
1713 let lexical = match value {
1714 serde_json::Value::String(s) => s,
1715 serde_json::Value::Number(n) => n.to_string(),
1716 serde_json::Value::Bool(b) => b.to_string(),
1717 other => {
1718 return Err(Error::InvalidArgument {
1719 name: "content".to_string(),
1720 detail: format!(
1721 "`{key}` is {other}; a parameter's value is a string, a number \
1722 or a boolean"
1723 ),
1724 })
1725 }
1726 };
1727 piped.insert(key, lexical);
1728 }
1729 }
1730 }
1731 let mut given = BTreeMap::new();
1732 for (parameter, must) in declared {
1733 let named = optional(inv, parameter)?;
1734 let lexical = match (named, piped.remove(*parameter)) {
1735 (Some(_), Some(_)) => {
1736 return Err(Error::InvalidArgument {
1737 name: parameter.to_string(),
1738 detail: "is given both by name and in the piped content".to_string(),
1739 })
1740 }
1741 (Some(value), None) => Some(value.to_string()),
1742 (None, Some(value)) => Some(value),
1743 (None, None) => None,
1744 };
1745 match lexical {
1746 Some(lexical) => {
1747 given.insert(parameter.to_string(), lexical);
1748 }
1749 None if *must => return Err(Error::MissingArgument(parameter.to_string())),
1750 None => {}
1751 }
1752 }
1753 if let Some(stray) = piped.keys().next() {
1754 return Err(Error::InvalidArgument {
1755 name: "content".to_string(),
1756 detail: format!(
1757 "`{stray}` is not a parameter of urn:script:{name}; {}",
1758 listed()
1759 ),
1760 });
1761 }
1762 Ok(given)
1763}
1764
1765struct ResultEndpoint {
1766 shared: Arc<SpaceConfig>,
1767}
1768
1769#[async_trait]
1770impl Endpoint for ResultEndpoint {
1771 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1772 if inv.request.verb != Verb::Source {
1773 return Err(unsupported("script-result", inv.request.verb));
1774 }
1775 let name = name::from_bindings(inv)?;
1776 let (prepared, keep, ceiling) = prepare_run(inv, &self.shared, &name).await?;
1777 let planned =
1778 prepare_request(inv, &self.shared, &prepared, keep, &ceiling, Door::Result).await?;
1779 let Planned {
1782 request,
1783 keep,
1784 note,
1785 } = planned;
1786 let answer = inv
1787 .issue_attenuated(request, keep)
1788 .await
1789 .map_err(|e| explain(note.as_deref(), e))?;
1790 Ok(Representation::new(answer.repr_type, answer.bytes)
1796 .cacheable()
1797 .depends_on(name::authority_thread(&name)))
1798 }
1799
1800 fn name(&self) -> &str {
1801 "script-result"
1802 }
1803
1804 fn describe(&self) -> Description {
1805 Description::new("script-result")
1806 .title("A script's result")
1807 .summary(
1808 "Run the script as a READ and answer its value. It runs under the runner's \
1809 capability narrowed to what the script declares, its publisher held and \
1810 the host allows it — never more. Cacheable exactly as far as the script's \
1811 own sub-requests are (a Lisp program opts in with `(cacheable …)`), and \
1812 recomputed when the script is republished. No run record: a cached answer \
1813 runs nothing; record a run with `…:runs`. A published SPARQL script or \
1814 plan is its own entry, with its parameters as arguments; a published Lisp \
1815 script's door also requires `urn:cap:lisp`. A run of a script that is not \
1816 published is refused for its state (to a caller who may see it) or as \
1817 absent.",
1818 )
1819 .verb(Verb::Meta)
1820 .action(
1821 ActionSpec::new(Verb::Source)
1822 .summary("The script's answer.")
1823 .input(name_arg())
1824 .input(data_arg("data", DATA_SUMMARY))
1825 .output(PLAIN)
1826 .requires(CAP_RUN),
1827 )
1828 }
1829}
1830
1831struct RunsEndpoint {
1832 shared: Arc<SpaceConfig>,
1833}
1834
1835#[async_trait]
1836impl Endpoint for RunsEndpoint {
1837 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1838 if inv.request.verb != Verb::Sink {
1839 return Err(unsupported("script-runs", inv.request.verb));
1840 }
1841 let name = name::from_bindings(inv)?;
1842 let shared = &self.shared;
1843 let (prepared, keep, ceiling) = prepare_run(inv, shared, &name).await?;
1844 let planned = prepare_request(inv, shared, &prepared, keep, &ceiling, Door::Runs).await?;
1847 let runs_as = inv.capability.attenuate(planned.keep.iter().cloned());
1848 let mut run = Run {
1849 schema: SCHEMA,
1850 name: name.clone(),
1851 id: 0,
1852 version: prepared.version.clone(),
1853 principal: principal_of(inv.capability),
1854 capability: runs_as.scopes().cloned().unwrap_or_default(),
1855 started: inv.now().map(|t| t.as_millis()),
1856 ended: None,
1857 outcome: Outcome::Running,
1858 result: None,
1859 truncated: false,
1860 trace_span: inv.trace_span(),
1861 };
1862 run.id = shared.backend.start_run(&name, &run)?;
1863 let Planned {
1864 request,
1865 keep,
1866 note,
1867 } = planned;
1868 let answer = inv
1869 .issue_attenuated(request, keep)
1870 .await
1871 .map_err(|e| explain(note.as_deref(), e));
1872 run.ended = inv.now().map(|t| t.as_millis());
1873 match answer {
1874 Ok(repr) => {
1875 let (text, truncated) = model::truncate(
1876 String::from_utf8_lossy(&repr.bytes).into_owned(),
1877 MAX_RECORDED_RESULT,
1878 );
1879 run.outcome = Outcome::Ok;
1880 run.result = Some(text);
1881 run.truncated = truncated;
1882 shared.backend.finish_run(&name, &run)?;
1883 Ok(plain(format!("{}\n", run.iri())))
1884 }
1885 Err(error) => {
1886 run.outcome = Outcome::Failed {
1887 kind: kind(&error).to_string(),
1888 message: error.to_string(),
1889 };
1890 shared.backend.finish_run(&name, &run)?;
1891 Err(annotate(error, &run.iri()))
1892 }
1893 }
1894 }
1895
1896 fn name(&self) -> &str {
1897 "script-runs"
1898 }
1899
1900 fn describe(&self) -> Description {
1901 Description::new("script-runs")
1902 .title("A script's runs")
1903 .summary(
1904 "Run the script for its EFFECTS and record the run: who (the principal \
1905 the capability names), which version, under exactly what capability, when, and the \
1906 outcome. Answers the run's IRI; a run that fails is recorded too, and its \
1907 error names the record. A published SPARQL script or plan is its own \
1908 entry, with its parameters as arguments; a published Lisp script's door also \
1909 requires `urn:cap:lisp`. A script that is not published is refused before \
1910 any run is recorded.",
1911 )
1912 .verb(Verb::Meta)
1913 .action(
1914 ActionSpec::new(Verb::Sink)
1915 .summary("Run the script and record the run.")
1916 .input(name_arg())
1917 .input(data_arg("content", DATA_SUMMARY))
1918 .output(PLAIN)
1919 .requires(CAP_RUN),
1920 )
1921 }
1922}
1923
1924#[derive(Clone)]
1931struct PerScript {
1932 result: Arc<dyn Endpoint>,
1933 runs: Arc<dyn Endpoint>,
1934 reads: bool,
1936 listed: bool,
1941}
1942
1943#[derive(Clone)]
1945enum Contract {
1946 Own(PerScript),
1949 Lisp,
1951 Template,
1954}
1955
1956#[derive(Clone)]
1980struct LispDoors {
1981 result: Arc<dyn Endpoint>,
1982 runs: Arc<dyn Endpoint>,
1983}
1984
1985impl LispDoors {
1986 fn new(shared: &Arc<SpaceConfig>) -> LispDoors {
1988 let door = |inner: Arc<dyn Endpoint>| -> Arc<dyn Endpoint> {
1989 let description = needs_lisp(inner.describe());
1990 Arc::new(Described {
1991 id: description.id.clone(),
1992 description,
1993 inner,
1994 })
1995 };
1996 LispDoors {
1997 result: door(Arc::new(ResultEndpoint {
1998 shared: Arc::clone(shared),
1999 })),
2000 runs: door(Arc::new(RunsEndpoint {
2001 shared: Arc::clone(shared),
2002 })),
2003 }
2004 }
2005}
2006
2007fn needs_lisp(mut template: Description) -> Description {
2010 for action in &mut template.actions {
2011 action.requires.push(CAP_LISP.to_string());
2012 }
2013 template
2014}
2015
2016struct Described {
2027 inner: Arc<dyn Endpoint>,
2028 id: String,
2029 description: Description,
2030}
2031
2032#[async_trait]
2033impl Endpoint for Described {
2034 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
2035 self.inner.invoke(inv).await
2036 }
2037
2038 fn name(&self) -> &str {
2039 &self.id
2040 }
2041
2042 fn describe(&self) -> Description {
2043 self.description.clone()
2044 }
2045}
2046
2047pub struct ScriptSpace {
2050 inner: EndpointSpace,
2051 lisp: LispDoors,
2052 shared: Arc<SpaceConfig>,
2053}
2054
2055impl ScriptSpace {
2056 fn contract(&self, name: &str) -> Contract {
2063 let shared = &self.shared;
2064 if let Some(known) = shared
2065 .described
2066 .lock()
2067 .expect("the described-script memo")
2068 .get(name)
2069 {
2070 return known.clone();
2071 }
2072 let Ok(Some(head)) = shared.backend.head(name) else {
2073 return Contract::Template;
2076 };
2077 let built = self.build(name, &head);
2078 shared
2079 .described
2080 .lock()
2081 .expect("the described-script memo")
2082 .insert(name.to_string(), built.clone());
2083 built
2084 }
2085
2086 fn build(&self, name: &str, head: &Head) -> Contract {
2087 if !ever_published(head, &head.version) {
2094 return Contract::Template;
2095 }
2096 let Ok(Some(version)) = self.shared.backend.version(name, &head.version) else {
2099 return Contract::Lisp;
2100 };
2101 let own = match version.language {
2102 Language::Lisp if head.state == State::Published => return Contract::Lisp,
2103 Language::Lisp => None,
2104 Language::Sparql => self.build_sparql(name, head, &version),
2105 Language::Plan => self.build_plan(name, head, &version),
2106 };
2107 own.map_or(Contract::Template, Contract::Own)
2110 }
2111
2112 fn described(
2114 &self,
2115 inner: Arc<dyn Endpoint>,
2116 id: String,
2117 mut description: Description,
2118 head: &Head,
2119 ) -> Arc<dyn Endpoint> {
2120 description.summary.push_str(&run_gate_note(head));
2121 Arc::new(Described {
2122 inner,
2123 id,
2124 description,
2125 }) as Arc<dyn Endpoint>
2126 }
2127
2128 fn result_endpoint(&self) -> Arc<dyn Endpoint> {
2129 Arc::new(ResultEndpoint {
2130 shared: Arc::clone(&self.shared),
2131 })
2132 }
2133
2134 fn runs_endpoint(&self) -> Arc<dyn Endpoint> {
2135 Arc::new(RunsEndpoint {
2136 shared: Arc::clone(&self.shared),
2137 })
2138 }
2139
2140 fn build_sparql(&self, name: &str, head: &Head, version: &Version) -> Option<PerScript> {
2141 let door = self.shared.sparql.as_ref()?;
2142 let analysis = sparql::analyze(&version.source, door).ok()?;
2143 let about = about(&version.source);
2144 let inputs: Vec<ArgSpec> = analysis
2145 .parameters
2146 .iter()
2147 .map(|p| {
2148 let mut arg = ArgSpec::new(p.name.clone())
2149 .summary(
2150 p.summary
2151 .clone()
2152 .unwrap_or_else(|| format!("The value of ?{} in the query.", p.name)),
2153 )
2154 .class(p.kind.iri().to_string());
2155 if let Some(default) = &p.default {
2156 arg = arg.default_value(default.clone()).optional();
2157 } else if !p.required {
2158 arg = arg.optional();
2159 }
2160 arg
2161 })
2162 .collect();
2163 let params = |spec: ActionSpec| contracted(spec, &inputs, name, head, version);
2164 let form = analysis.form;
2165 let faces = form.faces();
2166 let as_arg = || {
2167 ArgSpec::new("as")
2168 .summary(format!(
2169 "The face: one of {}. Any other is refused, never substituted.",
2170 faces.join(", ")
2171 ))
2172 .class(XSD_STRING)
2173 .one_of(faces.iter().copied())
2174 .default_value(faces[0])
2175 .optional()
2176 };
2177 let id = format!("script-{name}-result");
2178 let description = if form.is_read() {
2179 let mut spec = params(ActionSpec::new(Verb::Source))
2180 .summary(format!("Run the query {name} and answer its result."))
2181 .input(as_arg());
2182 for face in faces {
2183 spec = spec.output(*face);
2184 }
2185 Description::new(id.clone())
2186 .title(format!("{name}: a SPARQL {}", form.as_str().to_uppercase()))
2187 .summary(format!(
2188 "{about}Runs as a READ under the runner's capability narrowed to the \
2189 graphs the query names; cached as the store's answer is, and \
2190 recomputed after a write to the store or a republish."
2191 ))
2192 .verb(Verb::Meta)
2193 .action(spec)
2194 } else {
2195 Description::new(id.clone())
2198 .title(format!("{name}: a SPARQL UPDATE (run it at …:runs)"))
2199 .summary(format!(
2200 "{about}A write: run it with a Sink to urn:script:{name}:runs."
2201 ))
2202 .verb(Verb::Meta)
2203 };
2204 let result = self.described(self.result_endpoint(), id, description, head);
2205 let id = format!("script-{name}-runs");
2206 let mut spec = params(ActionSpec::new(Verb::Sink))
2207 .summary(format!(
2208 "Run {name} for its effects and record the run; answers the run's IRI."
2209 ))
2210 .input(piped_parameters_arg())
2211 .output(PLAIN);
2212 if form.is_read() {
2213 spec = spec.input(as_arg());
2214 }
2215 let runs = self.described(
2216 self.runs_endpoint(),
2217 id.clone(),
2218 Description::new(id)
2219 .title(format!("{name}: runs"))
2220 .summary(format!(
2221 "{about}Each run is recorded: who, which version, under exactly what \
2222 capability, when, and the outcome."
2223 ))
2224 .verb(Verb::Meta)
2225 .action(spec),
2226 head,
2227 );
2228 Some(PerScript {
2229 result,
2230 runs,
2231 reads: form.is_read(),
2232 listed: head.state == State::Published,
2233 })
2234 }
2235
2236 fn build_plan(&self, name: &str, head: &Head, version: &Version) -> Option<PerScript> {
2239 let analysis = plan::analyze(&version.source).ok()?;
2240 let about = about(&version.source);
2241 let inputs: Vec<ArgSpec> = analysis
2242 .parameters
2243 .iter()
2244 .map(|p| {
2245 let mut arg = ArgSpec::new(p.name.clone())
2246 .summary(
2247 p.summary
2248 .clone()
2249 .unwrap_or_else(|| format!("The plan's parameter `{}`.", p.name)),
2250 )
2251 .class(p.class.clone().unwrap_or_else(|| XSD_STRING.to_string()));
2252 if let Some(default) = &p.default {
2253 arg = arg.default_value(default.clone()).optional();
2254 } else if !p.required {
2255 arg = arg.optional();
2256 }
2257 arg
2258 })
2259 .collect();
2260 let params = |spec: ActionSpec| contracted(spec, &inputs, name, head, version);
2261 let as_arg = || {
2265 ArgSpec::new("as")
2266 .summary(
2267 "The face of the plan's result: when its result step served another \
2268 media type it is transrepted, and refused when nothing converts it.",
2269 )
2270 .class(XSD_STRING)
2271 .optional()
2272 };
2273 let reads = !analysis.mutates();
2274 let id = format!("script-{name}-result");
2275 let description = if reads {
2276 Description::new(id.clone())
2277 .title(format!("{name}: a plan"))
2278 .summary(format!(
2279 "{about}Runs as a READ (every step reads) under the runner's capability \
2280 narrowed to what the plan's steps require; as cacheable as its least \
2281 cacheable step, and recomputed after a republish."
2282 ))
2283 .verb(Verb::Meta)
2284 .action(
2285 params(ActionSpec::new(Verb::Source))
2286 .summary(format!("Run the plan {name} and answer its result."))
2287 .input(as_arg())
2288 .output(PLAIN),
2289 )
2290 } else {
2291 Description::new(id.clone())
2294 .title(format!("{name}: a plan that writes (run it at …:runs)"))
2295 .summary(format!(
2296 "{about}A write: run it with a Sink to urn:script:{name}:runs."
2297 ))
2298 .verb(Verb::Meta)
2299 };
2300 let result = self.described(self.result_endpoint(), id, description, head);
2301 let id = format!("script-{name}-runs");
2302 let runs = self.described(
2303 self.runs_endpoint(),
2304 id.clone(),
2305 Description::new(id)
2306 .title(format!("{name}: runs"))
2307 .summary(format!(
2308 "{about}Each run is recorded: who, which version, under exactly what \
2309 capability, when, and the outcome."
2310 ))
2311 .verb(Verb::Meta)
2312 .action(
2313 params(ActionSpec::new(Verb::Sink))
2314 .summary(format!(
2315 "Run the plan {name} and record the run; answers the run's IRI."
2316 ))
2317 .input(piped_parameters_arg())
2318 .input(as_arg())
2319 .output(PLAIN),
2320 ),
2321 head,
2322 );
2323 Some(PerScript {
2324 result,
2325 runs,
2326 reads,
2327 listed: head.state == State::Published,
2328 })
2329 }
2330}
2331
2332fn run_gate_note(head: &Head) -> String {
2341 let floor = authority::run_floor(&head.name);
2342 if head.public || floor == cap_run(&head.name) {
2343 return String::new();
2344 }
2345 format!(
2346 " Running it needs `{}` or a namespace grant covering it. A name with a `-` is in a \
2347 namespace, and a contract cannot depend on who asks, so the kernel's floor checks the \
2348 family of its top-level namespace, `{floor}`: any grant in it passes there, and the \
2349 exact rule is checked when the run starts.",
2350 cap_run(&head.name)
2351 )
2352}
2353
2354fn contracted(
2362 mut spec: ActionSpec,
2363 inputs: &[ArgSpec],
2364 name: &str,
2365 head: &Head,
2366 version: &Version,
2367) -> ActionSpec {
2368 for input in inputs {
2369 spec = spec.input(input.clone());
2370 }
2371 spec = spec.requires(if head.public {
2372 CAP_RUN.to_string()
2373 } else {
2374 authority::run_floor(name)
2375 });
2376 for scope in &version.requires {
2377 spec = spec.requires(scope.clone());
2378 }
2379 spec
2380}
2381
2382fn piped_parameters_arg() -> ArgSpec {
2384 ArgSpec::new("content")
2385 .summary(
2386 "The parameters as one JSON object, for a pipe (a parameter given both here and \
2387 by name is refused).",
2388 )
2389 .class(XSD_STRING)
2390 .optional()
2391}
2392
2393fn about(source: &str) -> String {
2395 let mut lines = Vec::new();
2396 for line in source.lines() {
2397 let trimmed = line.trim();
2398 let Some(body) = trimmed.strip_prefix('#') else {
2399 if trimmed.is_empty() {
2400 continue;
2401 }
2402 break;
2403 };
2404 let body = body.trim();
2405 if !body.is_empty() && !body.starts_with("@param") {
2406 lines.push(body);
2407 }
2408 }
2409 if lines.is_empty() {
2410 String::new()
2411 } else {
2412 format!("{} ", lines.join(" "))
2413 }
2414}
2415
2416impl Space for ScriptSpace {
2417 fn resolve(&self, request: &Request, scope: &Scope) -> Resolution {
2418 let resolution = self.inner.resolve(request, scope);
2419 let (name, part) = match &resolution {
2420 Resolution::Hit(hit) => {
2421 let part = match hit.endpoint.name() {
2422 "script-result" => Door::Result,
2423 "script-runs" => Door::Runs,
2424 _ => return resolution,
2425 };
2426 match hit.bindings.get("name") {
2427 Some(name) if name::validate(name).is_ok() => (name.to_string(), part),
2428 _ => return resolution,
2429 }
2430 }
2431 Resolution::Miss => return resolution,
2432 };
2433 let (result, runs) = match self.contract(&name) {
2434 Contract::Own(per) => (per.result, per.runs),
2435 Contract::Template => return resolution,
2436 Contract::Lisp => (Arc::clone(&self.lisp.result), Arc::clone(&self.lisp.runs)),
2437 };
2438 resolution.map_endpoint(|_| match part {
2439 Door::Result => result,
2440 Door::Runs => runs,
2441 })
2442 }
2443
2444 fn entries(&self) -> Option<Vec<SpaceEntry>> {
2445 let mut entries = self.inner.entries().unwrap_or_default();
2446 for name in self.shared.backend.names().unwrap_or_default() {
2447 let Contract::Own(per) = self.contract(&name) else {
2448 continue;
2449 };
2450 if !per.listed {
2451 continue;
2452 }
2453 if per.reads {
2454 entries.push(SpaceEntry::new(
2455 name::part_iri(&name, "result"),
2456 per.result.name(),
2457 ));
2458 }
2459 entries.push(SpaceEntry::new(
2460 name::part_iri(&name, "runs"),
2461 per.runs.name(),
2462 ));
2463 }
2464 Some(entries)
2465 }
2466
2467 fn id(&self) -> Option<Iri> {
2468 self.inner.id()
2469 }
2470
2471 fn topology(&self) -> Topology {
2472 self.inner.topology()
2473 }
2474}
2475
2476fn kind(error: &Error) -> &'static str {
2478 match error {
2479 Error::Unresolved(_) => "unresolved",
2480 Error::MissingArgument(_) => "missing-argument",
2481 Error::InvalidArgument { .. } => "invalid-argument",
2482 Error::Endpoint(_) => "endpoint",
2483 Error::Denied(_) => "denied",
2484 Error::NotFound(_) => "not-found",
2485 Error::Conflict(_) => "conflict",
2486 Error::Timeout(_) => "timeout",
2487 Error::Unavailable(_) => "unavailable",
2488 _ => "other",
2489 }
2490}
2491
2492fn annotate(error: Error, run: &str) -> Error {
2494 let note = |message: String| format!("{message} (run recorded at {run})");
2495 match error {
2496 Error::Endpoint(m) => Error::Endpoint(note(m)),
2497 Error::Denied(m) => Error::Denied(note(m)),
2498 Error::NotFound(m) => Error::NotFound(note(m)),
2499 Error::Conflict(m) => Error::Conflict(note(m)),
2500 Error::Timeout(m) => Error::Timeout(note(m)),
2501 Error::Unavailable(m) => Error::Unavailable(note(m)),
2502 Error::InvalidArgument { name, detail } => Error::InvalidArgument {
2503 name,
2504 detail: note(detail),
2505 },
2506 other => other,
2507 }
2508}
2509
2510struct RunEndpoint {
2515 shared: Arc<SpaceConfig>,
2516}
2517
2518#[async_trait]
2519impl Endpoint for RunEndpoint {
2520 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
2521 let name = name::from_bindings(inv)?;
2522 inv.depends_on(name::part_iri(&name, "runs"));
2531 if !may(inv, Act::Read, &name) && !may(inv, Act::Run, &name) {
2534 return Err(Error::Denied(format!(
2535 "a run record of urn:script:{name} needs `{}` or `{}`. {GRANT_FORMS}",
2536 cap(Act::Read, &name),
2537 cap(Act::Run, &name)
2538 )));
2539 }
2540 let id_text = inv
2541 .bindings
2542 .get("id")
2543 .ok_or_else(|| Error::Endpoint("no `id` captured".to_string()))?;
2544 let id = id_text.parse::<u64>().map_err(|_| Error::InvalidArgument {
2545 name: "id".to_string(),
2546 detail: format!("`{id_text}` is not a run number"),
2547 });
2548 let run = match id {
2549 Ok(id) => self.shared.backend.run(&name, id)?,
2550 Err(_) if inv.request.verb == Verb::Exists => None,
2551 Err(e) => return Err(e),
2552 };
2553 match inv.request.verb {
2554 Verb::Exists => Ok(plain(if run.is_some() { "true\n" } else { "false\n" })),
2555 Verb::Source => {
2556 let want = face_of(inv, &RECORD_FACES)?;
2557 let run = run.ok_or_else(|| {
2558 Error::NotFound(format!("urn:script:{name} has no run {id_text}"))
2559 })?;
2560 let finished = run.outcome != Outcome::Running;
2561 let repr = match want {
2562 JSON => json(&run)?,
2563 TURTLE => {
2564 let mut graph = crate::graph::Graph::default();
2565 graph.record(&run);
2566 turtle(graph)?
2567 }
2568 _ => plain(run.render()),
2569 };
2570 Ok(if finished { repr.cacheable() } else { repr })
2572 }
2573 other => Err(unsupported("script-run", other)),
2574 }
2575 }
2576
2577 fn name(&self) -> &str {
2578 "script-run"
2579 }
2580
2581 fn describe(&self) -> Description {
2582 let id = || {
2583 ArgSpec::new("id")
2584 .summary("The run's number, unique per script.")
2585 .class(XSD_INTEGER)
2586 .binding()
2587 };
2588 Description::new("script-run")
2589 .title("One run of a script")
2590 .summary(
2591 "A recorded run: principal, version, capability, start and end, outcome, the \
2592 result (up to 64 KiB) and the host's trace span when it traced. Needs the \
2593 script's read or run grant.",
2594 )
2595 .verb(Verb::Meta)
2596 .action(
2597 ActionSpec::new(Verb::Source)
2598 .summary("The run record, as text, JSON, or a PROV-O graph (Turtle).")
2599 .input(name_arg())
2600 .input(id())
2601 .input(record_as_arg())
2602 .output(PLAIN)
2603 .output(JSON)
2604 .output(TURTLE)
2605 .requires(CAP_ANY),
2606 )
2607 .action(
2608 ActionSpec::new(Verb::Exists)
2609 .summary("Whether the run exists.")
2610 .input(name_arg())
2611 .input(id())
2612 .output(PLAIN)
2613 .requires(CAP_ANY),
2614 )
2615 }
2616}
2617
2618struct EvalEndpoint;
2623
2624#[async_trait]
2625impl Endpoint for EvalEndpoint {
2626 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
2627 if inv.request.verb != Verb::Sink {
2628 return Err(unsupported("script-eval", inv.request.verb));
2629 }
2630 let code = inv.inline_str("content")?;
2631 let language_text = optional(inv, "language")?;
2632 let language = language_text
2633 .map(Language::parse)
2634 .transpose()?
2635 .unwrap_or(Language::Lisp);
2636 if language == Language::Sparql {
2637 return Err(Error::InvalidArgument {
2638 name: "language".to_string(),
2639 detail: "SPARQL through urn:script:eval is the SPARQL Protocol face (ledger \
2640 #955), not in this version: publish the query as a script and read \
2641 its `…:result`"
2642 .to_string(),
2643 });
2644 }
2645 if language == Language::Plan {
2646 return Err(Error::InvalidArgument {
2649 name: "language".to_string(),
2650 detail: format!(
2651 "a plan supplied ad hoc runs at {} (`in` = the plan), under the caller's \
2652 own capability; to keep it, publish it as a script",
2653 plan::EVAL
2654 ),
2655 });
2656 }
2657 if let Some(save) = optional(inv, "save")? {
2658 let save = save.trim();
2659 name::validate(save)?;
2660 let mut request = Request::new(Verb::Sink, iri(&name::script_iri(save))?)
2664 .with_arg("content", inline(code))
2665 .with_arg("language", inline(language.as_str()))
2666 .with_arg("state", inline("draft"));
2667 if let Some(requires) = optional(inv, "requires")? {
2668 request = request.with_arg("requires", inline(requires));
2669 }
2670 inv.issue(request).await?;
2671 }
2672 let answer = inv
2675 .issue(lisp_request(code, optional(inv, "data")?)?)
2676 .await?;
2677 Ok(Representation::new(answer.repr_type, answer.bytes))
2678 }
2679
2680 fn name(&self) -> &str {
2681 "script-eval"
2682 }
2683
2684 fn describe(&self) -> Description {
2685 Description::new("script-eval")
2686 .title("Run code you supply")
2687 .summary(
2688 "Run typed or pasted code as an anonymous script under the caller's OWN \
2689 capability and nothing more; answers its value. `save=` keeps it as a draft \
2690 of that script (which needs that script's write grant).",
2691 )
2692 .verb(Verb::Meta)
2693 .action(
2694 ActionSpec::new(Verb::Sink)
2695 .summary("Run the code.")
2696 .input(
2697 ArgSpec::new("content")
2698 .summary("The code (piped).")
2699 .class(XSD_STRING),
2700 )
2701 .input(
2702 ArgSpec::new("language")
2703 .summary(
2704 "The code's language: `lisp`, the one this door runs, so the \
2705 one its `requires` (`urn:cap:lisp`) is for. A plan runs at \
2706 urn:plan:eval, which declares no language capability; SPARQL \
2707 here is the Protocol face, not in this version.",
2708 )
2709 .class(XSD_STRING)
2710 .one_of([Language::Lisp.as_str()])
2714 .default_value("lisp")
2715 .optional(),
2716 )
2717 .input(data_arg("data", DATA_SUMMARY))
2718 .input(
2719 ArgSpec::new("save")
2720 .summary(
2721 "A script name: also keep the code as a draft at \
2722 `urn:script:{save}`. Needs `urn:cap:script:write:{save}`.",
2723 )
2724 .class(XSD_STRING)
2725 .optional(),
2726 )
2727 .input(
2728 ArgSpec::new("requires")
2729 .summary("With `save=`: the draft's declared capability.")
2730 .class(XSD_STRING)
2731 .optional(),
2732 )
2733 .output(PLAIN)
2734 .requires(CAP_LISP),
2735 )
2736 }
2737}
2738
2739#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2745pub struct CatalogEntry {
2746 pub name: String,
2748 pub iri: String,
2750 pub state: Option<State>,
2752 pub public: Option<bool>,
2754 pub version: Option<String>,
2756 #[serde(rename = "lastRun")]
2758 pub last_run: Option<LastRun>,
2759 pub error: Option<String>,
2761}
2762
2763#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2765pub struct LastRun {
2766 pub iri: String,
2768 pub status: String,
2770 pub ended: Option<u64>,
2772}
2773
2774#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2776pub struct Catalog {
2777 pub schema: u32,
2779 pub scripts: Vec<CatalogEntry>,
2781}
2782
2783struct CatalogEndpoint {
2784 shared: Arc<SpaceConfig>,
2785}
2786
2787#[async_trait]
2788impl Endpoint for CatalogEndpoint {
2789 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
2790 if inv.request.verb != Verb::Source {
2791 return Err(unsupported("script-catalog", inv.request.verb));
2792 }
2793 let want = face_of(inv, &RECORD_FACES)?;
2794 let backend = &self.shared.backend;
2795 let mut scripts = Vec::new();
2796 let mut last_runs = Vec::new();
2798 for name in backend.names()? {
2799 let own = may(inv, Act::Read, &name);
2800 let mut entry = CatalogEntry {
2801 iri: name::script_iri(&name),
2802 name: name.clone(),
2803 state: None,
2804 public: None,
2805 version: None,
2806 last_run: None,
2807 error: None,
2808 };
2809 let mut last = None;
2810 match backend.head(&name) {
2811 Err(e) if own => entry.error = Some(e.to_string()),
2814 Ok(Some(head)) if sight(inv, &head, &head.version) == Sight::Hidden => continue,
2816 Ok(Some(head))
2817 if own
2818 || (is_public(&head)
2819 && may_public(inv, Act::Read, CAP_READ_PUBLIC, &name)) =>
2820 {
2821 entry.state = Some(head.state);
2822 entry.public = Some(head.public);
2823 entry.version = Some(head.version);
2824 last = backend.last_run(&name).ok().flatten();
2825 entry.last_run = last.as_ref().map(|run| LastRun {
2826 iri: run.iri(),
2827 status: match run.outcome {
2828 Outcome::Running => "running",
2829 Outcome::Ok => "ok",
2830 Outcome::Failed { .. } => "failed",
2831 }
2832 .to_string(),
2833 ended: run.ended,
2834 });
2835 }
2836 _ => continue,
2837 }
2838 last_runs.push(last);
2839 scripts.push(entry);
2840 }
2841 if want == TURTLE {
2842 let mut graph = crate::graph::Graph::default();
2843 for (entry, last) in scripts.iter().zip(&last_runs) {
2844 graph.script(&entry.name, entry.version.as_deref(), last.as_ref());
2845 }
2846 return turtle(graph);
2847 }
2848 if want == JSON {
2849 return json(&Catalog {
2852 schema: SCHEMA,
2853 scripts,
2854 });
2855 }
2856 let mut text = String::new();
2857 for entry in &scripts {
2858 match (&entry.error, entry.state) {
2859 (Some(error), _) => text.push_str(&format!("{} BROKEN: {error}\n", entry.iri)),
2860 (None, Some(state)) => {
2861 let version = entry.version.as_deref().unwrap_or("");
2862 text.push_str(&format!(
2863 "{} {}{} {}",
2864 entry.iri,
2865 state.as_str(),
2866 if entry.public == Some(true) {
2867 " public"
2868 } else {
2869 ""
2870 },
2871 &version[..version.len().min(19)],
2872 ));
2873 if let Some(run) = &entry.last_run {
2874 text.push_str(&format!(
2875 " last run {} {} {}",
2876 run.iri,
2877 run.status,
2878 model::when(run.ended)
2879 ));
2880 }
2881 text.push('\n');
2882 }
2883 _ => {}
2884 }
2885 }
2886 if scripts.is_empty() {
2887 text.push_str("no scripts this capability may read\n");
2888 }
2889 Ok(plain(text))
2890 }
2891
2892 fn name(&self) -> &str {
2893 "script-catalog"
2894 }
2895
2896 fn describe(&self) -> Description {
2897 Description::new("script-catalog")
2898 .title("Scripts")
2899 .summary(
2900 "Every script the caller may read: state, public flag, head version and last \
2901 run. Live (never cached). A host that wants `urn:host:scripts` aliases it \
2902 here.",
2903 )
2904 .verb(Verb::Meta)
2905 .action(
2906 ActionSpec::new(Verb::Source)
2907 .summary("The catalog, as text, JSON, or a graph (Turtle).")
2908 .input(record_as_arg())
2909 .output(PLAIN)
2910 .output(JSON)
2911 .output(TURTLE)
2912 .requires(CAP_READ),
2913 )
2914 }
2915}
2916
2917fn unsupported(id: &str, verb: Verb) -> Error {
2918 Error::Endpoint(format!("{id} does not answer {verb:?}"))
2919}