super-stt-daemon/src/daemon/http/openapi.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | //! The `OpenAPI` document for the daemon protocol. |
3 | | //! |
4 | | //! The document is generated from the router, not written beside it: every |
5 | | //! `/v1` route is registered through [`utoipa_axum::routes!`], which reads the |
6 | | //! `#[utoipa::path]` attribute on the handler it points at. A route and its |
7 | | //! documentation are therefore one declaration — adding a route without |
8 | | //! documenting it does not compile, and changing a path changes both. |
9 | | //! |
10 | | //! `just openapi` writes the result to `docs/protocol/openapi.json`; |
11 | | //! `just openapi-check` fails when the committed file is stale, so a protocol |
12 | | //! change cannot merge without the published spec moving with it. |
13 | | //! |
14 | | //! The prose reference under `docs/protocol/` is not replaced by any of this. |
15 | | //! It explains *when* to call an endpoint and how the pieces fit; the spec |
16 | | //! states the shapes exactly, for tooling and for a client generator. |
17 | | |
18 | | use utoipa::Modify; |
19 | | use utoipa::OpenApi; |
20 | | use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; |
21 | | |
22 | | /// Base document: everything that is true of the protocol as a whole rather |
23 | | /// than of one endpoint. The paths and schemas are filled in from the router |
24 | | /// (see [`super::v1::openapi`]). |
25 | | #[derive(OpenApi)] |
26 | | #[openapi( |
27 | | info( |
28 | | title = "Super STT daemon protocol", |
29 | | description = "\ |
30 | | HTTP/1.1 + JSON on two transports that serve the identical API. |
31 | | |
32 | | **A Unix domain socket** under the per-user runtime directory at \ |
33 | | `<runtime dir>/stt/super-stt-http.sock` (override with `SUPER_STT_HTTP_SOCKET`), which is \ |
34 | | what native clients use. The runtime directory is `$XDG_RUNTIME_DIR` on Linux and the \ |
35 | | Darwin per-user temp directory on macOS — ask the daemon rather than assuming, or read \ |
36 | | the `runtime_dir` server variable below. Its filesystem permissions are the first layer \ |
37 | | of access control, and the daemon reads the peer credentials on each connection to \ |
38 | | identify the calling binary. It is the second server below, listed by its real path — \ |
39 | | dial that path directly and send whatever `Host` you like, since the daemon ignores it. |
40 | | |
41 | | **A loopback TCP listener**, which is what a browser can reach, since no browser can dial \ |
42 | | a Unix socket. It is the first server below, and it is a real address. There are no peer \ |
43 | | credentials on TCP, so a caller there is identified by its `Origin` instead — a claim from \ |
44 | | the browser rather than a fact from the kernel, which is why the consent dialog says so. |
45 | | |
46 | | Every endpoint except `POST /v1/auth/request` requires `Authorization: Bearer <token>`. \ |
47 | | A token is minted only after the user approves your client in a consent dialog, and is \ |
48 | | bound to whichever identity was approved — a binary or an origin, never interchangeable. \ |
49 | | A client cannot widen its own permissions. See `docs/protocol/auth.md`. |
50 | | |
51 | | With curl, over the socket: |
52 | | |
53 | | ``` |
54 | | curl --unix-socket \"${XDG_RUNTIME_DIR:-$TMPDIR}/stt/super-stt-http.sock\" \\ |
55 | | -H \"Authorization: Bearer $STT_TOKEN\" \\ |
56 | | http://stt.local/v1/ping |
57 | | ```", |
58 | | license(name = "GPL-3.0-only", identifier = "GPL-3.0-only"), |
59 | | contact(name = "Super STT", url = "https://github.com/jorge-menjivar/super-stt"), |
60 | | ), |
61 | | // The servers list is filled in by `LocalServers`, not here: the TCP |
62 | | // entry's URL embeds `config::DEFAULT_TCP_PORT`, and a macro attribute |
63 | | // takes a literal. Writing the port twice is exactly how the document |
64 | | // comes to advertise an address the daemon is not on. |
65 | | modifiers(&BearerAuth, &LocalServers), |
66 | | // The registry types are shared, generic over what they hold, and a type |
67 | | // reached only through a type parameter is not collected from the routes |
68 | | // on its own. See `super_engine_spec::registry`. |
69 | | components(schemas( |
70 | | super_stt_shared::registry::RegistryBackend, |
71 | | super_stt_shared::registry::RegistryModel, |
72 | | )), |
73 | | tags( |
74 | | (name = "auth", description = "Consent handshake and token probing."), |
75 | | (name = "health", description = "Liveness and what the daemon is currently running."), |
76 | | (name = "transcribe", description = "Start, stop and stream transcription."), |
77 | | (name = "events", description = "Server-Sent Events for recording state, audio levels, model and download progress, and final transcripts."), |
78 | | (name = "pipeline", description = "The ordered stages a transcript passes through: which backend fills each, which model runs there, and on what device."), |
79 | | (name = "settings", description = "Stored daemon preferences, one value apiece, all under `/v1/settings`: audio cues, write and notification methods, language, update policy. Sharing the `settings` scope is not the same as being a setting \u{2014} `backends`, `pipeline` and `registry` are guarded by it too."), |
80 | | (name = "hardware", description = "What the daemon can see of this machine: GPUs, drivers, runtimes."), |
81 | | (name = "update", description = "Whether a newer daemon exists, and asking it to look now."), |
82 | | (name = "backends", description = "Installed backends: their models, options and secrets."), |
83 | | (name = "registry", description = "The published backend catalog: browse, install, update, uninstall."), |
84 | | (name = "contexts", description = "Named dictation contexts \u{2014} what the user is dictating, as a prompt for a model that follows instructions and a vocabulary of terms for one that does not. One is active at a time, and a backend may be pointed at another or at none."), |
85 | | ), |
86 | | )] |
87 | | pub(crate) struct ApiDoc; |
88 | | |
89 | | /// The two addresses the daemon answers on. |
90 | | /// |
91 | | /// Order matters: tooling that offers to send a request uses the first server, |
92 | | /// and only one of these can actually receive one. The TCP listener is a real |
93 | | /// address a browser can reach; the second is the socket's own path, spelled |
94 | | /// `unix://`, which no HTTP client can resolve — it is there to be read, not |
95 | | /// dialed. |
96 | | struct LocalServers; |
97 | | |
98 | | /// The example runtime directory shown for the `runtime_dir` server variable. |
99 | | /// |
100 | | /// Per-platform because the two do not look remotely alike, and a reader on |
101 | | /// the wrong one would take `/run/user/1000` for a path to try. It is an |
102 | | /// illustration either way — the real value is this user's own — so the |
103 | | /// macOS form keeps a placeholder where the per-user hash goes rather than |
104 | | /// printing a directory that belongs to whoever generated the document. |
105 | | #[cfg(target_os = "linux")] |
106 | | const DEFAULT_RUNTIME_DIR: &str = "/run/user/1000"; |
107 | | /// See [`DEFAULT_RUNTIME_DIR`]. |
108 | | #[cfg(target_os = "macos")] |
109 | | const DEFAULT_RUNTIME_DIR: &str = "/var/folders/xx/xxxxxxxxxxxxxxxxxxxxxxxxxxxx/T"; |
110 | | |
111 | | impl Modify for LocalServers { |
112 | 125 | fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { |
113 | | use utoipa::openapi::{ServerBuilder, ServerVariableBuilder}; |
114 | 125 | openapi.servers = Some(vec![ |
115 | 125 | ServerBuilder::new() |
116 | 125 | .url(format!( |
117 | 125 | "http://127.0.0.1:{}", |
118 | 125 | crate::config::DEFAULT_TCP_PORT |
119 | 125 | )) |
120 | 125 | .description(Some( |
121 | 125 | "The daemon's default loopback TCP listener. Your browser can reach this.", |
122 | 125 | )) |
123 | 125 | .build(), |
124 | 125 | ServerBuilder::new() |
125 | 125 | // The socket's real path, not an invented hostname. It is what |
126 | 125 | // a reader has to type, and naming it here saves them going to |
127 | 125 | // find it. `unix://` + an absolute path is the spelling Docker |
128 | 125 | // and friends use, so it reads as a socket rather than a host. |
129 | 125 | .url("unix://{runtime_dir}/stt/super-stt-http.sock") |
130 | 125 | .parameter( |
131 | 125 | "runtime_dir", |
132 | 125 | ServerVariableBuilder::new() |
133 | 125 | .default_value(DEFAULT_RUNTIME_DIR) |
134 | 125 | .description(Some( |
135 | 125 | "Your per-user runtime directory: `$XDG_RUNTIME_DIR` \ |
136 | 125 | (`/run/user/<uid>`) on a systemd host, or the Darwin \ |
137 | 125 | per-user temp directory (`/var/folders/<xx>/<hash>/T`, what \ |
138 | 125 | `getconf DARWIN_USER_TEMP_DIR` prints) on macOS. `/tmp/stt` \ |
139 | 125 | is the fallback on both. Override the whole socket path with \ |
140 | 125 | `SUPER_STT_HTTP_SOCKET`.", |
141 | 125 | )), |
142 | 125 | ) |
143 | 125 | .description(Some( |
144 | 125 | "The Unix socket native clients use. No browser can dial it. Use with curl instead.", |
145 | 125 | )) |
146 | 125 | .build(), |
147 | 125 | ]); |
148 | 125 | } |
149 | | } |
150 | | |
151 | | /// The one security scheme: the session token from `POST /v1/auth/request`, |
152 | | /// presented as a bearer token. Declared here rather than per endpoint so the |
153 | | /// scheme has a single definition; which *scopes* each endpoint needs is stated |
154 | | /// on the endpoint, since that is where it differs. |
155 | | struct BearerAuth; |
156 | | |
157 | | impl Modify for BearerAuth { |
158 | 125 | fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { |
159 | 125 | let components = openapi |
160 | 125 | .components |
161 | 125 | .as_mut() |
162 | 125 | .expect("the derived document always carries a components object"); |
163 | 125 | components.add_security_scheme( |
164 | | "session_token", |
165 | 125 | SecurityScheme::Http( |
166 | 125 | HttpBuilder::new() |
167 | 125 | .scheme(HttpAuthScheme::Bearer) |
168 | 125 | .description(Some( |
169 | 125 | "Session token from `POST /v1/auth/request`. Valid for 30 days, and \ |
170 | 125 | bound to whoever the user approved: the calling binary over the \ |
171 | 125 | Unix socket, or the page's origin over TCP. It stops working if \ |
172 | 125 | that identity changes.", |
173 | 125 | )) |
174 | 125 | .build(), |
175 | 125 | ), |
176 | | ); |
177 | 125 | } |
178 | | } |
179 | | |
180 | | /// Restate each operation's required scope in its summary, where a reader can |
181 | | /// actually see it. |
182 | | /// |
183 | | /// The scope is already declared, in `security` on every `#[utoipa::path]`, and |
184 | | /// that declaration is the authority — a contract test checks it against the |
185 | | /// guard the route actually sits behind. What it is not is *visible*: Swagger UI |
186 | | /// renders scopes only for `OAuth2` and `OpenID` Connect schemes, where it runs the |
187 | | /// flow itself. Ours is a plain bearer scheme, so the UI shows a padlock, drops |
188 | | /// the scope array, and leaves a reader to work out which of six scopes an |
189 | | /// endpoint wants by reading prose. |
190 | | /// |
191 | | /// So this copies it into the summary, which is the line shown beside the path |
192 | | /// in the collapsed operation list — the one place you can compare endpoints |
193 | | /// without opening them. Copied rather than written by hand in 66 summaries: |
194 | | /// a second hand-maintained statement of the same fact is one that goes stale, |
195 | | /// and a summary claiming the wrong scope is worse than one that omits it. |
196 | | /// |
197 | | /// Runs after assembly rather than as an [`ApiDoc`] modifier, because the paths |
198 | | /// are merged in by the router *after* the base document is built — a modifier |
199 | | /// here would walk an empty map. |
200 | 17 | pub(crate) fn annotate_scopes(openapi: &mut utoipa::openapi::OpenApi) { |
201 | 884 | for item in openapi.paths.paths17 .values_mut17 () { |
202 | 884 | let operations = [ |
203 | 884 | &mut item.get, |
204 | 884 | &mut item.put, |
205 | 884 | &mut item.post, |
206 | 884 | &mut item.delete, |
207 | 884 | &mut item.options, |
208 | 884 | &mut item.head, |
209 | 884 | &mut item.patch, |
210 | 884 | &mut item.trace, |
211 | 884 | ]; |
212 | 1.34k | for operation in operations884 .into_iter884 ().flatten884 () { |
213 | 1.34k | let note = scope_note(operation.security.as_ref()); |
214 | | // Test against the note itself, not against a trailing `)`: a |
215 | | // summary may legitimately end in a parenthetical of its own, and |
216 | | // `GET /transcribe/realtime` ("… session (WebSocket)") is one — a |
217 | | // looser check silently left the one endpoint whose summary already |
218 | | // had a suffix as the only one without its scope. |
219 | 1.34k | if let Some(summary) = operation.summary.as_mut() |
220 | 1.34k | && !summary.ends_with(¬e) |
221 | 1.34k | { |
222 | 1.34k | summary.push_str(¬e); |
223 | 1.34k | }0 |
224 | | } |
225 | | } |
226 | 17 | } |
227 | | |
228 | | /// How an operation's `security` reads in plain words. |
229 | | /// |
230 | | /// `None` means the operation declared none, which is the genuinely |
231 | | /// unauthenticated case: `POST /v1/auth/request`, the endpoint that mints the |
232 | | /// token every other one needs. An empty scope list means any valid token will |
233 | | /// do whatever its scopes — `/v1/events` is that, because its scopes are |
234 | | /// per-topic and enforced inside the handler against the topics asked for. |
235 | 1.34k | fn scope_note(security: Option<&Vec<utoipa::openapi::security::SecurityRequirement>>) -> String { |
236 | 1.34k | let Some(requirements1.32k ) = security else { |
237 | 17 | return " (no token needed)".to_string(); |
238 | | }; |
239 | | // `SecurityRequirement` keeps its map private, so read it back the way it is |
240 | | // written out. This runs once, in a generator, not on a request path. |
241 | 1.32k | let scopes: Vec<String> = requirements |
242 | 1.32k | .iter() |
243 | 1.32k | .filter_map(|requirement| serde_json::to_value(requirement).ok()) |
244 | 1.32k | .filter_map(|value| value.get("session_token").cloned()) |
245 | 1.32k | .filter_map(|scopes| serde_json::from_value::<Vec<String>>(scopes).ok()) |
246 | 1.32k | .flatten() |
247 | 1.32k | .collect(); |
248 | | |
249 | 1.32k | if scopes.is_empty() { |
250 | 51 | " (any valid token)".to_string() |
251 | | } else { |
252 | 1.27k | format!(" (scope: {})", scopes.join(", ")) |
253 | | } |
254 | 1.34k | } |