Add: multi token management

This commit is contained in:
DaZuo0122
2026-02-15 12:20:00 +08:00
parent f20ed1fd9d
commit 832fbda04d
8 changed files with 713 additions and 16 deletions

View File

@@ -1,8 +1,10 @@
use sprimo_api::{ApiConfig, ApiServerError, ApiState};
use sprimo_config::{save, AppConfig, ConfigError};
use sprimo_config::{save, ApiTokenEntry, AppConfig, ConfigError};
use sprimo_protocol::v1::{CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
use tokio::runtime::Runtime;
use tokio::sync::{mpsc, Mutex};
@@ -19,6 +21,10 @@ pub enum RuntimeCoreError {
SnapshotPoisoned,
#[error("config lock poisoned")]
ConfigPoisoned,
#[error("api token not found: {0}")]
ApiTokenNotFound(String),
#[error("cannot revoke the last API token")]
LastApiToken,
}
pub struct RuntimeCore {
@@ -26,6 +32,7 @@ pub struct RuntimeCore {
config: Arc<RwLock<AppConfig>>,
snapshot: Arc<RwLock<FrontendStateSnapshot>>,
api_config: ApiConfig,
auth_store: Arc<RwLock<HashSet<String>>>,
command_tx: mpsc::Sender<CommandEnvelope>,
command_rx: Arc<Mutex<mpsc::Receiver<CommandEnvelope>>>,
}
@@ -43,6 +50,7 @@ impl RuntimeCore {
) -> Result<Self, RuntimeCoreError> {
let click_through_was_enabled = config_value.window.click_through;
config_value.window.click_through = false;
let tokens_changed = normalize_api_tokens(&mut config_value);
let mut snapshot = FrontendStateSnapshot::idle(capabilities);
snapshot.x = config_value.window.x;
snapshot.y = config_value.window.y;
@@ -52,18 +60,34 @@ impl RuntimeCore {
snapshot.flags.visible = config_value.window.visible;
snapshot.active_sprite_pack = config_value.sprite.selected_pack.clone();
let api_config = ApiConfig::default_with_token(config_value.api.auth_token.clone());
let api_config = ApiConfig::default_with_tokens(
config_value
.api
.auth_tokens
.iter()
.map(|entry| entry.token.clone())
.collect(),
);
let (command_tx, command_rx) = mpsc::channel(1_024);
let auth_store = Arc::new(RwLock::new(
config_value
.api
.auth_tokens
.iter()
.map(|entry| entry.token.clone())
.collect(),
));
let core = Self {
config_path,
config: Arc::new(RwLock::new(config_value)),
snapshot: Arc::new(RwLock::new(snapshot)),
api_config,
auth_store,
command_tx,
command_rx: Arc::new(Mutex::new(command_rx)),
};
if click_through_was_enabled {
if click_through_was_enabled || tokens_changed {
core.persist_config()?;
}
Ok(core)
@@ -137,15 +161,108 @@ impl RuntimeCore {
self.api_config.clone()
}
pub fn list_api_tokens(&self) -> Result<Vec<ApiTokenEntry>, RuntimeCoreError> {
let guard = self
.config
.read()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
Ok(guard.api.auth_tokens.clone())
}
pub fn create_api_token(
&self,
label: Option<String>,
) -> Result<ApiTokenEntry, RuntimeCoreError> {
let entry = ApiTokenEntry {
id: uuid::Uuid::new_v4().to_string(),
label: normalize_token_label(label.as_deref().unwrap_or("")),
token: uuid::Uuid::new_v4().to_string(),
created_at_ms: now_unix_ms(),
};
{
let mut guard = self
.config
.write()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
guard.api.auth_tokens.push(entry.clone());
guard.api.auth_token = guard
.api
.auth_tokens
.first()
.map(|token| token.token.clone())
.unwrap_or_default();
}
self.persist_config()?;
self.refresh_auth_store_from_config()?;
Ok(entry)
}
pub fn rename_api_token(&self, id: &str, label: &str) -> Result<(), RuntimeCoreError> {
let mut found = false;
{
let mut guard = self
.config
.write()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
for entry in &mut guard.api.auth_tokens {
if entry.id == id {
entry.label = normalize_token_label(label);
found = true;
break;
}
}
}
if !found {
return Err(RuntimeCoreError::ApiTokenNotFound(id.to_string()));
}
self.persist_config()?;
Ok(())
}
pub fn revoke_api_token(&self, id: &str) -> Result<(), RuntimeCoreError> {
let removed = {
let mut guard = self
.config
.write()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
if guard.api.auth_tokens.len() <= 1 {
return Err(RuntimeCoreError::LastApiToken);
}
let before = guard.api.auth_tokens.len();
guard.api.auth_tokens.retain(|entry| entry.id != id);
let after = guard.api.auth_tokens.len();
guard.api.auth_token = guard
.api
.auth_tokens
.first()
.map(|token| token.token.clone())
.unwrap_or_default();
before != after
};
if !removed {
return Err(RuntimeCoreError::ApiTokenNotFound(id.to_string()));
}
self.persist_config()?;
self.refresh_auth_store_from_config()?;
Ok(())
}
pub fn spawn_api(&self, runtime: &Runtime) {
let mut cfg = self.api_config.clone();
if let Ok(guard) = self.config.read() {
cfg.bind_addr = ([127, 0, 0, 1], guard.api.port).into();
cfg.auth_tokens = guard
.api
.auth_tokens
.iter()
.map(|entry| entry.token.clone())
.collect();
}
let state = Arc::new(ApiState::new(
cfg.clone(),
Arc::clone(&self.snapshot),
self.command_tx.clone(),
Arc::clone(&self.auth_store),
));
runtime.spawn(async move {
if let Err(err) = sprimo_api::run_server(cfg, state).await {
@@ -273,6 +390,27 @@ impl RuntimeCore {
save(&self.config_path, &guard)?;
Ok(())
}
fn refresh_auth_store_from_config(&self) -> Result<(), RuntimeCoreError> {
let tokens = {
let guard = self
.config
.read()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
guard
.api
.auth_tokens
.iter()
.map(|entry| entry.token.clone())
.collect::<HashSet<_>>()
};
let mut auth = self
.auth_store
.write()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
*auth = tokens;
Ok(())
}
}
fn default_animation_for_state(state: sprimo_protocol::v1::FrontendState) -> &'static str {
@@ -297,6 +435,98 @@ fn log_api_error(err: ApiServerError) {
warn!(%err, "runtime core api server exited");
}
fn normalize_api_tokens(config: &mut AppConfig) -> bool {
let mut changed = false;
let mut seen = HashSet::new();
let mut normalized = Vec::new();
let legacy = config.api.auth_token.trim().to_string();
if legacy != config.api.auth_token {
config.api.auth_token = legacy.clone();
changed = true;
}
for mut entry in config.api.auth_tokens.clone() {
let original_id = entry.id.clone();
let original_label = entry.label.clone();
let original_token = entry.token.clone();
let original_created = entry.created_at_ms;
entry.id = entry.id.trim().to_string();
if entry.id.is_empty() {
entry.id = uuid::Uuid::new_v4().to_string();
}
entry.label = normalize_token_label(&entry.label);
entry.token = entry.token.trim().to_string();
if entry.created_at_ms == 0 {
entry.created_at_ms = now_unix_ms();
}
let field_changed = entry.id != original_id
|| entry.label != original_label
|| entry.token != original_token
|| entry.created_at_ms != original_created;
if field_changed {
changed = true;
}
if entry.token.is_empty() {
changed = true;
continue;
}
if !seen.insert(entry.token.clone()) {
changed = true;
continue;
}
normalized.push(entry);
}
if normalized.is_empty() {
let token = if legacy.is_empty() {
uuid::Uuid::new_v4().to_string()
} else {
legacy
};
normalized.push(ApiTokenEntry {
id: uuid::Uuid::new_v4().to_string(),
label: "default".to_string(),
token,
created_at_ms: now_unix_ms(),
});
changed = true;
}
let mirror = normalized
.first()
.map(|entry| entry.token.clone())
.unwrap_or_default();
if config.api.auth_token != mirror {
config.api.auth_token = mirror;
changed = true;
}
if config.api.auth_tokens != normalized {
config.api.auth_tokens = normalized;
changed = true;
}
changed
}
fn normalize_token_label(value: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
"token".to_string()
} else {
trimmed.to_string()
}
}
fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|v| v.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::RuntimeCore;
@@ -399,4 +629,48 @@ mod tests {
1
);
}
#[test]
fn api_token_create_rename_revoke_roundtrip() {
let temp = TempDir::new().expect("tempdir");
let path = temp.path().join("config.toml");
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
.expect("core init");
let initial_len = core.list_api_tokens().expect("tokens").len();
let created = core
.create_api_token(Some("backend-ci".to_string()))
.expect("create token");
let after_create = core.list_api_tokens().expect("tokens");
assert_eq!(after_create.len(), initial_len + 1);
assert!(after_create.iter().any(|entry| entry.id == created.id));
core.rename_api_token(&created.id, "automation")
.expect("rename token");
let after_rename = core.list_api_tokens().expect("tokens");
assert!(
after_rename
.iter()
.any(|entry| entry.id == created.id && entry.label == "automation")
);
core.revoke_api_token(&created.id).expect("revoke token");
let after_revoke = core.list_api_tokens().expect("tokens");
assert_eq!(after_revoke.len(), initial_len);
assert!(!after_revoke.iter().any(|entry| entry.id == created.id));
}
#[test]
fn cannot_revoke_last_api_token() {
let temp = TempDir::new().expect("tempdir");
let path = temp.path().join("config.toml");
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
.expect("core init");
let only = core.list_api_tokens().expect("tokens");
assert_eq!(only.len(), 1);
let err = core
.revoke_api_token(&only[0].id)
.expect_err("last token revoke must fail");
assert!(format!("{err}").contains("last API token"));
}
}