Skip to main content

cufflink_types/manifest/
route.rs

1use crate::features;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::BTreeMap;
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7pub struct CustomRouteDefinition {
8    pub method: String,
9    pub path: String,
10    pub handler: String,
11    /// When present, the HTTP route accepts the request, persists a
12    /// `wasm_jobs` row, publishes to JetStream, and returns `202 Accepted`
13    /// with `{job_id}`. The handler executes asynchronously on a worker
14    /// with no inline 30s wall.
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub job: Option<JobConfig>,
17    /// Who may call this route. `None` marks a manifest built before route
18    /// postures existed: the platform runs it in legacy mode (best-effort auth,
19    /// no enforcement) and warns. `Some` — always emitted by the current SDK —
20    /// makes the platform enforce the declared posture.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub posture: Option<RoutePosture>,
23    /// Pass the caller's `authorization`, `cookie` and `proxy-authorization`
24    /// headers into the guest. Without it they are stripped once the tenant's
25    /// `tenant_binding` policy is enforced.
26    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
27    pub forward_auth: bool,
28    /// The largest request body the route accepts; 4 MiB when unset.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub body_limit_bytes: Option<u64>,
31    /// The route's execution budget; 30 s when unset.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub timeout_secs: Option<u32>,
34    /// Parse a `multipart/form-data` body into parts for the guest.
35    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
36    pub multipart: bool,
37    /// Serve the route as a server-sent event stream fed by a NATS subject.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub sse: Option<SseRouteDef>,
40    /// The route's OpenAPI description; documentation only.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub doc: Option<RouteDoc>,
43}
44
45/// How a custom route appears in the service's OpenAPI spec. Schemas are
46/// JSON Schema (draft 2020-12) referring to `#/components/schemas/…`.
47#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
48pub struct RouteDoc {
49    /// Defaults to the handler name.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub operation_id: Option<String>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub summary: Option<String>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub description: Option<String>,
56    /// Defaults to the service name.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub tags: Vec<String>,
59    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
60    pub deprecated: bool,
61    /// An object schema whose properties are the path parameters.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub path_params: Option<Value>,
64    /// An object schema whose properties are the query parameters.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub query: Option<Value>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub request: Option<BodyDoc>,
69    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
70    pub responses: BTreeMap<u16, ResponseDoc>,
71    /// Security scheme names; `None` derives them from the posture.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub security: Option<Vec<String>>,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct BodyDoc {
78    pub content_type: String,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub schema: Option<Value>,
81    pub required: bool,
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub struct ResponseDoc {
86    pub description: String,
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub content: Vec<MediaDoc>,
89}
90
91impl ResponseDoc {
92    /// The description a response gets when its declaration gives none: the
93    /// status's reason phrase.
94    pub fn default_description(status: u16) -> &'static str {
95        match status {
96            200 => "OK",
97            201 => "Created",
98            202 => "Accepted",
99            204 => "No Content",
100            301 => "Moved Permanently",
101            302 => "Found",
102            303 => "See Other",
103            304 => "Not Modified",
104            307 => "Temporary Redirect",
105            308 => "Permanent Redirect",
106            400 => "Bad Request",
107            401 => "Unauthorized",
108            403 => "Forbidden",
109            404 => "Not Found",
110            405 => "Method Not Allowed",
111            409 => "Conflict",
112            413 => "Payload Too Large",
113            415 => "Unsupported Media Type",
114            422 => "Unprocessable Entity",
115            429 => "Too Many Requests",
116            500 => "Internal Server Error",
117            502 => "Bad Gateway",
118            503 => "Service Unavailable",
119            504 => "Gateway Timeout",
120            _ => "Response",
121        }
122    }
123}
124
125/// A response body of one media type; without a schema it documents a body
126/// of that type (a file) with no structure.
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub struct MediaDoc {
129    pub content_type: String,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub schema: Option<Value>,
132}
133
134/// The NATS subject an event-stream route bridges, and the handler that
135/// turns each message into events.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct SseRouteDef {
138    /// Dot-separated tokens starting `dw.{tenant}.`; a `{name}` token is the
139    /// route's path parameter of that name. `*` and `>` wildcards are allowed.
140    pub subject: String,
141    /// Exported handler reducing each message; without one every message is
142    /// forwarded as a data-only event.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub reducer: Option<String>,
145    /// Streams to callers the route does not authenticate: required on a
146    /// public or undeclared posture.
147    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
148    pub anonymous: bool,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum SseSubjectError {
153    /// A `{name}` placeholder with no value.
154    Missing(String),
155    /// A value that is not a single subject token.
156    InvalidToken(String),
157}
158
159impl std::fmt::Display for SseSubjectError {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        match self {
162            Self::Missing(name) => write!(f, "the event subject needs path parameter '{name}'"),
163            Self::InvalidToken(name) => write!(
164                f,
165                "path parameter '{name}' must be one subject token (no '.', '*', '>' or whitespace)"
166            ),
167        }
168    }
169}
170
171impl std::error::Error for SseSubjectError {}
172
173impl SseRouteDef {
174    /// Every subject starts with this: an event stream reads only its own
175    /// tenant's subjects.
176    pub const SUBJECT_PREFIX: &'static str = "dw.{tenant}.";
177
178    /// The method of the request a reducer receives.
179    pub const REDUCER_METHOD: &'static str = "SSE_EVENT";
180
181    /// Tokens after the prefix the platform itself publishes under (the
182    /// tenant's audit log), never bridged.
183    pub const RESERVED_TOKENS: &'static [&'static str] = &["logs"];
184
185    /// Why the subject cannot be bridged for a route whose path parameters
186    /// are `params` (`None` on a legacy route, which has none): it must be
187    /// dot-separated tokens under the prefix, name its events with a literal
188    /// token that is not reserved, and bind every `{name}` but `{tenant}`
189    /// to a path parameter.
190    pub fn subject_violation(&self, params: Option<&[String]>) -> Option<String> {
191        let tokens: Vec<&str> = self.subject.split('.').collect();
192        let malformed = tokens.iter().any(|token| {
193            token.is_empty()
194                || (token.contains(['{', '}']) && placeholder(token).is_none_or(str::is_empty))
195        });
196        if !self.subject.starts_with(Self::SUBJECT_PREFIX) || malformed {
197            return Some(format!(
198                "sse subject '{}' must be dot-separated tokens starting {}",
199                self.subject,
200                Self::SUBJECT_PREFIX
201            ));
202        }
203        let named = tokens.get(2).copied().unwrap_or_default();
204        if matches!(named, "*" | ">") || placeholder(named).is_some() {
205            return Some(format!(
206                "sse subject '{}' must name its events with a literal token after {}",
207                self.subject,
208                Self::SUBJECT_PREFIX
209            ));
210        }
211        if Self::RESERVED_TOKENS.contains(&named) {
212            return Some(format!(
213                "sse subject '{}' reads dw.{{tenant}}.{named}, which the platform reserves",
214                self.subject
215            ));
216        }
217        let unbound = self.placeholders().find(|name| {
218            *name != "tenant" && !params.is_some_and(|params| params.iter().any(|p| p == name))
219        })?;
220        Some(match params {
221            Some(_) => format!(
222                "sse subject placeholder '{{{unbound}}}' is not a path parameter of this strict route"
223            ),
224            None => format!(
225                "sse subject placeholder '{{{unbound}}}' needs routing: strict, where it names a path parameter"
226            ),
227        })
228    }
229
230    /// Why the stream cannot be served on a posture that admits anonymous
231    /// callers (public or undeclared) unless it opts in.
232    pub fn anonymous_violation(&self, anonymous_posture: bool) -> Option<&'static str> {
233        (anonymous_posture && !self.anonymous).then_some(
234            "sse on a public or undeclared posture streams to anonymous callers; \
235             declare anonymous = true to allow it",
236        )
237    }
238
239    /// The names of the template's `{name}` tokens, `tenant` included.
240    pub fn placeholders(&self) -> impl Iterator<Item = &str> {
241        self.subject.split('.').filter_map(placeholder)
242    }
243
244    /// The subject for `tenant` and the route's path parameters.
245    pub fn resolve(
246        &self,
247        tenant: &str,
248        params: &[(String, String)],
249    ) -> Result<String, SseSubjectError> {
250        self.subject
251            .split('.')
252            .map(|token| match placeholder(token) {
253                None => Ok(token),
254                Some("tenant") => Ok(tenant),
255                Some(name) => params
256                    .iter()
257                    .find(|(param, _)| param == name)
258                    .map(|(_, value)| value.as_str())
259                    .ok_or_else(|| SseSubjectError::Missing(name.to_string()))
260                    .and_then(|value| {
261                        if is_subject_token(value) {
262                            Ok(value)
263                        } else {
264                            Err(SseSubjectError::InvalidToken(name.to_string()))
265                        }
266                    }),
267            })
268            .collect::<Result<Vec<_>, _>>()
269            .map(|tokens| tokens.join("."))
270    }
271}
272
273fn placeholder(token: &str) -> Option<&str> {
274    token.strip_prefix('{').and_then(|t| t.strip_suffix('}'))
275}
276
277fn is_subject_token(value: &str) -> bool {
278    !value.is_empty()
279        && !value
280            .chars()
281            .any(|c| matches!(c, '.' | '*' | '>') || c.is_whitespace())
282}
283
284/// The authorization posture of a custom route, enforced by the platform so a
285/// guest handler cannot forget it.
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
287#[serde(tag = "kind", rename_all = "snake_case")]
288pub enum RoutePosture {
289    /// Deliberately anonymous. `reason` is mandatory so every public route is
290    /// greppable and reviewable (third-party webhooks, signup, public profiles).
291    Public { reason: String },
292    /// Any authenticated tenant principal.
293    Authenticated,
294    /// Authenticated and holding the `{area}:{operation}` permission.
295    Permission { area: String, operation: String },
296    /// Authenticated; the guest scopes the operation to rows whose `field`
297    /// equals the caller's subject. The platform enforces authentication.
298    Owner { field: String },
299    /// Only another service of the tenant, calling through the platform
300    /// with a platform-minted identity: `callers` names the services
301    /// allowed, or is `["*"]` for any. User credentials are not accepted.
302    Internal { callers: Vec<String> },
303    /// Only a partner key (`X-Api-Key`) of the tenant holding a scope that
304    /// covers `scope` (`resource[:action]`, `resource:*` covering every
305    /// action). User credentials are not accepted.
306    ApiKey { scope: String },
307    /// Authenticated and holding a grant on `{area}:{operation}`; the
308    /// platform resolves the grants' scopes (`@own`, `@field(…)`, `@all`)
309    /// into the rows the guest may reach, `@own` meaning `owner_field`
310    /// holds the caller's subject.
311    Scoped {
312        area: String,
313        operation: String,
314        #[serde(default, skip_serializing_if = "Option::is_none")]
315        owner_field: Option<String>,
316    },
317}
318
319impl CustomRouteDefinition {
320    /// The body cap of a route that declares none.
321    pub const DEFAULT_BODY_LIMIT_BYTES: u64 = 4 * 1024 * 1024;
322    /// The largest `body_limit_bytes` a route may declare.
323    pub const MAX_BODY_LIMIT_BYTES: u64 = 64 * 1024 * 1024;
324    /// The range `timeout_secs` must fall in.
325    pub const TIMEOUT_SECS: std::ops::RangeInclusive<u32> = 1..=120;
326    /// The methods a multipart route may use.
327    pub const MULTIPART_METHODS: [&'static str; 3] = ["POST", "PUT", "PATCH"];
328
329    /// Platform features this route's declaration requires.
330    pub fn derived_features(&self) -> impl Iterator<Item = &'static str> {
331        let limits =
332            self.body_limit_bytes.is_some() || self.timeout_secs.is_some() || self.multipart;
333        [
334            self.posture.is_some().then_some(features::ROUTE_POSTURE),
335            limits.then_some(features::ROUTE_LIMITS),
336            self.sse.is_some().then_some(features::SSE_BRIDGE),
337            self.posture.as_ref().and_then(RoutePosture::feature),
338        ]
339        .into_iter()
340        .flatten()
341    }
342
343    /// Whether the guest receives the R2 request keys (`query`, `client_ip`,
344    /// …): only a route declaring an R2 capability does, so every other
345    /// guest's request JSON stays byte-identical.
346    pub fn uses_request_v2(&self) -> bool {
347        self.derived_features()
348            .any(|feature| feature != features::ROUTE_POSTURE)
349    }
350}
351
352impl RoutePosture {
353    /// Whether a caller with no credentials can reach this route.
354    pub fn is_anonymous(&self) -> bool {
355        matches!(self, RoutePosture::Public { .. })
356    }
357
358    /// Short human label for audit output.
359    pub fn label(&self) -> &'static str {
360        match self {
361            RoutePosture::Public { .. } => "public",
362            RoutePosture::Authenticated => "authenticated",
363            RoutePosture::Permission { .. } => "permission",
364            RoutePosture::Owner { .. } => "owner",
365            RoutePosture::Internal { .. } => "internal",
366            RoutePosture::ApiKey { .. } => "api_key",
367            RoutePosture::Scoped { .. } => "scoped",
368        }
369    }
370
371    /// The platform feature this posture needs beyond `route_posture`.
372    pub fn feature(&self) -> Option<&'static str> {
373        match self {
374            RoutePosture::Internal { .. } => Some(features::INTERNAL_POSTURE),
375            RoutePosture::ApiKey { .. } => Some(features::API_KEY_POSTURE),
376            RoutePosture::Scoped { .. } => Some(features::SCOPED_POSTURE),
377            RoutePosture::Public { .. }
378            | RoutePosture::Authenticated
379            | RoutePosture::Permission { .. }
380            | RoutePosture::Owner { .. } => None,
381        }
382    }
383
384    /// Whether `service` may call a route of this posture: listed, or any
385    /// service when `callers` is `["*"]`.
386    pub fn admits_caller(callers: &[String], service: &str) -> bool {
387        callers
388            .iter()
389            .any(|caller| caller == "*" || caller == service)
390    }
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
394pub struct JobConfig {
395    /// Per-handler wall-clock ceiling enforced by the worker.
396    pub timeout_secs: u32,
397    /// Maximum delivery attempts before the job is permanently failed.
398    /// `1` disables retry-on-crash.
399    pub max_attempts: u32,
400}
401
402impl JobConfig {
403    pub const DEFAULT_TIMEOUT_SECS: u32 = 600;
404    pub const DEFAULT_MAX_ATTEMPTS: u32 = 3;
405}
406
407impl Default for JobConfig {
408    fn default() -> Self {
409        Self {
410            timeout_secs: Self::DEFAULT_TIMEOUT_SECS,
411            max_attempts: Self::DEFAULT_MAX_ATTEMPTS,
412        }
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn job_config_defaults() {
422        let cfg = JobConfig::default();
423        assert_eq!(cfg.timeout_secs, JobConfig::DEFAULT_TIMEOUT_SECS);
424        assert_eq!(cfg.max_attempts, JobConfig::DEFAULT_MAX_ATTEMPTS);
425    }
426
427    #[test]
428    fn custom_route_serde_round_trip_with_job() {
429        let route = CustomRouteDefinition {
430            method: "POST".into(),
431            path: "/run".into(),
432            handler: "do_thing".into(),
433            job: Some(JobConfig {
434                timeout_secs: 900,
435                max_attempts: 2,
436            }),
437            posture: None,
438            forward_auth: false,
439            ..Default::default()
440        };
441        let json = serde_json::to_string(&route).unwrap();
442        let round: CustomRouteDefinition = serde_json::from_str(&json).unwrap();
443        assert_eq!(round.method, "POST");
444        let job = round.job.unwrap();
445        assert_eq!(job.timeout_secs, 900);
446        assert_eq!(job.max_attempts, 2);
447    }
448
449    #[test]
450    fn forward_auth_defaults_off_and_is_only_serialized_when_set() {
451        let stored: CustomRouteDefinition =
452            serde_json::from_str(r#"{"method":"GET","path":"/x","handler":"x"}"#).unwrap();
453        assert!(!stored.forward_auth);
454        let json = serde_json::to_string(&stored).unwrap();
455        assert!(!json.contains("forward_auth"), "{json}");
456        let forwarding = CustomRouteDefinition {
457            forward_auth: true,
458            ..stored
459        };
460        let json = serde_json::to_value(&forwarding).unwrap();
461        assert_eq!(json["forward_auth"], true);
462    }
463
464    #[test]
465    fn custom_route_without_job_serializes_without_field() {
466        let route = CustomRouteDefinition {
467            method: "GET".into(),
468            path: "/x".into(),
469            handler: "x".into(),
470            job: None,
471            posture: None,
472            forward_auth: false,
473            ..Default::default()
474        };
475        let json = serde_json::to_value(&route).unwrap();
476        assert!(json.get("job").is_none(), "absent job should be omitted");
477        assert!(
478            json.get("posture").is_none(),
479            "absent posture should be omitted"
480        );
481    }
482
483    #[test]
484    fn custom_route_legacy_json_without_job_deserializes() {
485        let json = r#"{"method":"GET","path":"/x","handler":"x"}"#;
486        let route: CustomRouteDefinition = serde_json::from_str(json).unwrap();
487        assert!(route.job.is_none());
488        assert!(route.posture.is_none());
489    }
490
491    #[test]
492    fn sse_subjects_substitute_tenant_and_path_parameters() {
493        let sse = SseRouteDef {
494            subject: "dw.{tenant}.quotes.pdf.progress.{lead_id}".into(),
495            reducer: Some("reduce".into()),
496            anonymous: false,
497        };
498        assert_eq!(
499            sse.placeholders().collect::<Vec<_>>(),
500            ["tenant", "lead_id"]
501        );
502        let params = |value: &str| vec![("lead_id".to_string(), value.to_string())];
503        assert_eq!(
504            sse.resolve("acme", &params("L-7")),
505            Ok("dw.acme.quotes.pdf.progress.L-7".into())
506        );
507        for bad in ["a.b", "*", ">", "a b", ""] {
508            assert_eq!(
509                sse.resolve("acme", &params(bad)),
510                Err(SseSubjectError::InvalidToken("lead_id".into())),
511                "{bad}"
512            );
513        }
514        assert_eq!(
515            sse.resolve("acme", &[]),
516            Err(SseSubjectError::Missing("lead_id".into()))
517        );
518        let wildcard = SseRouteDef {
519            subject: "dw.{tenant}.jobs.>".into(),
520            reducer: None,
521            anonymous: false,
522        };
523        assert_eq!(wildcard.resolve("acme", &[]), Ok("dw.acme.jobs.>".into()));
524    }
525
526    #[test]
527    fn sse_subjects_name_unreserved_events_and_bind_their_placeholders() {
528        let sse = |subject: &str| SseRouteDef {
529            subject: subject.into(),
530            reducer: None,
531            anonymous: false,
532        };
533        let params = ["id".to_string()];
534        for ok in [
535            "dw.{tenant}.jobs.>",
536            "dw.{tenant}.q.{id}",
537            "dw.{tenant}.q.*",
538        ] {
539            assert_eq!(sse(ok).subject_violation(Some(&params)), None, "{ok}");
540        }
541        for (subject, params, needle) in [
542            (
543                "dw.other.q",
544                Some(&params[..]),
545                "must be dot-separated tokens",
546            ),
547            (
548                "dw.{tenant}.",
549                Some(&params[..]),
550                "must be dot-separated tokens",
551            ),
552            (
553                "dw.{tenant}.q.{}",
554                Some(&params[..]),
555                "must be dot-separated tokens",
556            ),
557            ("dw.{tenant}.>", Some(&params[..]), "with a literal token"),
558            (
559                "dw.{tenant}.{id}.x",
560                Some(&params[..]),
561                "with a literal token",
562            ),
563            (
564                "dw.{tenant}.logs",
565                Some(&params[..]),
566                "the platform reserves",
567            ),
568            (
569                "dw.{tenant}.q.{other}",
570                Some(&params[..]),
571                "not a path parameter",
572            ),
573            ("dw.{tenant}.q.{id}", None, "needs routing: strict"),
574        ] {
575            let violation = sse(subject).subject_violation(params).unwrap_or_default();
576            assert!(violation.contains(needle), "{subject}: {violation}");
577        }
578        assert!(sse("dw.{tenant}.q").anonymous_violation(true).is_some());
579        assert!(sse("dw.{tenant}.q").anonymous_violation(false).is_none());
580        let opted_in = SseRouteDef {
581            anonymous: true,
582            ..sse("dw.{tenant}.q")
583        };
584        assert!(opted_in.anonymous_violation(true).is_none());
585        assert_eq!(
586            serde_json::to_value(&opted_in).unwrap(),
587            serde_json::json!({"subject": "dw.{tenant}.q", "anonymous": true})
588        );
589    }
590
591    #[test]
592    fn internal_routes_demand_their_feature_and_admit_listed_callers() {
593        let route = CustomRouteDefinition {
594            posture: Some(RoutePosture::Internal {
595                callers: vec!["pricing".into()],
596            }),
597            ..Default::default()
598        };
599        assert_eq!(
600            route.derived_features().collect::<Vec<_>>(),
601            [features::ROUTE_POSTURE, features::INTERNAL_POSTURE]
602        );
603        assert!(route.uses_request_v2());
604        assert_eq!(
605            serde_json::to_value(route.posture).unwrap(),
606            serde_json::json!({"kind": "internal", "callers": ["pricing"]})
607        );
608        let listed = ["pricing".to_string()];
609        assert!(RoutePosture::admits_caller(&listed, "pricing"));
610        assert!(!RoutePosture::admits_caller(&listed, "assets"));
611        assert!(RoutePosture::admits_caller(&["*".to_string()], "assets"));
612        assert!(!RoutePosture::admits_caller(&[], "assets"));
613    }
614
615    #[test]
616    fn api_key_routes_demand_their_feature() {
617        let route = CustomRouteDefinition {
618            posture: Some(RoutePosture::ApiKey {
619                scope: "leads:ingest".into(),
620            }),
621            ..Default::default()
622        };
623        assert_eq!(
624            route.derived_features().collect::<Vec<_>>(),
625            [features::ROUTE_POSTURE, features::API_KEY_POSTURE]
626        );
627        assert_eq!(
628            serde_json::to_value(route.posture).unwrap(),
629            serde_json::json!({"kind": "api_key", "scope": "leads:ingest"})
630        );
631    }
632
633    #[test]
634    fn scoped_routes_demand_their_feature() {
635        let route = CustomRouteDefinition {
636            posture: Some(RoutePosture::Scoped {
637                area: "assets".into(),
638                operation: "view".into(),
639                owner_field: None,
640            }),
641            ..Default::default()
642        };
643        assert_eq!(
644            route.derived_features().collect::<Vec<_>>(),
645            [features::ROUTE_POSTURE, features::SCOPED_POSTURE]
646        );
647        assert_eq!(
648            serde_json::to_value(route.posture).unwrap(),
649            serde_json::json!({"kind": "scoped", "area": "assets", "operation": "view"})
650        );
651    }
652
653    #[test]
654    fn route_posture_serde_tagged_round_trip() {
655        let cases = [
656            RoutePosture::Public {
657                reason: "stripe webhook".into(),
658            },
659            RoutePosture::Authenticated,
660            RoutePosture::Permission {
661                area: "items".into(),
662                operation: "edit".into(),
663            },
664            RoutePosture::Owner {
665                field: "customer_id".into(),
666            },
667            RoutePosture::Internal {
668                callers: vec!["pricing-service".into()],
669            },
670            RoutePosture::ApiKey {
671                scope: "leads:ingest".into(),
672            },
673            RoutePosture::Scoped {
674                area: "assets".into(),
675                operation: "view".into(),
676                owner_field: Some("customer_id".into()),
677            },
678            RoutePosture::Scoped {
679                area: "assets".into(),
680                operation: "view".into(),
681                owner_field: None,
682            },
683        ];
684        for p in cases {
685            let json = serde_json::to_string(&p).unwrap();
686            let round: RoutePosture = serde_json::from_str(&json).unwrap();
687            assert_eq!(round, p);
688        }
689        let public = serde_json::to_value(RoutePosture::Authenticated).unwrap();
690        assert_eq!(public["kind"], "authenticated");
691    }
692}