pub(crate) mod acl;
pub(crate) mod admin;
#[cfg(feature = "admin-ui")]
mod admin_ui;
pub(crate) mod audit;
pub mod auth;
pub(crate) mod backup;
pub(crate) mod ceremonies;
pub(crate) mod community;
pub(crate) mod credential_exchange;
pub(crate) mod did_log;
pub(crate) mod endorsement_types;
pub(crate) mod endorsements;
pub(crate) mod health;
pub(crate) mod install;
pub(crate) mod invitations;
pub mod join_requests;
pub(crate) mod member_portal;
pub(crate) mod members;
pub(crate) mod oid4vp;
pub(crate) mod policies;
pub mod recognise;
pub(crate) mod recognition_admin;
pub(crate) mod registry_admin;
pub(crate) mod relationships;
pub(crate) mod rooms;
pub(crate) mod schemas;
pub(crate) mod status_lists;
pub mod trust_tasks;
pub(crate) mod vetting;
#[cfg(feature = "vetting-pcs")]
pub mod vetting_hidden;
#[cfg(feature = "website")]
pub(crate) mod website;
use std::sync::Arc;
use axum::Router;
use axum::extract::DefaultBodyLimit;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{any, delete, get, post};
use ipnetwork::IpNetwork;
use tower_governor::GovernorLayer;
use tower_governor::governor::GovernorConfigBuilder;
use utoipa::OpenApi;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use vti_common::rate_limit::TrustedProxyKeyExtractor;
use vti_common::trust_task::{TrustTask, task_routes};
use crate::config::RoutingConfig;
use crate::server::AppState;
#[derive(OpenApi)]
#[openapi(
info(
title = "Verifiable Trust Community (VTC) API",
description = "Community lifecycle, ACL, audit, policy, credentials, \
endorsements, and cross-community recognition REST surface \
of a Verifiable Trust Community.",
version = env!("CARGO_PKG_VERSION"),
),
modifiers(&SecurityAddon),
// Schemas reachable *only* from a query parameter. utoipa collects
// schemas transitively from `request_body` and `body =`, but not through
// an `IntoParams` field, so a `params(...)`-only type is referenced by the
// document and absent from its components — a dangling `$ref`, which
// `no_dangling_refs` below fails on and `openapi-typescript` refuses to
// generate from at all.
//
// And the `git-ns/*` administrator reads' response bodies, which no route
// returns — they are Trust Task responses on the document endpoint
// (`git_ns::admin_reads`) — but which the admin console's wire types are
// generated from, so no shape is written twice.
//
// Likewise the admin verbs with no route at all — signed documents only —
// whose shapes the console's signed calls are typed by.
components(schemas(
policies::read::PolicyStatusFilter,
crate::git_ns::admin_reads::GitNsNamespaceList,
crate::git_ns::admin_reads::GitNsRepoList,
crate::git_ns::admin_reads::GitNsRightList,
crate::git_ns::admin_reads::GitNsDepartedGrants,
crate::git_ns::admin_reads::GitNsJobList,
crate::git_ns::admin_reads::GitNsProjection,
crate::git_ns::admin_reads::GitNsAccountList,
crate::git_ns::admin_reads::GitNsActivity,
// A repository's drift, as `git-ns/view/0.5`'s `repos[].sync` carries it.
vta_sdk::openapi::GitNsView01DriftItem,
acl::AclListResponse,
acl::AclEntryEnvelope,
vta_sdk::openapi::VetterList01Payload,
vta_sdk::openapi::VetterList01Response,
endorsement_types::RegisterBody,
endorsement_types::RegisterResponse,
vta_sdk::openapi::EndorsementTypeDelete01Response,
// The operational verbs the console signs (`trust_tasks::admin_tasks`).
health::DiagnosticsResponse,
vta_sdk::openapi::RegistrySyncJobsList01Response,
vta_sdk::openapi::RegistrySyncJobsRetry02Response,
vta_sdk::openapi::RegistrySyncJobsDiscard01Response,
vta_sdk::openapi::RegistryRecordsList01Response,
// The room-hosting administrator's reads and limit override
// (`trust_tasks::room_admin_tasks`), signed documents only.
vta_sdk::openapi::RoomsGet01Response,
vta_sdk::openapi::RoomsUsage01Payload,
vta_sdk::openapi::RoomsUsage01Response,
vta_sdk::openapi::RoomsLimitsSet01Payload,
vta_sdk::openapi::RoomsLimitsSet01Response,
// Rooms the community itself owns (`trust_tasks::community_room_tasks`),
// signed documents only.
vta_sdk::openapi::RoomsCommunityCreate01Payload,
vta_sdk::openapi::RoomsCommunityCreate01Response,
vta_sdk::openapi::RoomsCommunityList01Response,
vta_sdk::openapi::RoomsCommunityGet01Response,
// 0.2 — each room's description, tags and who last changed them — and the
// update that changes them.
vta_sdk::openapi::RoomsCommunityCreate02Payload,
vta_sdk::openapi::RoomsCommunityCreate02Response,
vta_sdk::openapi::RoomsCommunityList02Payload,
vta_sdk::openapi::RoomsCommunityList02Response,
vta_sdk::openapi::RoomsCommunityGet02Response,
vta_sdk::openapi::RoomsCommunityUpdate01Payload,
vta_sdk::openapi::RoomsCommunityUpdate01Response,
// 0.3 / 0.2: the label and description also written into the room as its profile.
vta_sdk::openapi::RoomsCommunityCreate03Payload,
vta_sdk::openapi::RoomsCommunityCreate03Response,
vta_sdk::openapi::RoomsCommunityUpdate02Payload,
vta_sdk::openapi::RoomsCommunityUpdate02Response,
vta_sdk::openapi::RoomsCommunityMembersAdd01Payload,
vta_sdk::openapi::RoomsCommunityMembersAdd01Response,
vta_sdk::openapi::RoomsCommunityMembersRemove01Payload,
vta_sdk::openapi::RoomsCommunityMembersRemove01Response,
vta_sdk::openapi::RoomsCommunityMembersRemove02Payload,
vta_sdk::openapi::RoomsCommunityMembersRemove02Response,
// The member directory the console administers (`trust_tasks::directory_tasks`).
vta_sdk::openapi::DirectoryFieldsList01Payload,
vta_sdk::openapi::DirectoryFieldsList01Response,
vta_sdk::openapi::DirectoryFieldsDefine01Payload,
vta_sdk::openapi::DirectoryFieldsDefine01Response,
vta_sdk::openapi::DirectoryFieldsUpdate01Payload,
vta_sdk::openapi::DirectoryFieldsUpdate01Response,
vta_sdk::openapi::DirectoryFieldsRetire01Payload,
vta_sdk::openapi::DirectoryFieldsRetire01Response,
vta_sdk::openapi::DirectoryCardAssert01Payload,
vta_sdk::openapi::DirectoryCardAssert01Response,
vta_sdk::openapi::DirectoryEntriesList01Payload,
vta_sdk::openapi::DirectoryEntriesList01Response,
vta_sdk::openapi::DirectoryEntriesShow01Payload,
vta_sdk::openapi::DirectoryEntriesShow01Response,
vta_sdk::openapi::RoomsCommunityMembersList01Payload,
vta_sdk::openapi::RoomsCommunityMembersList01Response,
// Where rooms' files are kept (`trust_tasks::storage_tasks`), signed
// documents only.
vta_sdk::openapi::RoomsStorageAssign01Payload,
vta_sdk::openapi::RoomsStorageAssign01Response,
vta_sdk::openapi::StorageConfigsList01Response,
vta_sdk::openapi::StorageConfigsGet01Response,
vta_sdk::openapi::StorageConfigsCreate01Payload,
vta_sdk::openapi::StorageConfigsCreate01Response,
vta_sdk::openapi::StorageConfigsUpdate01Payload,
vta_sdk::openapi::StorageConfigsUpdate01Response,
vta_sdk::openapi::StorageConfigsRetire01Response,
vta_sdk::openapi::StorageConfigsProbe01Response,
audit::AuditListResponse,
crate::config_store::EffectiveConfig,
admin::config::PatchResponse,
admin::config::ReloadResponse,
admin::config::RestartResponse,
admin::invites::ListInvitesResponse,
admin::invites::CreateInviteRequest,
admin::invites::CreateInviteResponse,
admin::invites::RevokeInviteResponse,
auth::SessionListResponse,
auth::RevokeSessionResponse,
// The console's bearer → cookie bridge and an administrator's own
// passkeys (`trust_tasks::{admin_session_tasks,admin_passkey_tasks}`),
// signed documents only since #1641's last batch.
auth::AdminSessionResponse,
auth::WhoamiResponse,
auth::SessionView,
admin::passkeys::ListResponse,
admin::passkeys::RegisteredCredential,
admin::passkeys::RegisterStartResponse,
admin::passkeys::RegisterFinishResponse,
admin::passkeys::RevokeStartResponse,
admin::passkeys::RevokeFinishResponse,
// The community verbs the console signs (`trust_tasks::community_tasks`).
community::profile::CommunityProfileResponse,
ceremonies::CeremonyListResponse,
vti_common::pagination::Paginated<crate::endorsement_types::EndorsementType>,
recognition_admin::RecognitionCheck,
members::read::RemovedMembersResponse,
members::read::MemberEnvelope,
members::request_vmc::RequestVmcBody,
members::request_vmc::RequestVmcResponse,
join_requests::read::JoinRequestEnvelope,
relationships::RelationshipsGraph,
invitations::IssueInvitationBody,
invitations::IssueInvitationResponse,
invitations::InvitationListResponse,
invitations::RevokeResponse,
vta_sdk::openapi::InvitationDeliver01Payload,
vta_sdk::openapi::InvitationDeliver01Response,
// The verbs whose shapes are documented for codegen though every
// bearer route is gone: the roster and join queue, a member's
// credentials, the join decision, a vetter grant, and the policy log.
vti_common::pagination::Paginated<members::read::MemberResponse>,
vti_common::pagination::Paginated<crate::join::JoinRequest>,
vta_sdk::openapi::MemberCredentials01Response,
join_requests::decide::DecideResponse,
vta_sdk::openapi::VetterGrant01Response,
policies::read::PolicyListResponse,
policies::read::PolicyModuleResponse,
policies::admin::UploadResponse,
crate::policy::PolicyPurpose,
// The administration surfaces the console signs (`trust_tasks::surface_tasks`).
join_requests::read::JoinRequestVettingResponse,
join_requests::read::JoinRequestVetting,
join_requests::read::JoinRequestVettingStatement,
// The join criteria, as the console registers and lists them
// (`vtc/schemas/accepts/{register,list}/0.2`) and as applicants read
// them (`vtc/join-requests/manifest/0.3`).
vta_sdk::openapi::AcceptsRegister02Payload,
vta_sdk::openapi::AcceptsList02Response,
vta_sdk::openapi::JoinManifest03Response,
rooms::HostedRoom,
// The custom-endorsement reads/revoke the console signs
// (`vtc/endorsements/{list,show,revoke}/0.1`) — bearer REST routes for
// all three had no caller once the spine dispatched them (tt-tf#689)
// and were removed.
endorsements::EndorsementRow,
vti_common::pagination::Paginated<endorsements::EndorsementRow>,
endorsements::EndorsementEnvelope,
endorsements::RevokeResponse,
// The admin resend-on-behalf the console signs
// (`vtc/vetting/vetters/resend/0.2`) — its admin-only bearer REST
// route had no caller once the spine dispatched it (tt-tf#689) and
// was removed. `0.1`'s response shape covers `0.2`'s too: neither
// adds a member.
vta_sdk::openapi::VetterResend01Response,
// The website admin verbs the console signs
// (`vtc/website/{files/list,files/delete,generations/list,rollback}/0.1`,
// `trust_tasks::website_tasks`) — every bearer REST route for these had
// no caller once vtc-client/cnm-cli/admin-ui moved onto the signed door.
website::files::ListResponse,
website::files::FileEntry,
website::files::DeleteResponse,
website::generations::GenerationsResponse,
website::generations::GenerationRow,
website::generations::RollbackResponse,
// The vetting admin reads the console signs
// (`vtc/vetting/vetters/grants/list/0.1`, `vtc/vetting/auto-grant/
// {show,update}/0.1`, `vtc/vetting/revocations/list/0.1`,
// `trust_tasks::surface_tasks`) — their admin-only bearer REST routes
// had no caller left once `vtc-client` and the admin console signed
// them instead, so nothing else forces these shapes into the spec any
// more; the admin-ui's generated wire types still need them.
vta_sdk::protocols::vetting::VetterGrantRow,
vta_sdk::protocols::vetting::VetterProfileSummary,
vta_sdk::protocols::vetting::GrantOrigin,
vta_sdk::protocols::vetting::AutoGrantStatus,
vta_sdk::protocols::vetting::AutoGrantConfig,
vta_sdk::protocols::vetting::AutoGrantSweep,
crate::routes::vetting::VettingRevocationRow,
crate::routes::vetting::RevocationReviewState,
)),
)]
pub struct ApiDoc;
struct SecurityAddon;
impl utoipa::Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme(
"bearer_jwt",
SecurityScheme::Http(
HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.bearer_format("JWT")
.build(),
),
);
}
}
async fn serve_openapi(api: utoipa::openapi::OpenApi) -> axum::Json<utoipa::openapi::OpenApi> {
axum::Json(api)
}
pub fn openapi_spec() -> utoipa::openapi::OpenApi {
OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
.nest("/v1", build_api_chain(&RoutingConfig::default(), &[]))
.split_for_parts()
.1
}
pub const MAX_BODY_SIZE: usize = 1024 * 1024;
pub const UNAUTH_BODY_SIZE: usize = 64 * 1024;
fn tt(
routes: utoipa_axum::router::UtoipaMethodRouter<AppState>,
url: &'static str,
) -> utoipa_axum::router::UtoipaMethodRouter<AppState> {
task_routes(routes, TrustTask::new(url).expect("static Trust-Task URL"))
}
pub fn router() -> Router<AppState> {
#[cfg(feature = "website")]
{
router_with(&RoutingConfig::default(), None)
}
#[cfg(not(feature = "website"))]
{
router_with(&RoutingConfig::default())
}
}
#[cfg(feature = "website")]
pub fn router_with(
routing: &RoutingConfig,
website_state: Option<crate::website::WebsiteState>,
) -> Router<AppState> {
router_with_inner(routing, website_state, &[])
}
#[cfg(feature = "website")]
pub fn router_with_xff(
routing: &RoutingConfig,
website_state: Option<crate::website::WebsiteState>,
trust_xff_cidrs: &[IpNetwork],
) -> Router<AppState> {
router_with_inner(routing, website_state, trust_xff_cidrs)
}
#[cfg(not(feature = "website"))]
pub fn router_with(routing: &RoutingConfig) -> Router<AppState> {
router_with_inner(routing, &[])
}
#[cfg(not(feature = "website"))]
pub fn router_with_xff(routing: &RoutingConfig, trust_xff_cidrs: &[IpNetwork]) -> Router<AppState> {
router_with_inner(routing, trust_xff_cidrs)
}
#[cfg(not(feature = "website"))]
fn router_with_inner(routing: &RoutingConfig, trust_xff_cidrs: &[IpNetwork]) -> Router<AppState> {
let api_chain = build_api_chain(routing, trust_xff_cidrs)
.split_for_parts()
.0;
with_csrf(assemble(routing, api_chain))
}
#[cfg(feature = "website")]
fn router_with_inner(
routing: &RoutingConfig,
website_state: Option<crate::website::WebsiteState>,
trust_xff_cidrs: &[IpNetwork],
) -> Router<AppState> {
let api_chain = build_api_chain(routing, trust_xff_cidrs)
.split_for_parts()
.0;
with_csrf(assemble_with_website(routing, api_chain, website_state))
}
fn with_csrf(app: Router<AppState>) -> Router<AppState> {
app.layer(axum::middleware::from_fn(crate::routing::csrf::enforce))
}
fn build_api_chain(
_routing: &RoutingConfig,
trust_xff_cidrs: &[IpNetwork],
) -> OpenApiRouter<AppState> {
let api = OpenApiRouter::<AppState>::new()
.routes(routes!(status_lists::show))
.routes(routes!(community::profile::get_public_profile))
.routes(routes!(community::did_qr::get_did_qr))
;
let api = api
.route("/member/me", get(member_portal::me))
.route("/member/passkeys", get(member_portal::list_passkeys))
.route(
"/member/passkeys/register/start",
post(member_portal::register_start),
)
.route(
"/member/passkeys/register/finish",
post(member_portal::register_finish),
)
.route(
"/member/passkeys/{credential_id}",
delete(member_portal::remove_passkey),
);
let api = api
.layer(DefaultBodyLimit::max(MAX_BODY_SIZE));
let unauth = build_unauth_routes(trust_xff_cidrs);
api.merge(unauth)
}
fn build_unauth_routes(trust_xff_cidrs: &[IpNetwork]) -> OpenApiRouter<AppState> {
let synth_connect_info = trust_xff_cidrs.is_empty().then(|| {
axum::middleware::from_fn(vti_common::rate_limit::insert_default_connect_info_if_missing)
});
let unauth_router = OpenApiRouter::<AppState>::new()
.routes(tt(
routes!(credential_exchange::request),
<trust_tasks_rs::specs::credential_exchange::request::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI,
))
.route("/wallet/auth/challenge", post(auth::challenge))
.route("/wallet/auth/", post(auth::authenticate))
.route("/wallet/auth/refresh", post(auth::refresh))
.routes(tt(
routes!(auth::refresh),
"https://trusttasks.org/spec/auth/refresh/0.1",
))
.routes(routes!(auth::sign_out))
.route(
"/member/wallet/auth/challenge",
post(member_portal::wallet_challenge),
)
.route(
"/member/wallet/auth/",
post(member_portal::wallet_authenticate),
)
.route(
"/member/wallet/auth/refresh",
post(member_portal::wallet_refresh),
)
.route(
"/member/sign-in/config",
get(member_portal::sign_in_config),
)
.route("/member/session", post(member_portal::session))
.route("/member/auth/refresh", post(member_portal::cookie_refresh))
.route("/member/sign-out", post(member_portal::sign_out))
.route(
"/member/passkey-login/start",
post(member_portal::passkey_login_start),
)
.route(
"/member/passkey-login/finish",
post(member_portal::passkey_login_finish),
)
.routes(routes!(oid4vp::request_object))
.routes(routes!(oid4vp::response))
.routes(tt(
routes!(auth::passkey_login_start),
"https://trusttasks.org/spec/auth/passkey/login/start/0.2",
))
.routes(tt(
routes!(auth::passkey_login_finish),
"https://trusttasks.org/spec/auth/passkey/login/finish/0.2",
))
.layer(DefaultBodyLimit::max(UNAUTH_BODY_SIZE));
let cfg = Arc::new(
GovernorConfigBuilder::default()
.per_second(5)
.burst_size(10)
.key_extractor(TrustedProxyKeyExtractor::new(trust_xff_cidrs.to_vec()))
.finish()
.expect("governor config values are static and non-zero"),
);
let limits = crate::routing::trust_task_admission::TrustTaskLimits::new(
cfg.limiter().clone(),
TrustedProxyKeyExtractor::new(trust_xff_cidrs.to_vec()),
);
let unauth_router = unauth_router
.layer(
GovernorLayer::new(cfg)
.error_handler(crate::routing::rate_limit::governor_error_response),
)
.merge(
OpenApiRouter::<AppState>::new()
.routes(routes!(trust_tasks::dispatch))
.layer(axum::middleware::from_fn_with_state(
limits,
crate::routing::trust_task_admission::client_address,
))
.layer(DefaultBodyLimit::max(
crate::trust_tasks::size::largest_max_document_bytes(),
)),
);
match synth_connect_info {
Some(layer) => unauth_router.layer(layer),
None => unauth_router,
}
}
#[cfg_attr(feature = "website", allow(dead_code))]
fn assemble(routing: &RoutingConfig, api: Router<AppState>) -> Router<AppState> {
use axum::middleware::from_fn;
use crate::routing::security_headers::security_headers;
let admin_placeholder: Router<AppState> = Router::new()
.fallback(any(placeholder_503))
.layer(from_fn(security_headers));
let website_placeholder: Router<AppState> = Router::new()
.fallback(any(placeholder_503))
.layer(from_fn(security_headers));
let spec = openapi_spec();
let mut app: Router<AppState> = Router::new()
.route("/health", get(health::health))
.route("/openapi.json", get(move || serve_openapi(spec.clone())))
.route("/.well-known/did.jsonl", get(did_log::did_log))
.route(
"/room-dids/{id}/did.jsonl",
get(crate::rooms::community::serve_room_did_log),
)
.nest(&routing.api.mount, api);
app = app.nest(&routing.admin_ui.mount, admin_placeholder);
if routing.website.mount == "/" {
app = app.merge(website_placeholder);
} else {
app = app.nest(&routing.website.mount, website_placeholder);
}
app
}
#[cfg(feature = "website")]
pub fn assemble_with_website(
routing: &RoutingConfig,
api: Router<AppState>,
website_state: Option<crate::website::WebsiteState>,
) -> Router<AppState> {
use axum::middleware::from_fn;
use crate::routing::security_headers::security_headers;
#[cfg(feature = "admin-ui")]
let admin: Router<AppState> = Router::new()
.route("/build-info.json", get(admin_ui::build_info))
.route("/plugins.json", get(admin_ui::plugins_manifest))
.route("/plugins/{id}/{*rel_path}", get(admin_ui::plugin_asset))
.route("/", get(admin_ui::serve_spa))
.route("/{*path}", get(admin_ui::serve_spa))
.layer(from_fn(security_headers));
#[cfg(not(feature = "admin-ui"))]
let admin: Router<AppState> = Router::new()
.route("/", any(placeholder_503))
.route("/{*path}", any(placeholder_503))
.layer(from_fn(security_headers));
let website: axum::Router<()> = match website_state {
Some(state) => Router::new()
.route("/", get(crate::website::serve))
.route("/{*path}", get(crate::website::serve))
.layer(from_fn(security_headers))
.with_state(state),
None => Router::new()
.route("/", get(crate::website::default_site::serve))
.route("/{*path}", get(crate::website::default_site::serve))
.layer(from_fn(security_headers)),
};
let spec = openapi_spec();
let mut app: Router<AppState> = Router::new()
.route("/health", get(health::health))
.route("/openapi.json", get(move || serve_openapi(spec.clone())))
.route("/.well-known/did.jsonl", get(did_log::did_log))
.route(
"/room-dids/{id}/did.jsonl",
get(crate::rooms::community::serve_room_did_log),
)
.nest(&routing.api.mount, api);
app = app.nest(&routing.admin_ui.mount, admin);
let admin_slash = format!("{}/", routing.admin_ui.mount.trim_end_matches('/'));
#[cfg(feature = "admin-ui")]
{
app = app.route(admin_slash.as_str(), get(admin_ui::serve_spa));
}
#[cfg(feature = "admin-ui")]
{
let members: Router<AppState> = Router::new()
.route("/", get(admin_ui::serve_members_spa))
.route("/{*path}", get(admin_ui::serve_members_spa))
.layer(from_fn(security_headers));
app = app.nest("/members", members);
app = app.route("/members/", get(admin_ui::serve_members_spa));
}
#[cfg(not(feature = "admin-ui"))]
{
app = app.route(admin_slash.as_str(), any(placeholder_503));
}
if routing.website.mount == "/" {
app = app.fallback_service(website);
} else {
app = app.nest_service(&routing.website.mount, website);
}
app
}
#[cfg_attr(all(feature = "website", feature = "admin-ui"), allow(dead_code))]
async fn placeholder_503() -> impl IntoResponse {
(
StatusCode::SERVICE_UNAVAILABLE,
"surface not yet implemented",
)
}
#[cfg(test)]
mod openapi_tests {
use super::*;
#[test]
fn openapi_spec_documents_the_bearer_scheme_and_the_signed_verbs_shapes() {
let spec = openapi_spec();
assert_eq!(spec.info.title, "Verifiable Trust Community (VTC) API");
let components = spec.components.as_ref().expect("components present");
assert!(components.security_schemes.contains_key("bearer_jwt"));
assert!(
components.schemas.contains_key("DiagnosticsResponse"),
"DiagnosticsResponse schema must be emitted"
);
}
const REST_EXCEPTIONS: &[(&str, &str, &str)] = &[
(
"POST",
"/v1/auth/refresh",
"the admin console's cookie-bound session renewal; it has no signed-document equivalent",
),
(
"POST",
"/v1/auth/sign-out",
"clears the console's cookie session; a browser action, not a protocol verb",
),
(
"POST",
"/v1/credential-exchange/request",
"redeems a credential offer; the key-binding proof is the authority, kept on its own governed mount alongside the generic Trust Task door",
),
(
"POST",
"/v1/trust-tasks",
"the generic Trust Task document endpoint itself",
),
(
"GET",
"/v1/oid4vp/request/{session}",
"OpenID4VP request_uri: a swiyu / EUDI wallet fetches the signed request object; the wallet signs no Trust Task (VTI-OPS-095). Its twin is vtc/join-requests/wallet-session/start/0.1",
),
(
"POST",
"/v1/oid4vp/response/{session}",
"OpenID4VP response_uri: the wallet posts its encrypted direct_post.jwt answer (VTI-OPS-095). Its twin is vtc/join-requests/wallet-session/status/0.1",
),
];
fn is_public_health_or_webauthn(method: &str, path: &str) -> bool {
const PUBLIC: &[(&str, &str)] = &[
("GET", "/v1/community/public-profile"),
("GET", "/v1/community/did-qr.svg"),
("GET", "/v1/status-lists/{purpose}"),
];
PUBLIC.contains(&(method, path))
|| path.starts_with("/v1/health")
|| path.contains("passkey")
}
#[test]
fn every_mounted_rest_route_is_justified() {
let spec = openapi_spec();
let mut unjustified = Vec::new();
for (path, item) in &spec.paths.paths {
for (method, op) in [
("GET", &item.get),
("POST", &item.post),
("PATCH", &item.patch),
("DELETE", &item.delete),
("PUT", &item.put),
] {
if op.is_none() {
continue;
}
if is_public_health_or_webauthn(method, path) {
continue;
}
if REST_EXCEPTIONS
.iter()
.any(|(m, p, _)| *m == method && *p == path)
{
continue;
}
unjustified.push(format!("{method} {path}"));
}
}
unjustified.sort();
assert!(
unjustified.is_empty(),
"these REST routes are mounted but not reasoned about — add each to REST_EXCEPTIONS with the reason it still needs a dedicated REST mount, or confirm it belongs in `is_public_health_or_webauthn` and extend that instead:\n {}",
unjustified.join("\n ")
);
let stale: Vec<&str> = REST_EXCEPTIONS
.iter()
.filter(|(m, p, _)| {
!spec
.paths
.paths
.get(*p)
.is_some_and(|item| op_for(item, m).is_some())
})
.map(|(_, p, _)| *p)
.collect();
assert!(
stale.is_empty(),
"REST_EXCEPTIONS entries for routes that are no longer mounted — remove them: {stale:?}"
);
}
fn op_for<'a>(
item: &'a utoipa::openapi::path::PathItem,
method: &str,
) -> Option<&'a utoipa::openapi::path::Operation> {
match method {
"GET" => item.get.as_ref(),
"POST" => item.post.as_ref(),
"PATCH" => item.patch.as_ref(),
"DELETE" => item.delete.as_ref(),
"PUT" => item.put.as_ref(),
_ => None,
}
}
const GOVERNED_UNAUTH: &[(&str, &str)] = &[
("POST", "/v1/auth/refresh"),
("POST", "/v1/auth/passkey-login/start"),
("POST", "/v1/auth/passkey-login/finish"),
("POST", "/v1/trust-tasks"),
("POST", "/v1/credential-exchange/request"),
("GET", "/v1/oid4vp/request/{session}"),
("POST", "/v1/oid4vp/response/{session}"),
];
const PUBLIC_UNGOVERNED: &[(&str, &str)] = &[
("GET", "/v1/community/public-profile"),
("GET", "/v1/community/did-qr.svg"),
("GET", "/v1/status-lists/{purpose}"),
];
fn documented_ops() -> Vec<(&'static str, String, bool)> {
let spec = openapi_spec();
let mut ops = Vec::new();
for (path, item) in &spec.paths.paths {
for (method, op) in [
("GET", &item.get),
("POST", &item.post),
("PATCH", &item.patch),
("DELETE", &item.delete),
("PUT", &item.put),
] {
if let Some(op) = op {
let secured = op.security.as_ref().map(|s| !s.is_empty()).unwrap_or(false);
ops.push((method, path.clone(), secured));
}
}
}
ops
}
fn in_allowlist(list: &[(&str, &str)], method: &str, path: &str) -> bool {
list.iter().any(|(m, p)| *m == method && *p == path)
}
#[test]
fn every_unauthenticated_route_is_classified() {
for (method, path, secured) in documented_ops() {
let governed = in_allowlist(GOVERNED_UNAUTH, method, &path);
let public = in_allowlist(PUBLIC_UNGOVERNED, method, &path);
if secured {
assert!(
!governed,
"{method} {path} requires a bearer JWT but is listed on the unauthenticated \
governed chain — an authenticated route must not sit in GOVERNED_UNAUTH"
);
} else {
assert!(
governed || public,
"{method} {path} is UNAUTHENTICATED but unclassified — add it to the governed \
unauth chain (GOVERNED_UNAUTH) or, if it is a deliberate public endpoint, to \
PUBLIC_UNGOVERNED. (This is the P0.5 backstop: an unauth route must never \
silently land on the 1 MiB no-limiter main chain.)"
);
assert!(
!(governed && public),
"{method} {path} is in both GOVERNED_UNAUTH and PUBLIC_UNGOVERNED — pick one"
);
}
}
}
#[test]
fn posture_allowlists_have_no_stale_entries() {
let ops = documented_ops();
let is_unauth_op = |method: &str, path: &str| {
ops.iter()
.any(|(m, p, secured)| *m == method && p == path && !secured)
};
for (method, path) in GOVERNED_UNAUTH.iter().chain(PUBLIC_UNGOVERNED) {
assert!(
is_unauth_op(method, path),
"posture allowlist entry {method} {path} is not a documented unauthenticated \
operation — remove it or fix the path/method"
);
}
}
}