194c44c
use export::AppState;
use export::space_token::SpaceTokenRecord;
use models::space::spaces;
use models::users::user::{self, ProfileVisibility};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait};
use serde::{Deserialize, Serialize};
use space::errors::SpaceError;
use space::services::members::{MemberActivationDefaults, activate_member_row};
use space::services::service::SpaceService;
use space::share::lookup::lookup_space_by_name;
use time::OffsetDateTime;
use uuid::Uuid;
/// Library-only service for issuing temporary top-level tokens for a space.
///
/// Deliberately not an HTTP route and not an MCP tool: it is only reachable
/// from Rust code that links this crate (`mcp_api::space_token::issue/revoke`).
/// The token authenticates as a login-prohibited per-space bot that is treated
/// as the platform system actor, but the MCP tool dispatch and the Git HTTP
/// auth entry points both restrict it to the single space the token was issued
/// for.
pub const DEFAULT_TTL_SECS: u64 = 15 * 60;
pub const MIN_TTL_SECS: u64 = 60;
pub const MAX_TTL_SECS: u64 = 24 * 60 * 60;
const BOT_USERNAME_SUFFIX: &str = "-bot";
const MAX_USERNAME_LEN: usize = 64;
#[derive(Debug, thiserror::Error)]
pub enum SpaceTokenError {
#[error("space not found")]
SpaceNotFound,
#[error("username {0:?} is already taken by a non-bot account")]
BotNameConflict(String),
#[error(transparent)]
Space(#[from] SpaceError),
#[error(transparent)]
Database(#[from] sea_orm::DbErr),
#[error(transparent)]
Export(#[from] export::ExportError),
}
impl From<sea_orm::TransactionError<SpaceTokenError>> for SpaceTokenError {
fn from(error: sea_orm::TransactionError<SpaceTokenError>) -> Self {
match error {
sea_orm::TransactionError::Connection(db_err) => Self::Database(db_err),
sea_orm::TransactionError::Transaction(error) => error,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueSpaceTokenCmd {
pub space: String,
#[serde(default)]
pub ttl_secs: Option<u64>,
#[serde(default)]
pub issued_by: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct IssuedSpaceToken {
pub token: String,
pub token_id: Uuid,
pub expires_at: OffsetDateTime,
pub space_id: Uuid,
pub space_name: String,
pub bot_uid: i64,
pub bot_username: String,
}
pub async fn issue(
state: &AppState,
cmd: IssueSpaceTokenCmd,
) -> Result<IssuedSpaceToken, SpaceTokenError> {
let ttl = cmd
.ttl_secs
.unwrap_or(DEFAULT_TTL_SECS)
.clamp(MIN_TTL_SECS, MAX_TTL_SECS);
let service = SpaceService::new(state.clone());
let space = match lookup_space_by_name(&service, cmd.space.trim()).await {
Ok(space) => space,
Err(SpaceError::NotFound) => return Err(SpaceTokenError::SpaceNotFound),
Err(error) => return Err(SpaceTokenError::Space(error)),
};
let username = bot_username(&space);
let bot_uid = resolve_space_bot(state, &space, &username).await?;
ensure_space_membership(state, space.id, bot_uid).await?;
let token_id = Uuid::now_v7();
let secret = random_secret();
let token = format!("{}{token_id}.{secret}", export::space_token::TOKEN_PREFIX);
let now = OffsetDateTime::now_utc();
let expires_at = now + time::Duration::seconds(ttl as i64);
let record = SpaceTokenRecord {
token_id,
space_id: space.id,
space_name: space.lower_name.clone(),
bot_uid,
secret_hash: export::space_token::hash_secret(&secret),
issued_by: cmd.issued_by.clone(),
created_at_unix: now.unix_timestamp(),
expires_at_unix: expires_at.unix_timestamp(),
};
export::space_token::store(&state.redis, &record, ttl).await?;
tracing::info!(
space_id = %space.id,
space = %space.lower_name,
bot_uid = %bot_uid,
token_id = %token_id,
ttl_secs = ttl,
issued_by = cmd.issued_by.as_deref().unwrap_or("-"),
"issued a temporary space top-level token"
);
Ok(IssuedSpaceToken {
token,
token_id,
expires_at,
space_id: space.id,
space_name: space.lower_name,
bot_uid,
bot_username: username,
})
}
/// Deletes a token before its TTL elapses; returns whether it still existed.
pub async fn revoke(state: &AppState, token_id: Uuid) -> Result<bool, SpaceTokenError> {
Ok(export::space_token::revoke(&state.redis, token_id).await?)
}
pub async fn verify(
state: &AppState,
token: &str,
) -> Result<Option<SpaceTokenRecord>, SpaceTokenError> {
Ok(export::space_token::verify(&state.redis, token).await?)
}
fn bot_username(space: &spaces::Model) -> String {
let base = format!("{}{BOT_USERNAME_SUFFIX}", space.lower_name);
if base.len() <= MAX_USERNAME_LEN {
return base;
}
let hash = export::space_token::hash_secret(&space.lower_name);
let keep = MAX_USERNAME_LEN - 1 - 8 - BOT_USERNAME_SUFFIX.len();
let prefix: String = space.lower_name.chars().take(keep).collect();
format!("{prefix}-{}{BOT_USERNAME_SUFFIX}", &hash[..8])
}
fn random_secret() -> String {
let bytes: [u8; 32] = rand::random();
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
/// Look up the space bot by its deterministic username and create it only when
/// it does not exist yet. The account is kept forever, so this is a plain
/// resolve-or-create; a racing insert falls back to a re-read.
async fn resolve_space_bot(
state: &AppState,
space: &spaces::Model,
username: &str,
) -> Result<i64, SpaceTokenError> {
let display = format!("{} Bot", space.name);
match agent::bot::ensure_bot(state, username, &display, true).await {
Ok(uid) => Ok(uid),
Err(sea_orm::DbErr::Custom(message))
if message.to_lowercase().contains("already taken") =>
{
Err(SpaceTokenError::BotNameConflict(username.to_string()))
}
Err(error) => Err(SpaceTokenError::Database(error)),
}
}
/// Idempotent: inserts the membership row on first use, revives a left/removed
/// row, and bumps `nums_members` exactly once. Skips the MANAGE_MEMBERS / 2FA
/// checks `add_member` performs because the bot is a system-equivalent actor.
async fn ensure_space_membership(
state: &AppState,
space_id: Uuid,
bot_uid: i64,
) -> Result<(), SpaceTokenError> {
if let Some(client) = state.kenauth.as_ref() {
client
.ensure_bot_member(&space_id.to_string(), bot_uid, Vec::new(), Vec::new())
.await
.map_err(|error| SpaceTokenError::Export(export::ExportError::Connection(error.to_string())))?;
}
let now = OffsetDateTime::now_utc();
state
.database
.primary()
.transaction::<_, bool, SpaceTokenError>(move |txn| {
Box::pin(async move {
activate_member_row(
txn,
space_id,
bot_uid,
now,
MemberActivationDefaults::default(),
)
.await?;
Ok(true)
})
})
.await?;
Ok(())
}