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