Skip to main content

embacle_server/uhp/
discovery.rs

1// ABOUTME: GET /v1/uhp — the unauthenticated protocol discovery document
2// ABOUTME: Capabilities are reported honestly; conformance_class is derived from them
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Discovery.
8//!
9//! Served without a credential on purpose: a client must be able to learn
10//! whether this is a UHP server, and which versions it speaks, *before*
11//! deciding what to present. The document holds nothing principal-specific.
12//!
13//! Capabilities are named booleans and are reported as they actually are. The
14//! specification is explicit that a server reports `false` for something it
15//! does not implement rather than omitting it, so a client can tell "not
16//! supported" from "server predates this field".
17
18use std::collections::BTreeMap;
19
20use axum::response::IntoResponse;
21use axum::Json;
22use serde::Serialize;
23
24use super::version::{SUPPORTED, VERSION};
25
26/// What this server can actually do, as named booleans.
27///
28/// The specification calls for named booleans and permits any additional key,
29/// so this is carried as an ordered map rather than a struct of `bool` fields:
30/// it serialises to exactly the same JSON, keeps the capability names in one
31/// list beside the class rule that reads them, and lets a future capability be
32/// added without reshaping a type.
33///
34/// A capability this server does not implement is reported `false`, never
35/// omitted — the specification is explicit that a client must be able to tell
36/// "not supported" from "server predates this field".
37#[derive(Debug, Clone, Serialize)]
38pub struct Capabilities(BTreeMap<&'static str, bool>);
39
40/// The capabilities `core` requires, all of which must be true to claim it.
41const CORE: [&str; 3] = ["streaming", "sessions", "cancellation"];
42
43/// What `extended` adds on top of [`CORE`].
44const EXTENDED: [&str; 3] = ["files_input", "files_output", "session_listing"];
45
46/// What `full` adds on top of [`EXTENDED`].
47const FULL: [&str; 1] = ["harness_management"];
48
49/// Every capability name the specification defines, in document order.
50const ALL: [&str; 9] = [
51    "streaming",
52    "sessions",
53    "cancellation",
54    "files_input",
55    "files_output",
56    "session_listing",
57    "harness_management",
58    "session_sharing",
59    "idempotency",
60];
61
62impl Capabilities {
63    /// What this build implements today.
64    ///
65    /// Every flag here is the truth, so a client planning against this
66    /// document is never told a surface exists that would fail on first use.
67    /// A capability is added to this list when its conformance checks pass,
68    /// not when its handler exists.
69    pub fn current() -> Self {
70        let implemented = [
71            "streaming",
72            "sessions",
73            "cancellation",
74            "session_listing",
75            "harness_management",
76            "session_sharing",
77            "files_input",
78            "files_output",
79        ];
80        Self(
81            ALL.iter()
82                .map(|&name| (name, implemented.contains(&name)))
83                .collect(),
84        )
85    }
86
87    /// Whether every named capability is present and true.
88    fn all_of(&self, names: &[&str]) -> bool {
89        names
90            .iter()
91            .all(|n| self.0.get(n).copied().unwrap_or(false))
92    }
93
94    /// The highest class these capabilities actually satisfy.
95    ///
96    /// The enum admits only `core`, `extended` and `full`, so a server that
97    /// does not yet meet `core` still has to name one. It names `core` — the
98    /// floor it is building toward — and the shortfall surfaces as a failing
99    /// D-05 rather than as a document that quietly overstates itself.
100    pub fn class(&self) -> &'static str {
101        if !self.all_of(&CORE) {
102            return "core";
103        }
104        if !self.all_of(&EXTENDED) {
105            return "core";
106        }
107        if self.all_of(&FULL) {
108            "full"
109        } else {
110            "extended"
111        }
112    }
113}
114
115/// The discovery document.
116#[derive(Debug, Clone, Serialize)]
117pub struct Discovery {
118    /// Always `uhp.discovery`.
119    pub object: &'static str,
120    /// Always `uhp`.
121    pub protocol: &'static str,
122    /// Every version this server can serve.
123    pub versions: Vec<&'static str>,
124    /// The version served when a client asks for none.
125    pub default_version: &'static str,
126    /// The class these capabilities satisfy.
127    pub conformance_class: &'static str,
128    /// Named booleans.
129    pub capabilities: Capabilities,
130    /// Who is serving.
131    pub implementation: Implementation,
132}
133
134/// Names the server behind the protocol.
135#[derive(Debug, Clone, Serialize)]
136pub struct Implementation {
137    /// Crate name.
138    pub name: &'static str,
139    /// Crate version.
140    pub version: &'static str,
141}
142
143/// Handle `GET /v1/uhp`.
144pub async fn handle() -> impl IntoResponse {
145    let capabilities = Capabilities::current();
146    let conformance_class = capabilities.class();
147    Json(Discovery {
148        object: "uhp.discovery",
149        protocol: "uhp",
150        versions: SUPPORTED.to_vec(),
151        default_version: VERSION,
152        conformance_class,
153        capabilities,
154        implementation: Implementation {
155            name: "embacle-server",
156            version: env!("CARGO_PKG_VERSION"),
157        },
158    })
159}