Skip to main content

cufflink_types/manifest/
service.rs

1use super::{
2    AuthorizationConfig, CustomRouteDefinition, HandlerType, SubscriptionDefinition,
3    TableDefinition,
4};
5use crate::features;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::BTreeMap;
9
10/// A service manifest describes the complete definition of a cufflink service.
11/// This is generated by the SDK macros from the user's Rust struct definitions.
12#[derive(Debug, Clone, Default, Serialize, Deserialize)]
13pub struct ServiceManifest {
14    pub name: String,
15    pub version: Option<String>,
16    pub tables: Vec<TableDefinition>,
17    #[serde(default)]
18    pub cells: Vec<CellDefinition>,
19    #[serde(default)]
20    pub events: Vec<EventDefinition>,
21    #[serde(default)]
22    pub subscriptions: Vec<SubscriptionDefinition>,
23    #[serde(default)]
24    pub custom_routes: Vec<CustomRouteDefinition>,
25    #[serde(default)]
26    pub mode: ServiceMode,
27    /// Authorization configuration (permission areas and default roles).
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub authorization: Option<AuthorizationConfig>,
30    /// Optional WASM handler invoked between additive and destructive schema
31    /// phases of a deploy. Used to backfill or restructure data when columns
32    /// are split, renamed, or have their semantics changed. The handler must
33    /// be idempotent — it may be re-invoked on retried deploys.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub on_migrate: Option<String>,
36    /// NATS subject patterns (with `*`/`>` wildcards) this service is allowed to
37    /// publish or request outside its own `dw.{tenant}.*` namespace. `None` marks
38    /// a manifest built before publish authorization existed: the platform then
39    /// runs in legacy mode (allow + warn). `Some` — always emitted by the current
40    /// SDK — enables enforcement.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub publishes: Option<Vec<String>>,
43    /// Platform capabilities this service requires to run safely. The platform
44    /// rejects a deploy whose required features it does not advertise at
45    /// `/api/version`. The SDK auto-derives this (e.g. `route_posture` when any
46    /// route declares a posture) so a security-relevant manifest cannot be
47    /// silently deployed to a platform too old to enforce it.
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub required_platform_features: Vec<String>,
50    /// Serve the auto-generated GraphQL endpoint. Off unless the service
51    /// opts in.
52    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
53    pub graphql: bool,
54    /// How custom routes are reached: `legacy` serves each only by handler
55    /// name under `_fn/`; `strict` also matches their declared paths.
56    #[serde(default, skip_serializing_if = "RoutingMode::is_legacy")]
57    pub routing: RoutingMode,
58    /// The JSON key the platform's own error responses for this service
59    /// carry their message under, instead of `error`.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub errors: Option<ErrorProfile>,
62    /// The service's OpenAPI document settings; its presence (or any
63    /// documented route) makes the spec server-relative.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub openapi: Option<OpenApiOptions>,
66    /// Shared JSON Schemas the route docs refer to as
67    /// `#/components/schemas/{name}`.
68    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
69    pub components: BTreeMap<String, Value>,
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
73pub struct OpenApiOptions {
74    /// Defaults to the service name.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub title: Option<String>,
77    /// Defaults to the manifest version.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub version: Option<String>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub description: Option<String>,
82    /// Defaults to the service's own `/svc/{tenant}/{service}` base.
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub servers: Vec<ServerDoc>,
85    /// The document's tag list.
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub tags: Vec<TagDoc>,
88    /// The HTTP bearer scheme authenticated routes name; `bearer_auth`
89    /// when unset.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub security_scheme: Option<String>,
92}
93
94impl OpenApiOptions {
95    pub const DEFAULT_SECURITY_SCHEME: &'static str = "bearer_auth";
96
97    pub fn security_scheme(&self) -> &str {
98        self.security_scheme
99            .as_deref()
100            .unwrap_or(Self::DEFAULT_SECURITY_SCHEME)
101    }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct ServerDoc {
106    pub url: String,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub description: Option<String>,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct TagDoc {
113    pub name: String,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub description: Option<String>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct ErrorProfile {
120    pub key: String,
121}
122
123impl ErrorProfile {
124    /// Whether `key` is a valid error key: `[a-z_][a-z0-9_]{0,63}`.
125    pub fn is_valid_key(key: &str) -> bool {
126        let mut bytes = key.bytes();
127        key.len() <= 64
128            && bytes
129                .next()
130                .is_some_and(|b| b.is_ascii_lowercase() || b == b'_')
131            && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
132    }
133}
134
135#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum RoutingMode {
138    #[default]
139    Legacy,
140    Strict,
141}
142
143impl RoutingMode {
144    pub fn is_legacy(&self) -> bool {
145        *self == RoutingMode::Legacy
146    }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
150#[serde(rename_all = "lowercase")]
151pub enum ServiceMode {
152    #[default]
153    Crud,
154    Wasm,
155    Container,
156    Web,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct CellDefinition {
161    pub name: String,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct EventDefinition {
166    pub name: String,
167    pub table: String,
168    pub action: EventAction,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172#[serde(rename_all = "lowercase")]
173pub enum EventAction {
174    Create,
175    Update,
176    Delete,
177}
178
179impl ServiceManifest {
180    /// Get table definition by name
181    pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
182        self.tables.iter().find(|t| t.name == name)
183    }
184
185    /// Whether this manifest needs a WASM artifact bundled at deploy time.
186    ///
187    /// A WASM module is required when the service runs user code: explicit
188    /// `mode: wasm`, an `on_migrate` schema hook, table CRUD lifecycle hooks,
189    /// or any `Wasm` subscription/custom-route handler. The CLI uses this to
190    /// decide whether to compile a WASM artifact; the platform uses it to
191    /// reject deploys that arrive without one.
192    pub fn requires_wasm(&self) -> bool {
193        if self.mode == ServiceMode::Wasm {
194            return true;
195        }
196        if self.on_migrate.is_some() {
197            return true;
198        }
199        if self.tables.iter().any(|t| t.hooks.is_some()) {
200            return true;
201        }
202        if !self.custom_routes.is_empty() {
203            return true;
204        }
205        if self
206            .subscriptions
207            .iter()
208            .any(|s| matches!(s.handler_type, HandlerType::Wasm))
209        {
210            return true;
211        }
212        false
213    }
214
215    /// All handler names this manifest expects the WASM module to export.
216    /// Used at deploy time to reject manifests whose declared handlers are
217    /// missing from the bundled binary.
218    pub fn declared_wasm_handlers(&self) -> Vec<&str> {
219        let mut handlers: Vec<&str> = Vec::new();
220        if let Some(ref h) = self.on_migrate {
221            handlers.push(h.as_str());
222        }
223        for route in &self.custom_routes {
224            handlers.push(route.handler.as_str());
225            if let Some(reducer) = route.sse.as_ref().and_then(|sse| sse.reducer.as_deref()) {
226                handlers.push(reducer);
227            }
228        }
229        for sub in &self.subscriptions {
230            if matches!(sub.handler_type, HandlerType::Wasm) {
231                handlers.push(sub.handler.as_str());
232            }
233        }
234        for table in &self.tables {
235            if let Some(ref hooks) = table.hooks {
236                for h in [
237                    hooks.before_create.as_deref(),
238                    hooks.after_create.as_deref(),
239                    hooks.before_update.as_deref(),
240                    hooks.after_update.as_deref(),
241                    hooks.before_delete.as_deref(),
242                    hooks.after_delete.as_deref(),
243                ]
244                .iter()
245                .flatten()
246                {
247                    handlers.push(h);
248                }
249            }
250        }
251        handlers.sort();
252        handlers.dedup();
253        handlers
254    }
255
256    /// The `custom_routes` entry exposing `handler` over the HTTP `_fn/`
257    /// surface, if any. Only custom routes are HTTP-invocable; subscription,
258    /// `on_migrate`, and table-hook handlers run solely via their internal
259    /// triggers, so a handler absent here must not be dispatched from an
260    /// inbound request. Matched by name alone — the `_fn/` route accepts every
261    /// method, so a verb-aware match would demote a job handler to inline
262    /// execution on the wrong verb.
263    pub fn http_route(&self, handler: &str) -> Option<&CustomRouteDefinition> {
264        self.http_route_index(handler)
265            .and_then(|index| self.custom_routes.get(index))
266    }
267
268    /// The position of [`http_route`](Self::http_route) in `custom_routes`.
269    pub fn http_route_index(&self, handler: &str) -> Option<usize> {
270        self.custom_routes.iter().position(|r| r.handler == handler)
271    }
272
273    /// Whether this manifest was built by a posture-aware SDK. True when any
274    /// custom route carries a posture — the current SDK emits a posture on
275    /// every route, so a posture-aware manifest is all-or-nothing and a lone
276    /// undeclared route among declared ones signals tampering or a bug.
277    pub fn is_posture_aware(&self) -> bool {
278        self.custom_routes.iter().any(|r| r.posture.is_some())
279    }
280
281    /// Platform features required by service-level fields.
282    pub(crate) fn service_features(&self) -> impl Iterator<Item = &'static str> {
283        [
284            (self.routing == RoutingMode::Strict).then_some(features::ROUTE_MATCHING),
285            self.errors.is_some().then_some(features::ERROR_PROFILE),
286        ]
287        .into_iter()
288        .flatten()
289    }
290
291    /// SHA-256 of the whole manifest: the deploy skip check compares this, so
292    /// a change to any field (`publishes`, `events`, `mode`, …) deploys.
293    pub fn deploy_hash(&self) -> serde_json::Result<String> {
294        use sha2::{Digest, Sha256};
295        serde_json::to_vec(self).map(|bytes| hex::encode(Sha256::digest(bytes)))
296    }
297
298    /// SHA-256 of the schema-bearing fields, stored on schema snapshots.
299    pub fn schema_hash(&self) -> serde_json::Result<String> {
300        use sha2::{Digest, Sha256};
301        let digest = [
302            serde_json::to_string(&self.tables)?,
303            serde_json::to_string(&self.custom_routes)?,
304            serde_json::to_string(&self.subscriptions)?,
305            serde_json::to_string(&self.authorization)?,
306            serde_json::to_string(&self.on_migrate)?,
307        ]
308        .iter()
309        .fold(Sha256::new(), |hasher, part| hasher.chain_update(part))
310        .finalize();
311        Ok(hex::encode(digest))
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::manifest::fixtures::sample_manifest;
319    use crate::manifest::{
320        ColumnDefinition, ColumnType, ForeignKeyAction, JobConfig, RoutePosture, TableHooks,
321    };
322    use std::collections::HashMap;
323
324    #[test]
325    fn test_manifest_serialization_roundtrip() {
326        let manifest = sample_manifest();
327        let json = serde_json::to_string(&manifest).unwrap();
328        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
329        assert_eq!(parsed.name, "test-service");
330        assert_eq!(parsed.tables.len(), 1);
331        assert_eq!(parsed.tables[0].columns.len(), 3);
332    }
333
334    #[test]
335    fn routing_is_serialized_only_when_strict() {
336        let mut manifest = sample_manifest();
337        let json = serde_json::to_value(&manifest).unwrap();
338        assert!(json.get("routing").is_none(), "{json}");
339        manifest.routing = RoutingMode::Strict;
340        let json = serde_json::to_value(&manifest).unwrap();
341        assert_eq!(json["routing"], "strict");
342        let parsed: ServiceManifest = serde_json::from_value(json).unwrap();
343        assert_eq!(parsed.routing, RoutingMode::Strict);
344    }
345
346    #[test]
347    fn error_profile_keys_are_snake_case_identifiers() {
348        for key in ["error_message", "_e", "e1", &"e".repeat(64)] {
349            assert!(ErrorProfile::is_valid_key(key), "{key}");
350        }
351        for key in [
352            "",
353            "1e",
354            "Error",
355            "error-message",
356            "error message",
357            &"e".repeat(65),
358        ] {
359            assert!(!ErrorProfile::is_valid_key(key), "{key}");
360        }
361        let mut manifest = sample_manifest();
362        assert!(serde_json::to_value(&manifest)
363            .unwrap()
364            .get("errors")
365            .is_none());
366        manifest.errors = Some(ErrorProfile {
367            key: "error_message".into(),
368        });
369        assert_eq!(
370            serde_json::to_value(&manifest).unwrap()["errors"],
371            serde_json::json!({"key": "error_message"})
372        );
373    }
374
375    #[test]
376    fn test_get_table() {
377        let manifest = sample_manifest();
378        assert!(manifest.get_table("todos").is_some());
379        assert!(manifest.get_table("nonexistent").is_none());
380    }
381
382    #[test]
383    fn test_schema_hash_deterministic() {
384        let m1 = sample_manifest();
385        let m2 = sample_manifest();
386        assert_eq!(
387            m1.schema_hash().expect("schema hash"),
388            m2.schema_hash().expect("schema hash")
389        );
390    }
391
392    #[test]
393    fn test_schema_hash_changes() {
394        let mut m1 = sample_manifest();
395        let m2 = sample_manifest();
396        m1.tables[0].columns.push(ColumnDefinition {
397            name: "extra".into(),
398            column_type: ColumnType::Text,
399            primary_key: false,
400            nullable: true,
401            auto_generate: false,
402            default_value: None,
403            references: None,
404            on_delete: None,
405            unique: false,
406            validations: vec![],
407        });
408        assert_ne!(
409            m1.schema_hash().expect("schema hash"),
410            m2.schema_hash().expect("schema hash")
411        );
412    }
413
414    #[test]
415    fn test_fk_serialization() {
416        let manifest = sample_manifest();
417        let json = serde_json::to_string(&manifest).unwrap();
418        assert!(json.contains("\"references\":\"users.id\""));
419        assert!(json.contains("\"on_delete\":\"cascade\""));
420
421        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
422        let user_id_col = &parsed.tables[0].columns[2];
423        assert_eq!(user_id_col.references.as_deref(), Some("users.id"));
424        assert_eq!(user_id_col.on_delete, Some(ForeignKeyAction::Cascade));
425    }
426
427    #[test]
428    fn test_service_mode_default() {
429        let json = r#"{"name":"svc","tables":[]}"#;
430        let m: ServiceManifest = serde_json::from_str(json).unwrap();
431        assert_eq!(m.mode, ServiceMode::Crud);
432    }
433
434    #[test]
435    fn test_on_migrate_defaults_to_none() {
436        let json = r#"{"name":"svc","tables":[]}"#;
437        let m: ServiceManifest = serde_json::from_str(json).unwrap();
438        assert!(m.on_migrate.is_none());
439    }
440
441    #[test]
442    fn test_on_migrate_serialization_roundtrip() {
443        let mut m = sample_manifest();
444        m.on_migrate = Some("handle_on_migrate".into());
445        let json = serde_json::to_string(&m).unwrap();
446        assert!(json.contains("\"on_migrate\":\"handle_on_migrate\""));
447        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
448        assert_eq!(parsed.on_migrate.as_deref(), Some("handle_on_migrate"));
449    }
450
451    #[test]
452    fn test_on_migrate_skipped_when_none() {
453        let m = sample_manifest();
454        let json = serde_json::to_string(&m).unwrap();
455        assert!(!json.contains("on_migrate"));
456    }
457
458    #[test]
459    fn deploy_hash_covers_fields_schema_hash_ignores() {
460        let m1 = sample_manifest();
461        let mut m2 = sample_manifest();
462        let hash = |m: &ServiceManifest| m.deploy_hash().expect("hash");
463        assert_eq!(hash(&m1), hash(&m2));
464        m2.publishes = Some(vec!["acme.orders.>".into()]);
465        assert_ne!(hash(&m1), hash(&m2));
466        assert_eq!(
467            m1.schema_hash().expect("schema hash"),
468            m2.schema_hash().expect("schema hash")
469        );
470    }
471
472    #[test]
473    fn test_on_migrate_changes_schema_hash() {
474        let m1 = sample_manifest();
475        let mut m2 = sample_manifest();
476        m2.on_migrate = Some("handle_on_migrate".into());
477        assert_ne!(
478            m1.schema_hash().expect("schema hash"),
479            m2.schema_hash().expect("schema hash")
480        );
481    }
482
483    #[test]
484    fn test_requires_wasm_false_for_plain_crud() {
485        let m = sample_manifest();
486        assert!(!m.requires_wasm());
487    }
488
489    #[test]
490    fn test_requires_wasm_true_for_wasm_mode() {
491        let mut m = sample_manifest();
492        m.mode = ServiceMode::Wasm;
493        assert!(m.requires_wasm());
494    }
495
496    #[test]
497    fn test_requires_wasm_true_for_on_migrate() {
498        let mut m = sample_manifest();
499        m.on_migrate = Some("handle_on_migrate".into());
500        assert!(m.requires_wasm());
501    }
502
503    #[test]
504    fn test_requires_wasm_true_for_table_hooks() {
505        let mut m = sample_manifest();
506        m.tables[0].hooks = Some(TableHooks {
507            before_create: Some("validate".into()),
508            ..Default::default()
509        });
510        assert!(m.requires_wasm());
511    }
512
513    #[test]
514    fn test_requires_wasm_true_for_custom_routes() {
515        let mut m = sample_manifest();
516        m.custom_routes.push(CustomRouteDefinition {
517            method: "GET".into(),
518            path: "/hello".into(),
519            handler: "hello".into(),
520            job: None,
521            posture: None,
522            forward_auth: false,
523            ..Default::default()
524        });
525        assert!(m.requires_wasm());
526    }
527
528    #[test]
529    fn test_requires_wasm_true_for_wasm_subscription() {
530        let mut m = sample_manifest();
531        m.subscriptions.push(SubscriptionDefinition {
532            subject: "events.foo".into(),
533            handler: "on_foo".into(),
534            handler_type: HandlerType::Wasm,
535            config: HashMap::new(),
536            stream: None,
537        });
538        assert!(m.requires_wasm());
539    }
540
541    #[test]
542    fn test_requires_wasm_false_for_non_wasm_subscription() {
543        let mut m = sample_manifest();
544        m.subscriptions.push(SubscriptionDefinition {
545            subject: "events.foo".into(),
546            handler: "on_foo".into(),
547            handler_type: HandlerType::Webhook,
548            config: HashMap::new(),
549            stream: None,
550        });
551        assert!(!m.requires_wasm());
552    }
553
554    #[test]
555    fn test_declared_wasm_handlers_collects_all_sources() {
556        let mut m = sample_manifest();
557        m.on_migrate = Some("on_mig".into());
558        m.custom_routes.push(CustomRouteDefinition {
559            method: "GET".into(),
560            path: "/h".into(),
561            handler: "route_h".into(),
562            job: None,
563            posture: None,
564            forward_auth: false,
565            ..Default::default()
566        });
567        m.subscriptions.push(SubscriptionDefinition {
568            subject: "x".into(),
569            handler: "sub_h".into(),
570            handler_type: HandlerType::Wasm,
571            config: HashMap::new(),
572            stream: None,
573        });
574        m.subscriptions.push(SubscriptionDefinition {
575            subject: "y".into(),
576            handler: "wh_ignored".into(),
577            handler_type: HandlerType::Webhook,
578            config: HashMap::new(),
579            stream: None,
580        });
581        m.tables[0].hooks = Some(TableHooks {
582            before_create: Some("bc".into()),
583            after_create: Some("ac".into()),
584            before_delete: Some("bc".into()), // duplicate handler name
585            ..Default::default()
586        });
587        let handlers = m.declared_wasm_handlers();
588        assert!(handlers.contains(&"on_mig"));
589        assert!(handlers.contains(&"route_h"));
590        assert!(handlers.contains(&"sub_h"));
591        assert!(handlers.contains(&"bc"));
592        assert!(handlers.contains(&"ac"));
593        assert!(!handlers.contains(&"wh_ignored"));
594        // Deduplicated
595        assert_eq!(handlers.iter().filter(|h| **h == "bc").count(), 1);
596    }
597
598    #[test]
599    fn http_route_exposes_only_custom_routes() {
600        let mut m = sample_manifest();
601        m.on_migrate = Some("on_mig".into());
602        m.custom_routes.push(CustomRouteDefinition {
603            method: "GET".into(),
604            path: "/h".into(),
605            handler: "route_h".into(),
606            job: None,
607            posture: None,
608            forward_auth: false,
609            ..Default::default()
610        });
611        m.subscriptions.push(SubscriptionDefinition {
612            subject: "evt.sub".into(),
613            handler: "sub_h".into(),
614            handler_type: HandlerType::Wasm,
615            config: HashMap::new(),
616            stream: None,
617        });
618        m.tables[0].hooks = Some(TableHooks {
619            after_create: Some("hook_h".into()),
620            ..Default::default()
621        });
622
623        assert_eq!(
624            m.http_route("route_h").map(|r| r.handler.as_str()),
625            Some("route_h")
626        );
627        assert!(m.http_route("sub_h").is_none());
628        assert!(m.http_route("on_mig").is_none());
629        assert!(m.http_route("hook_h").is_none());
630        assert!(m.http_route("unknown").is_none());
631    }
632
633    #[test]
634    fn http_route_allows_handler_declared_as_both_route_and_subscription() {
635        let mut m = sample_manifest();
636        m.custom_routes.push(CustomRouteDefinition {
637            method: "POST".into(),
638            path: "/dual".into(),
639            handler: "dual".into(),
640            job: Some(JobConfig::default()),
641            posture: None,
642            forward_auth: false,
643            ..Default::default()
644        });
645        m.subscriptions.push(SubscriptionDefinition {
646            subject: "evt.dual".into(),
647            handler: "dual".into(),
648            handler_type: HandlerType::Wasm,
649            config: HashMap::new(),
650            stream: None,
651        });
652
653        let route = m
654            .http_route("dual")
655            .expect("dual-purpose handler stays HTTP-invocable");
656        assert!(route.job.is_some());
657    }
658
659    #[test]
660    fn posture_aware_detects_any_declared_route() {
661        let mut m = sample_manifest();
662        assert!(!m.is_posture_aware());
663        m.custom_routes.push(CustomRouteDefinition {
664            method: "GET".into(),
665            path: "/a".into(),
666            handler: "a".into(),
667            job: None,
668            posture: None,
669            forward_auth: false,
670            ..Default::default()
671        });
672        assert!(!m.is_posture_aware());
673        m.custom_routes.push(CustomRouteDefinition {
674            method: "GET".into(),
675            path: "/b".into(),
676            handler: "b".into(),
677            job: None,
678            posture: Some(RoutePosture::Authenticated),
679            forward_auth: false,
680            ..Default::default()
681        });
682        assert!(m.is_posture_aware());
683    }
684
685    #[test]
686    fn manifest_publishes_and_features_default_absent() {
687        let json = serde_json::to_value(sample_manifest()).unwrap();
688        assert!(json.get("publishes").is_none());
689        assert!(json.get("required_platform_features").is_none());
690        let legacy = r#"{"name":"s","tables":[],"mode":"crud"}"#;
691        let parsed: ServiceManifest = serde_json::from_str(legacy).unwrap();
692        assert!(parsed.publishes.is_none());
693        assert!(parsed.required_platform_features.is_empty());
694    }
695}