Skip to main content

featherbit/admin/
policies.rs

1//! Admin API endpoints for policy CRUD, plus catalogs of available plugin
2//! types and on-disk scripts. Policy mutations rewrite the in-memory gateway
3//! config and trigger validation + graph recompilation via `SharedState::reload`.
4
5use std::sync::Arc;
6
7use axum::extract::{Path, State};
8use axum::http::StatusCode;
9use axum::response::IntoResponse;
10use axum::routing::{get, post};
11use axum::{Json, Router};
12
13use crate::config::PolicyConfig;
14use crate::state::SharedState;
15
16/// Builds the router for `/api/policies`, `/api/plugins`, and `/api/scripts`.
17pub fn router() -> Router<Arc<SharedState>> {
18    Router::new()
19        .route("/api/policies", get(list_policies))
20        .route("/api/policies/validate", post(validate_policy))
21        .route(
22            "/api/policies/{name}",
23            get(get_policy).put(update_policy).delete(delete_policy),
24        )
25        .route("/api/plugins", get(list_plugin_types))
26        .route("/api/scripts", get(list_scripts))
27}
28
29/// `GET /api/policies` — returns all configured policies as a JSON array.
30async fn list_policies(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
31    let gw = state.gateway.read().await;
32    Json(&gw.policies).into_response()
33}
34
35/// `GET /api/policies/{name}` — returns the named policy as JSON.
36///
37/// Errors: `404 Not Found` if no policy with that name exists.
38async fn get_policy(
39    State(state): State<Arc<SharedState>>,
40    Path(name): Path<String>,
41) -> impl IntoResponse {
42    let gw = state.gateway.read().await;
43    match gw.policies.iter().find(|p| p.name == name) {
44        Some(policy) => Json(policy).into_response(),
45        None => (
46            StatusCode::NOT_FOUND,
47            Json(serde_json::json!({"error": "not_found"})),
48        )
49            .into_response(),
50    }
51}
52
53/// `PUT /api/policies/{name}` — upserts a policy from the JSON body (the
54/// path name overrides any name in the body), then revalidates and
55/// recompiles all route graphs. Returns `{"status": "updated"}` on success.
56///
57/// Errors: `400 Bad Request` if the resulting configuration fails
58/// validation/recompilation (the previous compiled routes stay active).
59async fn update_policy(
60    State(state): State<Arc<SharedState>>,
61    Path(name): Path<String>,
62    Json(mut policy): Json<PolicyConfig>,
63) -> impl IntoResponse {
64    policy.name = name.clone();
65    let candidate = {
66        let gw = state.gateway.read().await;
67        let mut candidate = gw.clone();
68        if let Some(existing) = candidate.policies.iter_mut().find(|p| p.name == name) {
69            *existing = policy;
70        } else {
71            candidate.policies.push(policy);
72        }
73        candidate
74    };
75
76    match state.config_store.clone().commit(&state, candidate).await {
77        Ok(_) => Json(serde_json::json!({"status": "updated"})).into_response(),
78        Err(e) => (
79            StatusCode::BAD_REQUEST,
80            Json(serde_json::json!({"error": e})),
81        )
82            .into_response(),
83    }
84}
85
86/// `DELETE /api/policies/{name}` — removes the named policy, then
87/// revalidates and recompiles. Returns `{"status": "deleted"}` on success.
88///
89/// Errors: `404 Not Found` if the policy does not exist; `400 Bad Request`
90/// if recompilation fails (e.g. a route still references the policy).
91async fn delete_policy(
92    State(state): State<Arc<SharedState>>,
93    Path(name): Path<String>,
94) -> impl IntoResponse {
95    let candidate = {
96        let gw = state.gateway.read().await;
97        let mut candidate = gw.clone();
98        let before = candidate.policies.len();
99        candidate.policies.retain(|p| p.name != name);
100        if candidate.policies.len() == before {
101            return (
102                StatusCode::NOT_FOUND,
103                Json(serde_json::json!({"error": "not_found"})),
104            )
105                .into_response();
106        }
107        candidate
108    };
109
110    match state.config_store.clone().commit(&state, candidate).await {
111        Ok(_) => Json(serde_json::json!({"status": "deleted"})).into_response(),
112        Err(e) => (
113            StatusCode::BAD_REQUEST,
114            Json(serde_json::json!({"error": e})),
115        )
116            .into_response(),
117    }
118}
119
120/// `POST /api/policies/validate` — validates + compiles a policy against the
121/// live supernodes, plugin configs and stores, without persisting it. Body is
122/// the policy definition itself (`{"nodes": [...], "edges": [...], ...}`); a
123/// `name` is optional and, if absent, is not saved anywhere. Mirrors the MCP
124/// `validate_policy` tool so a human in the UI and an agent driving the
125/// gateway see the same verdict.
126///
127/// Response: `{"valid": bool, "errors": [...], "buffering": [...]}`. A
128/// policy that forces one or more upstreams to buffer instead of stream is
129/// still `valid` — `buffering` is informational, not an error. Each entry
130/// names the blocked upstream and the node responsible:
131/// `{"upstream": "up", "blocked_by": "rw", "node_type": "response-rewrite"}`.
132async fn validate_policy(
133    State(state): State<Arc<SharedState>>,
134    Json(mut raw): Json<serde_json::Value>,
135) -> impl IntoResponse {
136    if let Some(obj) = raw.as_object_mut() {
137        obj.entry("name")
138            .or_insert_with(|| serde_json::Value::String("unsaved-policy".to_string()));
139    }
140    let policy: PolicyConfig = match serde_json::from_value(raw) {
141        Ok(p) => p,
142        Err(e) => {
143            return Json(serde_json::json!({
144                "valid": false,
145                "errors": [e.to_string()],
146                "buffering": []
147            }))
148            .into_response();
149        }
150    };
151
152    let (supernodes, plugin_configs) = {
153        let gw = state.gateway.read().await;
154        (gw.supernodes.clone(), gw.plugin_configs.clone())
155    };
156
157    let compiled = crate::graph::prepare_policy(policy, &supernodes, &plugin_configs)
158        .and_then(|p| crate::graph::compile_policy(&p, state.resources.clone()));
159
160    let (errors, buffering, cache_pairs): (Vec<String>, serde_json::Value, serde_json::Value) =
161        match compiled {
162            Ok(graph) => (
163                Vec::new(),
164                serde_json::to_value(graph.buffering_reasons())
165                    .expect("BufferingReason always serializes"),
166                serde_json::to_value(graph.cache_pair_warnings())
167                    .expect("CachePairWarning always serializes"),
168            ),
169            Err(e) => (
170                e.split("; ").map(str::to_string).collect(),
171                serde_json::json!([]),
172                serde_json::json!([]),
173            ),
174        };
175
176    Json(serde_json::json!({
177        "valid": errors.is_empty(),
178        "errors": errors,
179        "buffering": buffering,
180        "cache_pairs": cache_pairs
181    }))
182    .into_response()
183}
184
185/// `GET /api/scripts` — lists scripted-plugin files found in the `plugins/`
186/// directory next to the config directory (currently `.lua` only).
187///
188/// A missing or unreadable directory yields an empty list rather than an error.
189///
190/// ```json
191/// { "scripts": [ { "name": "my_filter", "file": "plugins/my_filter.lua", "runtime": "lua" } ] }
192/// ```
193async fn list_scripts(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
194    let mut scripts = Vec::new();
195
196    // Scan the config directory for a "plugins" subdirectory
197    let config_path = state
198        .config_path
199        .as_deref()
200        .unwrap_or(std::path::Path::new("config"));
201    let plugins_dir = config_path
202        .parent()
203        .unwrap_or(std::path::Path::new("."))
204        .join("plugins");
205
206    if let Ok(entries) = std::fs::read_dir(&plugins_dir) {
207        for entry in entries.flatten() {
208            let path = entry.path();
209            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
210            let name = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
211            let runtime = match ext {
212                "lua" => "lua",
213                _ => continue,
214            };
215            scripts.push(serde_json::json!({
216                "name": name,
217                "file": path.to_string_lossy(),
218                "runtime": runtime,
219            }));
220        }
221    }
222
223    Json(serde_json::json!({ "scripts": scripts }))
224}
225
226/// `GET /api/plugins` — returns the static catalog of node/plugin types
227/// (type id + human-readable description) that the UI's node-graph editor
228/// offers in its palette. Always `200 OK`.
229///
230/// Every type registered in [`crate::plugins::create_plugin`] appears here, in
231/// the same category order the plugin reference uses. Types the UI has no
232/// config form for still work: `NodeInspector` falls back to a raw-JSON config
233/// editor. Keep this in sync with the factory — `test_catalog_covers_factory`
234/// fails if a registered type is missing.
235async fn list_plugin_types() -> impl IntoResponse {
236    Json(serde_json::json!({ "plugins": plugin_catalog() }))
237}
238
239/// The palette catalog: `(type, description)` for every registered node type.
240pub(crate) fn plugin_catalog() -> Vec<serde_json::Value> {
241    const CATALOG: &[(&str, &str)] = &[
242        // Structural & core proxy
243        ("listener", "Route entry point — receives incoming request"),
244        ("client", "Route exit point — sends response to client"),
245        (
246            "condition",
247            "Branch the policy on boolean conditions (true/false ports)",
248        ),
249        ("upstream", "Forward to a load-balanced backend pool"),
250        ("proxy-rewrite", "Rewrite path, add/remove headers"),
251        (
252            "response-rewrite",
253            "Rewrite response status, headers, and body",
254        ),
255        (
256            "body-transformer",
257            "Rewrite request/response JSON bodies via templates",
258        ),
259        (
260            "set-vars",
261            "Derive variables from the context (templates, JSONPath, regex captures)",
262        ),
263        (
264            "degraphql",
265            "Expose a REST endpoint backed by a GraphQL upstream",
266        ),
267        ("redirect", "HTTP redirect, or force HTTP→HTTPS"),
268        ("echo", "Wrap or replace the response body (demo/testing)"),
269        ("gzip", "Compress the response body with gzip"),
270        ("brotli", "Compress the response body with Brotli"),
271        ("request-id", "Attach a unique request-id header"),
272        (
273            "real-ip",
274            "Recover the client IP from a trusted proxy header",
275        ),
276        // Error handling & mocking
277        ("error-handler", "Custom error responses"),
278        (
279            "error-page",
280            "Replace 404/500/502/503 bodies with configured pages",
281        ),
282        (
283            "exit-transformer",
284            "Remap status and body of gateway-generated exits",
285        ),
286        (
287            "mocking",
288            "Respond with a configured mock instead of proxying",
289        ),
290        // Security & access control
291        ("cors", "CORS header management"),
292        ("csrf", "Double-submit CSRF token validation"),
293        ("ip-restriction", "Allow/deny by IP/CIDR"),
294        ("ua-restriction", "Allow/deny by User-Agent regex"),
295        ("referer-restriction", "Allow/deny by Referer host"),
296        ("uri-blocker", "Block requests matching URI regex rules"),
297        ("request-size-limit", "Reject oversized requests"),
298        (
299            "request-validation",
300            "Validate headers/body against JSON Schema",
301        ),
302        (
303            "data-mask",
304            "Mask sensitive fields in bodies, headers, query",
305        ),
306        // Traffic control
307        ("rate-limit", "Token bucket rate limiting"),
308        ("limit-count", "Fixed-window request-count limiting"),
309        (
310            "limit-conn",
311            "Concurrent-request limiting (acquire/release pair)",
312        ),
313        ("api-breaker", "Circuit breaker on unhealthy upstreams"),
314        ("traffic-split", "Weighted / conditional traffic steering"),
315        ("proxy-mirror", "Fire-and-forget clone to a shadow upstream"),
316        (
317            "proxy-cache",
318            "Cache upstream responses (lookup/store pair, plus a purge phase)",
319        ),
320        ("fault-injection", "Inject delays and abort responses"),
321        (
322            "workflow",
323            "Ordered rules — reject or rate-limit the first match",
324        ),
325        (
326            "traffic-label",
327            "Tag matching requests with headers and labels",
328        ),
329        // Authentication & consumers
330        ("key-auth", "API key authentication"),
331        ("basic-auth", "HTTP Basic authentication"),
332        ("jwt-auth", "JWT validation"),
333        (
334            "hmac-auth",
335            "HMAC request signing (access key / secret key)",
336        ),
337        ("jwe-decrypt", "Decrypt a JWE token into a forwarded header"),
338        (
339            "multi-auth",
340            "Chain auth plugins — accept the first that succeeds",
341        ),
342        ("ldap-auth", "Authenticate Basic credentials against LDAP"),
343        (
344            "consumer-restriction",
345            "Allow/deny by consumer name or group",
346        ),
347        ("acl", "Allow/deny by consumer group"),
348        (
349            "attach-consumer-label",
350            "Copy consumer labels into upstream headers",
351        ),
352        // External auth & authorization
353        (
354            "forward-auth",
355            "Delegate the decision to an external HTTP service",
356        ),
357        ("opa", "Delegate authorization to Open Policy Agent"),
358        ("authz-casbin", "Embedded Casbin RBAC/ABAC enforcement"),
359        ("authz-keycloak", "Keycloak UMA permission check"),
360        (
361            "authz-casdoor",
362            "Casdoor introspection or interactive OAuth login",
363        ),
364        (
365            "openid-connect",
366            "OIDC bearer validation or interactive login",
367        ),
368        ("cas-auth", "CAS ticket validation or interactive SSO login"),
369        ("wolf-rbac", "Wolf RBAC token check"),
370        (
371            "dingtalk-auth",
372            "DingTalk code/token validation with optional session mode",
373        ),
374        (
375            "feishu-auth",
376            "Feishu/Lark code/token validation with optional session mode",
377        ),
378        // Serverless & FaaS
379        (
380            "serverless-pre-function",
381            "Run inline Lua before the upstream",
382        ),
383        (
384            "serverless-post-function",
385            "Run inline Lua after the upstream",
386        ),
387        (
388            "oas-validator",
389            "Validate requests against an OpenAPI 3 spec",
390        ),
391        ("aws-lambda", "Invoke an AWS Lambda function"),
392        ("azure-functions", "Invoke an Azure Function"),
393        ("openwhisk", "Invoke an Apache OpenWhisk action"),
394        ("openfunction", "Invoke an OpenFunction function"),
395        // Observability & logging
396        ("logging", "Structured access logging"),
397        ("http-logger", "Ship logs to an HTTP endpoint"),
398        ("tcp-logger", "Ship logs over a raw TCP socket"),
399        ("udp-logger", "Ship logs over a raw UDP socket"),
400        ("syslog", "Ship logs via syslog (RFC 5424)"),
401        ("file-logger", "Append logs to a local file"),
402        (
403            "error-log-logger",
404            "Ship request-level errors to a TCP sink",
405        ),
406        ("elasticsearch-logger", "Bulk-index logs into Elasticsearch"),
407        ("clickhouse-logger", "Insert logs into ClickHouse"),
408        ("loki-logger", "Push logs to Grafana Loki"),
409        ("splunk-hec-logging", "Ship logs to Splunk HEC"),
410        ("datadog", "Emit DogStatsD metrics to the Datadog agent"),
411        ("loggly", "Ship logs to SolarWinds Loggly"),
412        ("google-cloud-logging", "Ship logs to Google Cloud Logging"),
413        ("sls-logger", "Ship logs to Alibaba Cloud SLS"),
414        ("tencent-cloud-cls", "Ship logs to Tencent Cloud CLS"),
415        ("skywalking-logger", "Ship logs to Apache SkyWalking"),
416        ("lago", "Meter requests as Lago billing events"),
417        // Tracing & metrics
418        ("prometheus", "Per-consumer request counters"),
419        ("opentelemetry", "OTLP/HTTP trace export (W3C traceparent)"),
420        ("zipkin", "Zipkin v2 trace export (B3 propagation)"),
421        ("skywalking", "SkyWalking segment export (sw8 propagation)"),
422        // Scripting
423        ("script", "Custom plugin logic written in Lua"),
424        // Policy state
425        (
426            "store-get",
427            "Read a key from a shared store into context.message (miss port when absent)",
428        ),
429        (
430            "store-set",
431            "Write a key into a shared store, with an optional TTL",
432        ),
433        (
434            "store-delete",
435            "Remove a key from a shared store (idempotent)",
436        ),
437        (
438            "store-incr",
439            "Atomically increment a counter in a shared store (TTL set at creation)",
440        ),
441    ];
442
443    CATALOG
444        .iter()
445        .map(|(t, d)| {
446            // Infallible: the drift tests keep CATALOG, KNOWN_PLUGIN_TYPES,
447            // the factory, and the port registry in lockstep — see
448            // `test_catalog_covers_factory` / `test_catalog_has_no_unknown_types`
449            // below, `plugins::tests::test_known_plugin_types_matches_factory`,
450            // and `plugins::ports::tests::test_every_known_type_has_a_valid_spec`.
451            let spec = crate::plugins::port_spec(t).expect("catalog type is registered");
452            serde_json::json!({
453                "type": t,
454                "description": d,
455                "ports": serde_json::to_value(spec).unwrap()
456            })
457        })
458        .collect()
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::config::{GatewayConfig, SystemConfig};
465    use crate::config_store::FileConfigStore;
466    use axum::body::Body;
467    use axum::http::Request;
468    use tower::ServiceExt;
469
470    fn test_state(gateway_yaml: &str) -> Arc<SharedState> {
471        let system: SystemConfig = serde_yaml::from_str("{}").unwrap();
472        let gateway: GatewayConfig = serde_yaml::from_str(gateway_yaml).unwrap();
473        Arc::new(
474            SharedState::new(
475                system,
476                gateway,
477                None,
478                Arc::new(FileConfigStore::new(std::path::PathBuf::from(
479                    "gateway.yaml",
480                ))),
481            )
482            .unwrap(),
483        )
484    }
485
486    fn app(state: Arc<SharedState>) -> Router {
487        router().with_state(state)
488    }
489
490    /// Drives `POST /api/policies/validate` against a fresh in-memory state
491    /// (no routes/policies configured) and returns the parsed JSON body.
492    async fn validate_policy_json(body: serde_json::Value) -> serde_json::Value {
493        let state = test_state("{}");
494        let resp = app(state)
495            .oneshot(
496                Request::post("/api/policies/validate")
497                    .header("content-type", "application/json")
498                    .body(Body::from(body.to_string()))
499                    .unwrap(),
500            )
501            .await
502            .unwrap();
503        assert_eq!(resp.status(), StatusCode::OK);
504        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
505            .await
506            .unwrap();
507        serde_json::from_slice(&bytes).unwrap()
508    }
509
510    /// Validating a policy whose upstream cannot stream must say so, naming the
511    /// node responsible. An operator who wires gzip onto an SSE route learns it
512    /// here rather than from "notifications stopped working".
513    #[tokio::test]
514    async fn test_validate_reports_forced_buffering() {
515        let body = validate_policy_json(serde_json::json!({
516            "nodes": [
517                { "id": "listener", "type": "listener", "config": {} },
518                { "id": "up", "type": "upstream",
519                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
520                { "id": "rw", "type": "response-rewrite",
521                  "config": { "filters": [{ "regex": "a", "replace": "b" }] } },
522                { "id": "client", "type": "client", "config": {} }
523            ],
524            "edges": [
525                { "from": "listener.out", "to": "up.in" },
526                { "from": "up.success", "to": "rw.in" },
527                { "from": "rw.success", "to": "client.in" }
528            ]
529        }))
530        .await;
531
532        assert_eq!(body["valid"], serde_json::json!(true));
533        assert_eq!(body["buffering"][0]["upstream"], serde_json::json!("up"));
534        assert_eq!(body["buffering"][0]["blocked_by"], serde_json::json!("rw"));
535    }
536
537    /// Extracts the node types registered in `create_plugin`'s match arms by
538    /// reading its source. The factory is a `match` on `&str`, so there is no
539    /// runtime list to enumerate -- and calling it for every type would need
540    /// each plugin's required config.
541    fn factory_types() -> Vec<String> {
542        include_str!("../plugins/mod.rs")
543            .lines()
544            .filter_map(|line| {
545                let line = line.trim();
546                let rest = line.strip_prefix('"')?;
547                let (name, tail) = rest.split_once('"')?;
548                tail.trim_start()
549                    .starts_with("=>")
550                    .then(|| name.to_string())
551            })
552            .collect()
553    }
554
555    /// The palette catalog drifting behind the factory is a silent failure: the
556    /// plugin still works in YAML, but the UI simply never offers it. That is
557    /// exactly what happened when the APISIX plugins landed and the catalog
558    /// kept advertising the original 13.
559    #[test]
560    fn test_catalog_covers_factory() {
561        let catalog: Vec<String> = plugin_catalog()
562            .iter()
563            .map(|p| p["type"].as_str().unwrap().to_string())
564            .collect();
565
566        let missing: Vec<_> = factory_types()
567            .iter()
568            .filter(|t| !catalog.contains(t))
569            .cloned()
570            .collect();
571        assert!(
572            missing.is_empty(),
573            "registered plugins missing from the UI catalog: {missing:?}"
574        );
575    }
576
577    /// The reverse drift: a catalog entry the factory does not know would put a
578    /// node in the palette that fails policy compilation the moment it is used.
579    #[test]
580    fn test_catalog_has_no_unknown_types() {
581        let factory = factory_types();
582        let unknown: Vec<_> = plugin_catalog()
583            .iter()
584            .map(|p| p["type"].as_str().unwrap().to_string())
585            .filter(|t| !factory.contains(t))
586            .collect();
587        assert!(
588            unknown.is_empty(),
589            "catalog advertises types create_plugin cannot build: {unknown:?}"
590        );
591    }
592
593    #[test]
594    fn test_catalog_has_no_duplicates() {
595        let mut seen = std::collections::HashSet::new();
596        for p in plugin_catalog() {
597            let t = p["type"].as_str().unwrap().to_string();
598            assert!(seen.insert(t.clone()), "duplicate catalog entry: {t}");
599        }
600    }
601
602    /// Extracts the plugin types that have a visual identity, by reading the
603    /// UI's `pluginMeta` map. Its entries are `type: { color, icon }` lines.
604    fn types_with_an_icon() -> Vec<String> {
605        include_str!("../../ui/src/pluginMeta.tsx")
606            .lines()
607            .filter_map(|line| {
608                let line = line.trim();
609                // Map entries declare both a color and an icon. This also matches
610                // getPluginMeta's fallback `|| { color: ..., icon: Box }`, whose
611                // "key" is not a bare plugin name -- the charset check drops it.
612                if !line.contains("color:") || !line.contains("icon:") {
613                    return None;
614                }
615                let (key, _) = line.split_once(':')?;
616                let key = key.trim().trim_matches('\'');
617                let plausible = !key.is_empty()
618                    && key
619                        .chars()
620                        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
621                plausible.then(|| key.to_string())
622            })
623            .collect()
624    }
625
626    /// A plugin with no `pluginMeta` entry still appears in the palette, but
627    /// renders with the neutral fallback cube instead of its own icon and color
628    /// -- a silent cosmetic regression that is easy to ship and hard to notice.
629    #[test]
630    fn test_every_catalog_plugin_has_an_icon() {
631        let with_icon = types_with_an_icon();
632        let missing: Vec<_> = plugin_catalog()
633            .iter()
634            .map(|p| p["type"].as_str().unwrap().to_string())
635            .filter(|t| !with_icon.contains(t))
636            .collect();
637        assert!(
638            missing.is_empty(),
639            "plugins with no icon in ui/src/pluginMeta.tsx (they fall back to the generic cube): {missing:?}"
640        );
641    }
642
643    /// The node types the UI's palette groups into categories: every
644    /// plugin-shaped single-quoted token in `pluginCategories.ts`, so both
645    /// one-per-line and inline `types: ['a', 'b']` arrays are seen.
646    fn types_in_a_palette_category() -> Vec<String> {
647        include_str!("../../ui/src/pluginCategories.ts")
648            .split('\'')
649            // Odd-indexed pieces are the quoted tokens.
650            .skip(1)
651            .step_by(2)
652            .filter(|t| {
653                !t.is_empty()
654                    && t.chars()
655                        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
656            })
657            .map(str::to_string)
658            .collect()
659    }
660
661    /// A plugin missing from `pluginCategories.ts` lands in the drawer's
662    /// synthesized "Other" group instead of its section: it still works, but
663    /// the palette taxonomy and the docs sidebar have silently diverged.
664    #[test]
665    fn test_every_catalog_plugin_is_in_a_palette_category() {
666        let categorised = types_in_a_palette_category();
667        let missing: Vec<_> = plugin_catalog()
668            .iter()
669            .map(|p| p["type"].as_str().unwrap().to_string())
670            // Fixed graph endpoints are drawn, never picked from the palette;
671            // `script` has its own drawer section fed by the script files.
672            .filter(|t| !matches!(t.as_str(), "listener" | "client" | "script"))
673            .filter(|t| !categorised.contains(t))
674            .collect();
675        assert!(
676            missing.is_empty(),
677            "plugins missing from ui/src/pluginCategories.ts (they fall into the palette's 'Other' group): {missing:?}"
678        );
679    }
680
681    /// Every plugin needs its reference page: it is what `get_node_type`
682    /// returns to an agent over MCP (embedded by `src/mcp/docs.rs`) and what
683    /// the docs site links. A type with no page leaves the agent guessing at
684    /// config keys.
685    #[test]
686    fn test_every_catalog_plugin_has_a_docs_page() {
687        let pages: std::collections::HashSet<String> = std::fs::read_dir(concat!(
688            env!("CARGO_MANIFEST_DIR"),
689            "/website/docs/reference/plugins"
690        ))
691        .expect("plugin docs directory")
692        .filter_map(|e| {
693            let name = e.ok()?.file_name().to_string_lossy().to_string();
694            name.strip_suffix(".md").map(str::to_string)
695        })
696        .collect();
697        let missing: Vec<_> = plugin_catalog()
698            .iter()
699            .map(|p| p["type"].as_str().unwrap().to_string())
700            // listener/client share one page; index.md is the catalog itself.
701            .filter(|t| t != "listener" && t != "client")
702            .filter(|t| !pages.contains(t))
703            .collect();
704        assert!(
705            missing.is_empty(),
706            "plugins with no website/docs/reference/plugins/<type>.md page (get_node_type returns no docs): {missing:?}"
707        );
708    }
709
710    /// A docs page that no sidebar lists is unreachable on the docs site.
711    #[test]
712    fn test_every_plugin_docs_page_is_in_the_sidebar() {
713        let sidebar = include_str!("../../website/sidebars.ts");
714        let missing: Vec<_> = plugin_catalog()
715            .iter()
716            .map(|p| p["type"].as_str().unwrap().to_string())
717            .filter(|t| t != "listener" && t != "client")
718            .filter(|t| !sidebar.contains(&format!("reference/plugins/{t}'")))
719            .collect();
720        assert!(
721            missing.is_empty(),
722            "plugin docs pages missing from website/sidebars.ts: {missing:?}"
723        );
724    }
725
726    /// Every catalog entry carries its port spec, and outcome ports match the registry.
727    #[test]
728    fn test_catalog_entries_carry_ports() {
729        for p in plugin_catalog() {
730            let ty = p["type"].as_str().unwrap();
731            let ports = &p["ports"];
732            assert!(
733                ports["outputs"].is_array(),
734                "'{ty}' catalog entry lacks ports.outputs"
735            );
736            let spec = crate::plugins::port_spec(ty).unwrap();
737            let names: Vec<&str> = ports["outputs"]
738                .as_array()
739                .unwrap()
740                .iter()
741                .map(|o| o["name"].as_str().unwrap())
742                .collect();
743            assert_eq!(
744                names,
745                spec.outputs.iter().map(|o| o.name).collect::<Vec<_>>()
746            );
747        }
748        // spot-check kind serialization
749        let cors = plugin_catalog()
750            .into_iter()
751            .find(|p| p["type"] == "cors")
752            .unwrap();
753        assert_eq!(cors["ports"]["outputs"][1]["kind"], "outcome");
754    }
755}