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