From d7c8064a6a4df1336dbaec93eb9b4ce275e444e9 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 10 Oct 2026 21:42:06 +0000 Subject: [PATCH] feat(meta): advertise the team secret types the server accepts A client newer than the server had no way to know that a team secret type was missing: uploading a port-knock sequence to a server without migration 055 came back as a bare 400 (VoltiusApp/voltius#608). /v1/meta now carries team_secret_types, built from the same table the upload handler uses to resolve a type's owner, so the list cannot drift from what is accepted. A client reads it before uploading and treats a server that omits the field as one that predates it. --- src/routes/meta.rs | 14 +++++++++++++- src/routes/team_objects.rs | 30 +++++++++++++++++++----------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/routes/meta.rs b/src/routes/meta.rs index fb0d56a..3120bd3 100644 --- a/src/routes/meta.rs +++ b/src/routes/meta.rs @@ -7,7 +7,7 @@ use axum::{Extension, Json}; use serde::Serialize; -use crate::{features::Features, self_host}; +use crate::{features::Features, routes::team_objects, self_host}; #[derive(Serialize)] pub struct MetaResponse { @@ -17,6 +17,8 @@ pub struct MetaResponse { pub team_invites_enabled: bool, pub identity_picks: bool, pub handles_from_email: bool, + /// A client must not upload a type missing from this list; a server that omits the field predates it. + pub team_secret_types: Vec<&'static str>, } pub async fn get_meta(Extension(features): Extension) -> Json { @@ -28,6 +30,7 @@ pub async fn get_meta(Extension(features): Extension) -> Json Option { secret_owner_type(secret_type).and_then(edit_permission_for_str) } +/// Each accepted secret type with the object type that owns it; `/v1/meta` advertises the first column. +const SECRET_TYPES: &[(&str, &str)] = &[ + ("connection_password", "connection"), + ("connection_key", "connection"), + ("connection_passphrase", "connection"), + ("connection_proxy_password", "connection"), + ("connection_knock_sequence", "connection"), + ("identity_password", "identity"), + ("key_private", "key"), + ("key_public", "key"), + ("key_passphrase", "key"), +]; + +pub fn secret_types() -> Vec<&'static str> { + SECRET_TYPES.iter().map(|(secret_type, _)| *secret_type).collect() +} + fn secret_owner_type(secret_type: &str) -> Option<&'static str> { - match secret_type { - "connection_password" - | "connection_key" - | "connection_passphrase" - | "connection_proxy_password" - | "connection_knock_sequence" => Some("connection"), - "identity_password" => Some("identity"), - "key_private" | "key_public" | "key_passphrase" => Some("key"), - _ => None, - } + SECRET_TYPES.iter().find(|(known, _)| *known == secret_type).map(|(_, owner)| *owner) } /// Inverse of the client's `teamSecretFromLocalKey`; a colon in a connection key id would alias `key::`. @@ -1227,8 +1235,8 @@ mod authz_tests { ]; for (secret_type, id) in cases { assert_eq!(canonical_secret_id("o", secret_type).as_deref(), Some(id)); - assert!(secret_owner_type(secret_type).is_some()); } + assert_eq!(secret_types(), cases.map(|(secret_type, _)| secret_type)); assert_eq!(canonical_secret_id("o", "bogus"), None); assert_eq!(canonical_secret_id("k:passphrase", "connection_key"), None); assert_eq!(canonical_secret_id("__global__", "connection_proxy_password"), None);