Coverage Report

Created: 2026-10-23 22:23

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
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(&note)
221
1.34k
            {
222
1.34k
                summary.push_str(&note);
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
}