Coverage Report

Created: 2026-11-04 02:14

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-tts-daemon/src/daemon/http/v1/mod.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
//! `/v1` — one module per path, named for the path it answers on.
3
//!
4
//! A directory where a path has sub-resources worth separating ([`backends`],
5
//! [`pipeline`], [`registry`], [`settings`]); a file otherwise, holding that
6
//! path and any sub-path small enough to read beside it. Two modules are named
7
//! for something other than a path because they are not endpoints: [`macros`],
8
//! which generates the one-value settings handlers, and [`wire`], the narrow
9
//! response bodies they answer with.
10
//!
11
//! `/auth` is the exception: its routes are `super_engine_daemon::auth::routes`,
12
//! shared with Super STT, and only registered here.
13
14
// Must come first: `#[macro_use]` puts the settings-endpoint macros in scope for
15
// every module declared after it.
16
#[macro_use]
17
mod macros;
18
19
pub(crate) mod backends;
20
pub(crate) mod events;
21
pub(crate) mod gpu_info;
22
pub(crate) mod ping;
23
pub(crate) mod pipeline;
24
pub(crate) mod registry;
25
pub(crate) mod settings;
26
pub(crate) mod speak;
27
pub(crate) mod speak_stream;
28
pub(crate) mod status;
29
pub(crate) mod update;
30
pub(crate) mod voice;
31
pub(crate) mod wire;
32
33
use crate::daemon::http::internal::auth::{require_speak_scope, require_voices_scope};
34
use crate::daemon::http::openapi::ApiDoc;
35
use crate::daemon::http::state::AppState;
36
use axum::Router;
37
use axum::middleware;
38
use super_engine_daemon::auth::middleware::{
39
    require_allowed_origin, require_any_authenticated, require_rate_limit, require_secrets_scope,
40
    require_settings_scope, require_status_scope,
41
};
42
use super_engine_daemon::auth::routes as auth;
43
use utoipa::OpenApi;
44
use utoipa_axum::router::OpenApiRouter;
45
use utoipa_axum::routes;
46
47
/// The `/v1` routes, grouped by the guard they share.
48
///
49
/// Registering a route and documenting it is one act here: every entry goes in
50
/// through [`routes!`], which reads the `#[utoipa::path]` attribute off the
51
/// handler it names. A handler without one does not compile, and a path spelled
52
/// two ways cannot exist, because there is only one spelling.
53
///
54
/// The guards are applied separately, in [`router`], for a practical reason:
55
/// `from_fn_with_state` needs a live [`AppState`], and building one opens the
56
/// system keyring. Keeping route registration free of it is what lets
57
/// [`openapi`] generate the document from these same registrations without a
58
/// running daemon — see `src/bin/gen_openapi.rs`.
59
struct ScopeGroups {
60
    /// Any valid token, whatever its scopes. `GET /events` lives here because
61
    /// its per-topic scope is enforced inside the handler, against the topics
62
    /// actually requested.
63
    any: OpenApiRouter<AppState>,
64
    status: OpenApiRouter<AppState>,
65
    speak: OpenApiRouter<AppState>,
66
    /// The configuration surface: every module whose paths the `settings`
67
    /// scope guards, gathered by [`settings_routes`].
68
    settings: OpenApiRouter<AppState>,
69
    secrets: OpenApiRouter<AppState>,
70
    /// The cloned-voice library. Its own scope rather than part of `settings`:
71
    /// these are recordings of a person, and an app that manages models has no
72
    /// business reading them.
73
    voices: OpenApiRouter<AppState>,
74
    /// Reachable without a token — it is how a caller gets one.
75
    unauthenticated: OpenApiRouter<AppState>,
76
}
77
78
/// Every `/v1` route, in its group. Paths are bare here (`/ping`); the `/v1`
79
/// prefix is applied once, in [`assemble`].
80
90
fn scope_groups() -> ScopeGroups {
81
    ScopeGroups {
82
90
        any: OpenApiRouter::new()
83
90
            .routes(routes!(ping::ping))
84
90
            .routes(routes!(auth::auth_status))
85
90
            .merge(events::routes()),
86
90
        status: OpenApiRouter::new().routes(routes!(status::status)),
87
90
        speak: speak::routes().merge(speak_stream::routes()),
88
90
        settings: settings_routes(),
89
90
        secrets: backends::secrets::routes(),
90
90
        voices: voice::routes(),
91
90
        unauthenticated: OpenApiRouter::new().routes(routes!(auth::auth_request)),
92
    }
93
90
}
94
95
/// Every route the `settings` scope guards, gathered from the modules that hold
96
/// them. This is the one place the scope's membership is written down; the
97
/// paths themselves live in each module's `#[utoipa::path]`, which is what
98
/// `routes!` reads them back off.
99
///
100
/// The families with their own trees ([`backends`], [`pipeline`], [`registry`],
101
/// [`settings`]) contribute their own `routes()`. [`backends::secrets`] is
102
/// deliberately not among them — those paths carry the `secrets` scope and are
103
/// guarded separately in [`scope_groups`].
104
90
fn settings_routes() -> OpenApiRouter<AppState> {
105
90
    OpenApiRouter::new()
106
90
        .routes(routes!(gpu_info::get_gpu_info))
107
90
        .routes(routes!(update::get_update))
108
90
        .routes(routes!(update::post_check))
109
90
        .merge(settings::routes())
110
90
        .merge(backends::routes())
111
90
        .merge(pipeline::routes())
112
90
        .merge(registry::routes())
113
90
}
114
115
/// Apply each group's guard. Rate limiting is layered under the scope check on
116
/// every guarded group, so an unauthenticated flood is rejected on the cheaper
117
/// test first.
118
70
fn guarded(groups: ScopeGroups, state: &AppState) -> ScopeGroups {
119
    /// `.layer(scope).layer(rate_limit)` — the pair every guarded group takes.
120
    macro_rules! guard {
121
        ($group:expr, $scope:expr) => {
122
            $group
123
                .layer(middleware::from_fn_with_state(state.auth.clone(), $scope))
124
                .layer(middleware::from_fn_with_state(
125
                    state.auth.clone(),
126
                    require_rate_limit,
127
                ))
128
        };
129
    }
130
131
70
    ScopeGroups {
132
70
        any: guard!(groups.any, require_any_authenticated),
133
70
        status: guard!(groups.status, require_status_scope),
134
70
        speak: guard!(groups.speak, require_speak_scope),
135
70
        settings: guard!(groups.settings, require_settings_scope),
136
70
        secrets: guard!(groups.secrets, require_secrets_scope),
137
70
        voices: guard!(groups.voices, require_voices_scope),
138
70
        // No guard: this is the endpoint that mints the token the others need.
139
70
        unauthenticated: groups.unauthenticated,
140
70
    }
141
70
}
142
143
/// Merge the groups under `/v1` and attach the base document. The live router
144
/// and the generated spec both come through here, so the two cannot describe
145
/// different route sets.
146
85
fn assemble(groups: ScopeGroups) -> OpenApiRouter<AppState> {
147
85
    let v1 = OpenApiRouter::new()
148
85
        .merge(groups.any)
149
85
        .merge(groups.status)
150
85
        .merge(groups.speak)
151
85
        .merge(groups.settings)
152
85
        .merge(groups.secrets)
153
85
        .merge(groups.voices)
154
85
        .merge(groups.unauthenticated);
155
156
85
    OpenApiRouter::with_openapi(ApiDoc::openapi()).nest("/v1", v1)
157
85
}
158
159
/// The live `/v1` router, guards applied and state bound.
160
///
161
/// The origin gate wraps everything, including `/v1/auth/request` — the one
162
/// route no scope guards. That is deliberate: `auth_request` is what opens a
163
/// consent dialog, so a page from an unlisted origin must be stopped before it
164
/// can put a popup on the user's screen, not merely stopped from using the
165
/// token it would mint.
166
70
pub(crate) fn router(state: AppState) -> Router {
167
70
    let (router, _spec) = assemble(guarded(scope_groups(), &state)).split_for_parts();
168
70
    router
169
70
        .layer(middleware::from_fn_with_state(
170
70
            state.auth.clone(),
171
            require_allowed_origin,
172
        ))
173
70
        .with_state(state)
174
70
}
175
176
/// The generated document for the same surface. The guards are omitted because
177
/// they add no paths — what each endpoint requires is stated in its own
178
/// `security` and prose.
179
15
pub(crate) fn openapi() -> utoipa::openapi::OpenApi {
180
15
    let (_router, mut spec) = assemble(scope_groups()).split_for_parts();
181
    // Has to happen here, not as an `ApiDoc` modifier: the paths only exist
182
    // once the router has been assembled.
183
15
    crate::daemon::http::openapi::annotate_scopes(&mut spec);
184
15
    spec
185
15
}
186
187
/// Which scope the router *enforces* on each `/v1` path, read from the grouping
188
/// itself rather than restated.
189
///
190
/// `None` means the path is reachable without a token. `Some(vec![])` means any
191
/// valid token will do, whatever its scopes.
192
///
193
/// This exists so the contract test can check each endpoint's advertised
194
/// `security` against the guard it actually sits behind. The two are written in
195
/// different places — the scope in a `#[utoipa::path]` attribute, the guard in
196
/// [`guarded`] — and nothing else would notice them disagreeing. A client author
197
/// told they need `settings` for an endpoint the daemon guards with `secrets`
198
/// requests the wrong scope and gets a `403` they cannot explain.
199
#[cfg(test)]
200
5
pub(super) fn enforced_scopes() -> std::collections::BTreeMap<String, Option<Vec<&'static str>>> {
201
5
    let groups = scope_groups();
202
5
    let by_group = [
203
5
        (groups.any, Some(vec![])),
204
5
        (groups.status, Some(vec!["status"])),
205
5
        (groups.speak, Some(vec!["speak"])),
206
5
        (groups.settings, Some(vec!["settings"])),
207
5
        (groups.secrets, Some(vec!["secrets"])),
208
5
        (groups.voices, Some(vec!["voices"])),
209
5
        (groups.unauthenticated, None),
210
5
    ];
211
212
5
    let mut enforced = std::collections::BTreeMap::new();
213
35
    for (router, scopes) in 
by_group5
{
214
        // The groups hold bare paths; `assemble` is what applies the `/v1`
215
        // prefix, so apply it here too to match the published document.
216
35
        let (_router, spec) = router.split_for_parts();
217
255
        for path in 
spec.paths.paths35
.
keys35
() {
218
255
            enforced.insert(format!("/v1{path}"), scopes.clone());
219
255
        }
220
    }
221
5
    enforced
222
5
}