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}