1use std::collections::{BTreeSet, HashSet};
15
16use http::Method;
17use schemars::{JsonSchema, Schema, SchemaGenerator};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20
21use crate::config::ConfigError;
22use crate::module::{HARNESS_API, Module};
23use crate::venture::Venture;
24
25pub const SURFACE_API: u32 = 1;
29
30pub const MAX_SIDECAR_SURFACE_BYTES: usize = 256 * 1024;
34pub const MAX_SIDECAR_ACTIONS: usize = 64;
36pub const MAX_SIDECAR_VIEWS: usize = 64;
38
39pub const HINT_KEYWORDS: &[&str] = &[
52 "x-cf-label",
53 "x-cf-placeholder",
54 "x-cf-help",
55 "x-cf-widget",
56 "x-cf-hidden",
57 "x-cf-options",
58];
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "kebab-case")]
64pub enum Audience {
65 Public,
68 Admin,
71 Link,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(tag = "kind", rename_all = "kebab-case")]
80pub enum Outcome {
81 Accepted { message: String },
84 Redirect,
86 Json,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct Action {
93 pub name: String,
96 #[serde(with = "method_serde")]
98 pub method: Method,
99 pub path: String,
101 pub audience: Audience,
102 #[serde(skip_serializing_if = "Option::is_none")]
105 pub input: Option<Schema>,
106 pub outcome: Outcome,
107 #[serde(skip)]
117 pub policy: crate::route_policy::RoutePolicy,
118 pub captcha: bool,
123}
124
125impl Action {
126 #[must_use]
129 pub fn post(name: impl Into<String>, path: impl Into<String>) -> Self {
130 Self::new(name, Method::POST, path)
131 }
132
133 #[must_use]
136 pub fn get(name: impl Into<String>, path: impl Into<String>) -> Self {
137 Self::new(name, Method::GET, path)
138 .audience(Audience::Link)
139 .outcome(Outcome::Redirect)
140 }
141
142 #[must_use]
144 pub fn delete(name: impl Into<String>, path: impl Into<String>) -> Self {
145 Self::new(name, Method::DELETE, path)
146 .audience(Audience::Admin)
147 .outcome(Outcome::Json)
148 }
149
150 #[must_use]
151 pub fn new(name: impl Into<String>, method: Method, path: impl Into<String>) -> Self {
152 Self {
153 name: name.into(),
154 method,
155 path: path.into(),
156 audience: Audience::Public,
157 input: None,
158 outcome: Outcome::Accepted {
159 message: "Thanks, you're in.".to_owned(),
160 },
161 policy: crate::route_policy::RoutePolicy::default(),
162 captcha: false,
163 }
164 }
165
166 #[must_use]
167 pub fn audience(mut self, audience: Audience) -> Self {
168 self.audience = audience;
169 self
170 }
171
172 #[must_use]
174 pub fn input<T: JsonSchema>(mut self) -> Self {
175 self.input = Some(schema_for::<T>());
176 self
177 }
178
179 #[must_use]
182 pub fn input_schema(mut self, schema: Schema) -> Self {
183 self.input = Some(schema);
184 self
185 }
186
187 #[must_use]
188 pub fn outcome(mut self, outcome: Outcome) -> Self {
189 self.outcome = outcome;
190 self
191 }
192
193 #[must_use]
195 pub fn accepted(self, message: impl Into<String>) -> Self {
196 self.outcome(Outcome::Accepted {
197 message: message.into(),
198 })
199 }
200
201 #[must_use]
203 pub fn captcha(mut self) -> Self {
204 self.policy = crate::route_policy::RoutePolicy::HumanForm;
205 self.captcha = true;
206 self
207 }
208
209 #[must_use]
214 pub fn policy(mut self, policy: crate::route_policy::RoutePolicy) -> Self {
215 self.policy = policy;
216 self.captcha = policy == crate::route_policy::RoutePolicy::HumanForm;
217 self
218 }
219
220 #[must_use]
229 pub fn demands_captcha(&self) -> bool {
230 self.policy == crate::route_policy::RoutePolicy::HumanForm || self.captcha
231 }
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct Column {
237 pub key: String,
239 pub label: String,
240}
241
242impl Column {
243 #[must_use]
244 pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
245 Self {
246 key: key.into(),
247 label: label.into(),
248 }
249 }
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(tag = "kind", rename_all = "kebab-case")]
255pub enum View {
256 Form { action: String },
258 Status { action: String },
260 Table {
262 source: String,
263 columns: Vec<Column>,
264 },
265}
266
267impl View {
268 #[must_use]
269 pub fn form(action: impl Into<String>) -> Self {
270 View::Form {
271 action: action.into(),
272 }
273 }
274
275 #[must_use]
276 pub fn status(action: impl Into<String>) -> Self {
277 View::Status {
278 action: action.into(),
279 }
280 }
281
282 #[must_use]
283 pub fn table(source: impl Into<String>, columns: Vec<Column>) -> Self {
284 View::Table {
285 source: source.into(),
286 columns,
287 }
288 }
289
290 fn action_names(&self) -> Vec<&str> {
291 match self {
292 View::Form { action } | View::Status { action } => vec![action],
293 View::Table { source, .. } => vec![source],
294 }
295 }
296}
297
298#[derive(Debug, Clone, Default, Serialize, Deserialize)]
300pub struct Surface {
301 pub actions: Vec<Action>,
302 pub views: Vec<View>,
303}
304
305impl Surface {
306 #[must_use]
308 pub fn none() -> Self {
309 Self::default()
310 }
311
312 #[must_use]
313 pub fn new() -> Self {
314 Self::default()
315 }
316
317 #[must_use]
318 pub fn action(mut self, action: Action) -> Self {
319 self.actions.push(action);
320 self
321 }
322
323 #[must_use]
324 pub fn view(mut self, view: View) -> Self {
325 self.views.push(view);
326 self
327 }
328
329 #[must_use]
331 pub fn is_empty(&self) -> bool {
332 self.actions.is_empty() && self.views.is_empty()
333 }
334
335 pub fn validate(&self, module: &str, errors: &mut ConfigError) {
341 let mut seen: HashSet<&str> = HashSet::new();
342 for action in &self.actions {
343 let name = action.name.as_str();
344 if !is_kebab(name) {
345 errors.push(format!(
346 "module `{module}` surface action `{name}` must be kebab-case"
347 ));
348 }
349 if !seen.insert(name) {
350 errors.push(format!(
351 "module `{module}` surface declares action `{name}` twice"
352 ));
353 }
354 if !action.path.starts_with('/') {
355 errors.push(format!(
356 "module `{module}` surface action `{name}` path `{}` must start with '/' \
357 (relative to /v1/{module})",
358 action.path
359 ));
360 }
361 if action.path.split('/').any(|segment| segment == "..") {
362 errors.push(format!(
363 "module `{module}` surface action `{name}` path `{}` must not contain '..' \
364 segments (an action stays inside its module's mount)",
365 action.path
366 ));
367 }
368 let under_admin = action.path == "/admin" || action.path.starts_with("/admin/");
369 match action.audience {
370 Audience::Admin if !under_admin => errors.push(format!(
371 "module `{module}` surface action `{name}` is admin but its path `{}` \
372 is not under /admin/",
373 action.path
374 )),
375 Audience::Public | Audience::Link if under_admin => errors.push(format!(
376 "module `{module}` surface action `{name}` is under /admin/ but its \
377 audience is not admin",
378 )),
379 _ => {}
380 }
381 if let Some(schema) = &action.input
382 && !is_object_schema(schema)
383 {
384 errors.push(format!(
385 "module `{module}` surface action `{name}` input schema must describe an \
386 object (a struct with named fields), so a renderer can lay out fields"
387 ));
388 }
389 match action.policy {
390 crate::route_policy::RoutePolicy::HumanForm if !action.captcha => {
391 errors.push(format!(
392 "module `{module}` surface action `{name}` declares policy=HumanForm \
393 but captcha=false; the renderer would omit the widget the policy \
394 demands — declare protection with `.captcha()` (issue #133)",
395 ));
396 }
397 crate::route_policy::RoutePolicy::Signature if action.captcha => {
398 errors.push(format!(
399 "module `{module}` surface action `{name}` declares policy=Signature \
400 (a machine caller) but captcha=true; a captcha cannot protect a \
401 webhook — drop `.captcha()` (issue #133)",
402 ));
403 }
404 _ => {}
408 }
409 }
410 for view in &self.views {
411 for referenced in view.action_names() {
412 if !seen.contains(referenced) {
413 errors.push(format!(
414 "module `{module}` surface view references action `{referenced}` \
415 which the module does not declare"
416 ));
417 }
418 }
419 }
420 }
421
422 #[must_use]
429 pub fn public(&self) -> Surface {
430 let actions: Vec<Action> = self
431 .actions
432 .iter()
433 .filter(|action| {
434 action.audience != Audience::Admin
435 && action.policy != crate::route_policy::RoutePolicy::Signature
436 })
437 .cloned()
438 .collect();
439 let names: BTreeSet<&str> = actions.iter().map(|a| a.name.as_str()).collect();
440 let views = self
441 .views
442 .iter()
443 .filter(|view| view.action_names().iter().all(|n| names.contains(n)))
444 .cloned()
445 .collect();
446 Surface { actions, views }
447 }
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize)]
452pub struct ModuleSurface {
453 pub name: String,
454 pub version: String,
455 #[serde(flatten)]
456 pub surface: Surface,
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct VentureSurface {
462 pub name: String,
463 pub public_url: String,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct SurfaceDocument {
470 pub surface_api: u32,
471 pub harness_api: u32,
472 pub venture: VentureSurface,
473 pub modules: Vec<ModuleSurface>,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub ui: Option<serde_json::Value>,
477}
478
479impl SurfaceDocument {
480 #[must_use]
482 pub fn compose(venture: &Venture, modules: &[std::sync::Arc<dyn Module>]) -> Self {
483 let modules = modules
484 .iter()
485 .map(|module| ModuleSurface {
486 name: module.name().to_owned(),
487 version: module.version().to_owned(),
488 surface: module.surface(),
489 })
490 .filter(|entry| !entry.surface.is_empty())
491 .collect();
492 Self {
493 surface_api: SURFACE_API,
494 harness_api: HARNESS_API,
495 venture: VentureSurface {
496 name: venture.name.clone(),
497 public_url: venture.public_url.clone(),
498 },
499 modules,
500 ui: None,
501 }
502 }
503
504 #[must_use]
507 pub fn public(&self) -> Self {
508 Self {
509 surface_api: self.surface_api,
510 harness_api: self.harness_api,
511 venture: self.venture.clone(),
512 modules: self
513 .modules
514 .iter()
515 .map(|entry| ModuleSurface {
516 name: entry.name.clone(),
517 version: entry.version.clone(),
518 surface: entry.surface.public(),
519 })
520 .filter(|entry| !entry.surface.is_empty())
521 .collect(),
522 ui: self.ui.clone(),
523 }
524 }
525}
526
527pub(crate) fn sanitize_sidecar_document(
537 document: &SurfaceDocument,
538 mount: &str,
539) -> Result<Vec<ModuleSurface>, Vec<String>> {
540 let mut errors = Vec::new();
541 if document.surface_api != SURFACE_API {
542 errors.push(format!(
543 "surface contract {} is not {SURFACE_API}",
544 document.surface_api
545 ));
546 }
547 if document.harness_api != HARNESS_API {
548 errors.push(format!(
549 "harness contract {} is not {HARNESS_API}",
550 document.harness_api
551 ));
552 }
553 let named: Vec<&ModuleSurface> = document
554 .modules
555 .iter()
556 .filter(|entry| entry.name == mount)
557 .collect();
558 match named.len() {
559 0 => {
560 if !document.modules.is_empty() {
561 let others: Vec<&str> = document.modules.iter().map(|m| m.name.as_str()).collect();
562 errors.push(format!(
563 "declares no surface for the mounted module `{mount}` (it names [{}] instead)",
564 others.join(", ")
565 ));
566 }
567 }
568 1 => {}
569 count => errors.push(format!("declares module `{mount}` {count} times")),
570 }
571 if let Some(entry) = named.first() {
572 if entry.surface.actions.len() > MAX_SIDECAR_ACTIONS {
573 errors.push(format!(
574 "declares {} actions, more than the {MAX_SIDECAR_ACTIONS} a merged surface allows",
575 entry.surface.actions.len()
576 ));
577 }
578 if entry.surface.views.len() > MAX_SIDECAR_VIEWS {
579 errors.push(format!(
580 "declares {} views, more than the {MAX_SIDECAR_VIEWS} a merged surface allows",
581 entry.surface.views.len()
582 ));
583 }
584 let mut validation = ConfigError::default();
585 entry.surface.validate(mount, &mut validation);
586 errors.extend(validation.problems);
587 }
588 if !errors.is_empty() {
589 return Err(errors);
590 }
591 Ok(named
592 .into_iter()
593 .map(|entry| ModuleSurface {
594 name: entry.name.clone(),
595 version: entry.version.clone(),
596 surface: entry.surface.public(),
597 })
598 .collect())
599}
600
601pub(crate) fn strip_unguarded_captcha_actions(surface: &mut Surface) -> usize {
610 let kept: Vec<Action> = surface
611 .actions
612 .iter()
613 .filter(|action| !action.demands_captcha())
614 .cloned()
615 .collect();
616 let dropped = surface.actions.len() - kept.len();
617 if dropped > 0 {
618 let names: std::collections::BTreeSet<&str> =
619 kept.iter().map(|a| a.name.as_str()).collect();
620 surface
621 .views
622 .retain(|view| view.action_names().iter().all(|n| names.contains(n)));
623 surface.actions = kept;
624 }
625 dropped
626}
627
628#[async_trait::async_trait]
634pub trait SurfaceSource: Send + Sync {
635 async fn current(&self) -> std::sync::Arc<SurfaceDocument>;
638 fn built(&self) -> std::sync::Arc<SurfaceDocument>;
641 fn rendered(&self, admin: bool) -> Option<&RenderedSurface> {
644 let _ = admin;
645 None
646 }
647}
648
649pub struct UiContext {
652 pub surface: std::sync::Arc<dyn SurfaceSource>,
656 pub api: axum::Router,
662 pub config: std::sync::Arc<dyn crate::config::Config>,
663 pub venture: std::sync::Arc<Venture>,
664 pub captcha_configured: bool,
667 pub signer: Option<std::sync::Arc<dyn crate::ports::Signer>>,
670 pub rate_limiter: Option<std::sync::Arc<dyn crate::ports::RateLimiter>>,
672}
673
674pub trait UiMount: Send + Sync + 'static {
678 fn router(&self, ctx: UiContext) -> axum::Router;
680 fn validate(&self, surface: &SurfaceDocument, errors: &mut ConfigError) {
684 let _ = (surface, errors);
685 }
686 fn describe(&self) -> Option<serde_json::Value> {
690 None
691 }
692}
693
694#[derive(Debug, Clone)]
698pub struct RenderedSurface {
699 pub json: String,
700 pub etag: String,
702}
703
704impl RenderedSurface {
705 #[must_use]
706 pub fn render(document: &SurfaceDocument) -> Self {
707 let json = serde_json::to_string(document).unwrap_or_else(|_| "{}".to_owned());
708 let digest = Sha256::digest(json.as_bytes());
709 let mut hex = String::with_capacity(32);
710 for byte in &digest[..16] {
711 use std::fmt::Write as _;
712 let _ = write!(hex, "{byte:02x}");
713 }
714 Self {
715 json,
716 etag: format!("\"{hex}\""),
717 }
718 }
719}
720
721#[must_use]
724pub fn schema_for<T: JsonSchema>() -> Schema {
725 let mut settings = schemars::generate::SchemaSettings::draft2020_12();
726 settings.inline_subschemas = true;
727 SchemaGenerator::new(settings).into_root_schema_for::<T>()
728}
729
730pub fn hint_field(schema: &mut Schema, field: &str, key: &str, value: serde_json::Value) {
735 if let Some(properties) = schema
736 .as_object_mut()
737 .and_then(|root| root.get_mut("properties"))
738 .and_then(serde_json::Value::as_object_mut)
739 && let Some(property) = properties
740 .get_mut(field)
741 .and_then(serde_json::Value::as_object_mut)
742 {
743 property.insert(key.to_owned(), value);
744 }
745}
746
747fn is_object_schema(schema: &Schema) -> bool {
748 let value = schema.as_value();
749 match value.get("type") {
750 Some(serde_json::Value::String(t)) => t == "object",
751 Some(serde_json::Value::Array(types)) => types.iter().any(|t| t == "object"),
752 _ => value.get("properties").is_some(),
753 }
754}
755
756fn is_kebab(name: &str) -> bool {
757 !name.is_empty()
758 && name.split('-').all(|part| {
759 !part.is_empty()
760 && part
761 .chars()
762 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
763 })
764}
765
766mod method_serde {
767 use http::Method;
768 use serde::{Deserialize, Deserializer, Serialize, Serializer};
769
770 pub fn serialize<S: Serializer>(method: &Method, serializer: S) -> Result<S::Ok, S::Error> {
771 method.as_str().serialize(serializer)
772 }
773
774 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Method, D::Error> {
775 let text = String::deserialize(deserializer)?;
776 Method::from_bytes(text.as_bytes()).map_err(serde::de::Error::custom)
777 }
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783
784 #[derive(JsonSchema)]
785 #[allow(dead_code)]
786 struct JoinBody {
787 #[schemars(extend("x-cf-label" = "Email", "x-cf-widget" = "email"))]
788 email: String,
789 product: String,
790 #[schemars(extend("x-cf-hidden" = true))]
791 #[serde(rename = "captchaToken")]
792 captcha_token: Option<String>,
793 }
794
795 fn join() -> Action {
796 Action::post("join", "/").input::<JoinBody>().captcha()
797 }
798
799 fn errors_of(surface: &Surface) -> Vec<String> {
800 let mut errors = ConfigError::default();
801 surface.validate("waitlist", &mut errors);
802 match errors.into_result() {
803 Ok(()) => Vec::new(),
804 Err(err) => err.to_string().lines().skip(1).map(str::to_owned).collect(),
805 }
806 }
807
808 #[test]
809 fn schema_carries_hints_and_is_inlined() {
810 let schema = schema_for::<JoinBody>();
811 let value = schema.as_value();
812 assert_eq!(value["type"], "object");
813 assert_eq!(value["properties"]["email"]["x-cf-label"], "Email");
814 assert_eq!(value["properties"]["email"]["x-cf-widget"], "email");
815 assert_eq!(value["properties"]["captchaToken"]["x-cf-hidden"], true);
816 assert!(value.get("$defs").is_none(), "subschemas must be inlined");
817 }
818
819 #[test]
820 fn hint_field_sets_a_keyword_and_ignores_unknown_fields() {
821 let mut schema = schema_for::<JoinBody>();
822 hint_field(
823 &mut schema,
824 "product",
825 "enum",
826 serde_json::json!(["a", "b"]),
827 );
828 hint_field(&mut schema, "missing", "x-cf-label", serde_json::json!("x"));
829 let value = schema.as_value();
830 assert_eq!(
831 value["properties"]["product"]["enum"],
832 serde_json::json!(["a", "b"])
833 );
834 assert!(value["properties"].get("missing").is_none());
835 }
836
837 #[test]
838 fn valid_surface_has_no_errors() {
839 let surface = Surface::new()
840 .action(join())
841 .action(Action::get("confirm", "/confirm"))
842 .action(
843 Action::get("export", "/admin/export.csv")
844 .audience(Audience::Admin)
845 .outcome(Outcome::Json),
846 )
847 .view(View::form("join"))
848 .view(View::table("export", vec![Column::new("email", "Email")]));
849 assert!(errors_of(&surface).is_empty());
850 }
851
852 #[test]
853 fn every_validation_rule_names_the_module_and_action() {
854 #[derive(JsonSchema)]
855 #[allow(dead_code)]
856 struct NotAnObject(Vec<String>);
857
858 let surface = Surface::new()
859 .action(join())
860 .action(join())
861 .action(Action::post("Bad Name", "no-slash"))
862 .action(Action::post("hidden", "/admin/thing"))
863 .action(Action::delete("wipe", "/wipe"))
864 .action(Action::post("list", "/list").input::<NotAnObject>())
865 .view(View::form("missing"));
866 let errors = errors_of(&surface);
867 let joined = errors.join("\n");
868 for needle in [
869 "declares action `join` twice",
870 "action `Bad Name` must be kebab-case",
871 "path `no-slash` must start with '/'",
872 "action `hidden` is under /admin/ but its audience is not admin",
873 "action `wipe` is admin but its path `/wipe` is not under /admin/",
874 "action `list` input schema must describe an object",
875 "view references action `missing`",
876 ] {
877 assert!(joined.contains(needle), "missing `{needle}` in:\n{joined}");
878 }
879 assert!(joined.lines().all(|l| l.contains("`waitlist`")), "{joined}");
880 }
881
882 #[test]
883 fn public_subset_drops_admin_actions_and_their_views() {
884 let surface = Surface::new()
885 .action(join())
886 .action(
887 Action::get("export", "/admin/export.csv")
888 .audience(Audience::Admin)
889 .outcome(Outcome::Json),
890 )
891 .view(View::form("join"))
892 .view(View::table("export", vec![]));
893 let public = surface.public();
894 assert_eq!(public.actions.len(), 1);
895 assert_eq!(public.views.len(), 1);
896 assert!(matches!(public.views[0], View::Form { .. }));
897 }
898
899 #[test]
900 fn rendered_surface_etag_is_stable_and_differs_per_variant() {
901 let doc = SurfaceDocument {
902 surface_api: SURFACE_API,
903 harness_api: HARNESS_API,
904 venture: VentureSurface {
905 name: "v".into(),
906 public_url: "https://v.test".into(),
907 },
908 modules: vec![ModuleSurface {
909 name: "waitlist".into(),
910 version: "0.1.0".into(),
911 surface: Surface::new()
912 .action(join())
913 .action(Action::delete("wipe", "/admin/wipe")),
914 }],
915 ui: None,
916 };
917 let full = RenderedSurface::render(&doc);
918 let again = RenderedSurface::render(&doc);
919 let public = RenderedSurface::render(&doc.public());
920 assert_eq!(full.etag, again.etag);
921 assert_ne!(full.etag, public.etag);
922 assert!(full.etag.starts_with('"') && full.etag.ends_with('"'));
923 assert_eq!(full.etag.len(), 34);
924 let parsed: serde_json::Value = serde_json::from_str(&full.json).unwrap();
925 assert_eq!(parsed["modules"][0]["actions"][0]["method"], "POST");
926 assert_eq!(parsed["modules"][0]["actions"][1]["audience"], "admin");
927 assert_eq!(parsed["surface_api"], SURFACE_API);
928 }
929
930 #[test]
931 fn document_omits_modules_without_a_surface() {
932 struct Silent;
933 impl Module for Silent {
934 fn name(&self) -> &'static str {
935 "silent"
936 }
937 fn version(&self) -> &'static str {
938 "0.0.0"
939 }
940 fn requires(&self) -> &'static [crate::ports::Port] {
941 &[]
942 }
943 fn migrations(&self) -> crate::module::Migrations {
944 crate::module::Migrations::EMPTY
945 }
946 fn validate_config(&self, _: &dyn crate::config::Config) -> Result<(), ConfigError> {
947 Ok(())
948 }
949 fn router(&self, _: crate::module::ModuleContext) -> axum::Router {
950 axum::Router::new()
951 }
952 }
953 let venture = Venture::new("v", "v.test");
954 let modules: Vec<std::sync::Arc<dyn Module>> = vec![std::sync::Arc::new(Silent)];
955 let doc = SurfaceDocument::compose(&venture, &modules);
956 assert!(doc.modules.is_empty());
957 }
958}