Skip to main content

agent_relay/
server.rs

1//! HTTP relay server with SQLite persistence.
2//!
3//! Messages survive server restarts. Run with:
4//!   `agent-relay server --port 4800`
5//!
6//! Data stored in `~/.agent-relay/relay.db` by default.
7
8use axum::{
9    extract::{Path as AxumPath, Query, State},
10    http::{HeaderMap, StatusCode},
11    routing::{get, post},
12    Json, Router,
13};
14use rusqlite::Connection;
15use serde::{Deserialize, Serialize};
16use std::path::Path;
17use std::sync::{Arc, Mutex};
18
19use crate::hub;
20use crate::{AgentRegistration, Message};
21
22// ── Shared state ──
23
24pub type SharedState = Arc<Mutex<Connection>>;
25
26/// Initialize the database schema.
27pub fn init_db(conn: &Connection) {
28    conn.execute_batch(
29        "CREATE TABLE IF NOT EXISTS messages (
30            id TEXT PRIMARY KEY,
31            from_session TEXT NOT NULL,
32            from_agent TEXT NOT NULL,
33            to_session TEXT,
34            content TEXT NOT NULL,
35            timestamp INTEGER NOT NULL
36        );
37        CREATE TABLE IF NOT EXISTS message_reads (
38            message_id TEXT NOT NULL,
39            session_id TEXT NOT NULL,
40            PRIMARY KEY (message_id, session_id)
41        );
42        CREATE TABLE IF NOT EXISTS agents (
43            session_id TEXT PRIMARY KEY,
44            agent_id TEXT NOT NULL,
45            pid INTEGER NOT NULL,
46            registered_at INTEGER NOT NULL,
47            last_heartbeat INTEGER NOT NULL,
48            metadata TEXT NOT NULL DEFAULT '{}'
49        );",
50    )
51    .expect("Failed to initialize database schema");
52
53    hub::init_hub_tables(conn);
54}
55
56// ── Request/Response types ──
57
58#[derive(Deserialize)]
59pub struct SendRequest {
60    pub from_session: String,
61    pub from_agent: String,
62    pub to_session: Option<String>,
63    pub content: String,
64    pub team_id: Option<String>,
65    pub channel: Option<String>,
66}
67
68#[derive(Deserialize)]
69pub struct InboxQuery {
70    pub session: String,
71    #[serde(default = "default_limit")]
72    pub limit: usize,
73    pub team: Option<String>,
74    pub channel: Option<String>,
75}
76
77fn default_limit() -> usize {
78    50
79}
80
81#[derive(Deserialize)]
82pub struct RegisterRequest {
83    pub session_id: String,
84    pub agent_id: String,
85    pub pid: u32,
86    #[serde(default)]
87    pub metadata: serde_json::Value,
88}
89
90#[derive(Serialize)]
91pub struct CountResponse {
92    pub count: u64,
93}
94
95// ── Router ──
96
97/// Build the router with a SQLite-backed database at the given path.
98pub fn build_router(db_path: &Path) -> Router {
99    let conn = Connection::open(db_path).expect("Failed to open SQLite database");
100    conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")
101        .expect("Failed to set pragmas");
102    init_db(&conn);
103
104    let state: SharedState = Arc::new(Mutex::new(conn));
105
106    Router::new()
107        .route("/health", get(health))
108        .route("/agents", get(list_agents))
109        .route("/agents/register", post(register_agent))
110        .route("/agents/unregister", post(unregister_agent))
111        .route("/messages/send", post(send_message))
112        .route("/messages/inbox", get(inbox))
113        .route("/messages/unread", get(unread_count))
114        // Auth routes
115        .route("/auth/signup", post(signup))
116        .route("/auth/login", post(login))
117        // Team routes
118        .route("/teams", post(create_team).get(list_teams))
119        .route("/teams/join", post(join_team))
120        .route("/teams/{team_id}/invite", post(create_invite))
121        .route("/teams/{team_id}/members", get(list_members))
122        .route("/teams/{team_id}/kick", post(kick_member))
123        // Channel routes
124        .route(
125            "/teams/{team_id}/channels",
126            post(create_channel).get(list_channels),
127        )
128        .layer(
129            tower_http::cors::CorsLayer::new()
130                .allow_origin(tower_http::cors::Any)
131                .allow_methods([
132                    axum::http::Method::GET,
133                    axum::http::Method::POST,
134                ])
135                .allow_headers([axum::http::header::CONTENT_TYPE, axum::http::header::AUTHORIZATION])
136                .max_age(std::time::Duration::from_secs(3600)),
137        )
138        .with_state(state)
139}
140
141// ── Handlers ──
142
143async fn health() -> &'static str {
144    "agent-relay server ok"
145}
146
147async fn register_agent(
148    State(state): State<SharedState>,
149    Json(req): Json<RegisterRequest>,
150) -> (StatusCode, Json<AgentRegistration>) {
151    let now = crate::Relay::now();
152    let metadata_str = req.metadata.to_string();
153
154    let conn = state.lock().unwrap();
155    conn.execute(
156        "INSERT OR REPLACE INTO agents (session_id, agent_id, pid, registered_at, last_heartbeat, metadata)
157         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
158        rusqlite::params![req.session_id, req.agent_id, req.pid, now, now, metadata_str],
159    )
160    .expect("Failed to insert agent");
161
162    let reg = AgentRegistration {
163        session_id: req.session_id,
164        agent_id: req.agent_id,
165        pid: req.pid,
166        registered_at: now,
167        last_heartbeat: now,
168        metadata: req.metadata,
169    };
170
171    (StatusCode::CREATED, Json(reg))
172}
173
174async fn unregister_agent(
175    State(state): State<SharedState>,
176    Json(req): Json<serde_json::Value>,
177) -> StatusCode {
178    let session = req["session_id"].as_str().unwrap_or("");
179    let conn = state.lock().unwrap();
180    let _ = conn.execute("DELETE FROM agents WHERE session_id = ?1", [session]);
181    StatusCode::OK
182}
183
184async fn list_agents(State(state): State<SharedState>) -> Json<Vec<AgentRegistration>> {
185    let conn = state.lock().unwrap();
186    let mut stmt = conn
187        .prepare("SELECT session_id, agent_id, pid, registered_at, last_heartbeat, metadata FROM agents ORDER BY last_heartbeat DESC")
188        .unwrap();
189
190    let agents: Vec<AgentRegistration> = stmt
191        .query_map([], |row| {
192            let metadata_str: String = row.get(5)?;
193            Ok(AgentRegistration {
194                session_id: row.get(0)?,
195                agent_id: row.get(1)?,
196                pid: row.get::<_, u32>(2)?,
197                registered_at: row.get(3)?,
198                last_heartbeat: row.get(4)?,
199                metadata: serde_json::from_str(&metadata_str).unwrap_or_default(),
200            })
201        })
202        .unwrap()
203        .filter_map(|r| r.ok())
204        .collect();
205
206    Json(agents)
207}
208
209async fn send_message(
210    State(state): State<SharedState>,
211    Json(req): Json<SendRequest>,
212) -> Result<(StatusCode, Json<Message>), (StatusCode, String)> {
213    // SECURITY: Enforce message size limits to prevent abuse
214    if req.content.len() > 10_000 {
215        return Err((
216            StatusCode::PAYLOAD_TOO_LARGE,
217            "Message content exceeds 10KB limit".to_string(),
218        ));
219    }
220    if req.content.is_empty() {
221        return Err((
222            StatusCode::BAD_REQUEST,
223            "Message content cannot be empty".to_string(),
224        ));
225    }
226
227    let now = crate::Relay::now();
228    let id = format!("msg-{}", &uuid::Uuid::new_v4().to_string()[..8]);
229
230    let conn = state.lock().unwrap();
231    conn.execute(
232        "INSERT INTO messages (id, from_session, from_agent, to_session, content, timestamp, team_id, channel)
233         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
234        rusqlite::params![
235            id,
236            req.from_session,
237            req.from_agent,
238            req.to_session,
239            req.content,
240            now,
241            req.team_id,
242            req.channel.as_deref().unwrap_or("general")
243        ],
244    )
245    .expect("Failed to insert message");
246
247    // Mark as read by sender
248    let _ = conn.execute(
249        "INSERT OR IGNORE INTO message_reads (message_id, session_id) VALUES (?1, ?2)",
250        rusqlite::params![id, req.from_session],
251    );
252
253    let msg = Message {
254        id,
255        from_session: req.from_session,
256        from_agent: req.from_agent,
257        to_session: req.to_session,
258        content: req.content,
259        timestamp: now,
260        read_by: vec![],
261    };
262
263    Ok((StatusCode::CREATED, Json(msg)))
264}
265
266async fn inbox(
267    State(state): State<SharedState>,
268    Query(q): Query<InboxQuery>,
269) -> Json<Vec<Message>> {
270    let conn = state.lock().unwrap();
271
272    let mut sql = String::from(
273        "SELECT id, from_session, from_agent, to_session, content, timestamp
274         FROM messages
275         WHERE (to_session IS NULL OR to_session = ?1 OR from_session = ?1)",
276    );
277    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![
278        Box::new(q.session.clone()),
279    ];
280
281    if let Some(ref team) = q.team {
282        params.push(Box::new(team.clone()));
283        sql.push_str(&format!(" AND team_id = ?{}", params.len()));
284    }
285    if let Some(ref channel) = q.channel {
286        params.push(Box::new(channel.clone()));
287        sql.push_str(&format!(" AND channel = ?{}", params.len()));
288    }
289
290    params.push(Box::new(q.limit as i64));
291    sql.push_str(&format!(" ORDER BY timestamp DESC LIMIT ?{}", params.len()));
292
293    let mut stmt = conn.prepare(&sql).unwrap();
294    let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
295
296    let messages: Vec<Message> = stmt
297        .query_map(param_refs.as_slice(), |row| {
298            Ok(Message {
299                id: row.get(0)?,
300                from_session: row.get(1)?,
301                from_agent: row.get(2)?,
302                to_session: row.get(3)?,
303                content: row.get(4)?,
304                timestamp: row.get(5)?,
305                read_by: vec![],
306            })
307        })
308        .unwrap()
309        .filter_map(|r| r.ok())
310        .collect();
311
312    // Mark all returned messages as read by this session
313    for msg in &messages {
314        let _ = conn.execute(
315            "INSERT OR IGNORE INTO message_reads (message_id, session_id) VALUES (?1, ?2)",
316            rusqlite::params![msg.id, q.session],
317        );
318    }
319
320    Json(messages)
321}
322
323async fn unread_count(
324    State(state): State<SharedState>,
325    Query(q): Query<InboxQuery>,
326) -> Json<CountResponse> {
327    let conn = state.lock().unwrap();
328
329    let count: u64 = conn
330        .query_row(
331            "SELECT COUNT(*) FROM messages m
332             WHERE (m.to_session IS NULL OR m.to_session = ?1)
333               AND m.from_session != ?1
334               AND NOT EXISTS (
335                   SELECT 1 FROM message_reads mr
336                   WHERE mr.message_id = m.id AND mr.session_id = ?1
337               )",
338            [&q.session],
339            |row| row.get(0),
340        )
341        .unwrap_or(0);
342
343    Json(CountResponse { count })
344}
345
346// ── Auth helper ──
347
348fn extract_user(
349    state: &SharedState,
350    headers: &HeaderMap,
351) -> Result<hub::User, (StatusCode, String)> {
352    let auth = headers
353        .get("authorization")
354        .and_then(|v| v.to_str().ok())
355        .and_then(|v| v.strip_prefix("Bearer "))
356        .ok_or((
357            StatusCode::UNAUTHORIZED,
358            "Missing Authorization header".to_string(),
359        ))?;
360    let conn = state.lock().unwrap();
361    hub::verify_api_key(&conn, auth).ok_or((
362        StatusCode::UNAUTHORIZED,
363        "Invalid API key".to_string(),
364    ))
365}
366
367// ── Auth request/response types ──
368
369#[derive(Deserialize)]
370struct SignupRequest {
371    email: String,
372    name: String,
373}
374
375#[derive(Serialize)]
376struct SignupResponse {
377    user: hub::User,
378    api_key: String,
379}
380
381#[derive(Deserialize)]
382struct LoginRequest {
383    email: String,
384}
385
386#[derive(Serialize)]
387struct LoginResponse {
388    api_key: String,
389}
390
391// ── Auth handlers ──
392
393async fn signup(
394    State(state): State<SharedState>,
395    Json(req): Json<SignupRequest>,
396) -> Result<(StatusCode, Json<SignupResponse>), (StatusCode, String)> {
397    let conn = state.lock().unwrap();
398    let now = crate::Relay::now();
399    let user_id = uuid::Uuid::new_v4().to_string();
400
401    conn.execute(
402        "INSERT INTO users (id, email, name, created_at) VALUES (?1, ?2, ?3, ?4)",
403        rusqlite::params![user_id, req.email, req.name, now],
404    )
405    .map_err(|e| {
406        if e.to_string().contains("UNIQUE") {
407            (
408                StatusCode::CONFLICT,
409                format!("Email '{}' already registered. Use /auth/login instead.", req.email),
410            )
411        } else {
412            (
413                StatusCode::INTERNAL_SERVER_ERROR,
414                format!("Database error: {}", e),
415            )
416        }
417    })?;
418
419    let api_key = hub::generate_api_key();
420    let key_hash = hub::hash_api_key(&api_key);
421
422    conn.execute(
423        "INSERT INTO api_keys (key_hash, user_id, label, created_at) VALUES (?1, ?2, ?3, ?4)",
424        rusqlite::params![key_hash, user_id, "default", now],
425    )
426    .map_err(|e| {
427        (
428            StatusCode::INTERNAL_SERVER_ERROR,
429            format!("Failed to create API key: {}", e),
430        )
431    })?;
432
433    let user = hub::User {
434        id: user_id,
435        email: req.email,
436        name: req.name,
437    };
438
439    Ok((
440        StatusCode::CREATED,
441        Json(SignupResponse {
442            user,
443            api_key,
444        }),
445    ))
446}
447
448async fn login(
449    State(state): State<SharedState>,
450    Json(req): Json<LoginRequest>,
451) -> Result<Json<LoginResponse>, (StatusCode, String)> {
452    let conn = state.lock().unwrap();
453    let now = crate::Relay::now();
454
455    let user_id: String = conn
456        .query_row(
457            "SELECT id FROM users WHERE email = ?1",
458            [&req.email],
459            |row| row.get(0),
460        )
461        .map_err(|_| {
462            (
463                StatusCode::NOT_FOUND,
464                format!("No account found for '{}'", req.email),
465            )
466        })?;
467
468    let api_key = hub::generate_api_key();
469    let key_hash = hub::hash_api_key(&api_key);
470
471    conn.execute(
472        "INSERT INTO api_keys (key_hash, user_id, label, created_at) VALUES (?1, ?2, ?3, ?4)",
473        rusqlite::params![key_hash, user_id, "default", now],
474    )
475    .map_err(|e| {
476        (
477            StatusCode::INTERNAL_SERVER_ERROR,
478            format!("Failed to create API key: {}", e),
479        )
480    })?;
481
482    Ok(Json(LoginResponse { api_key }))
483}
484
485// ── Team request/response types ──
486
487#[derive(Deserialize)]
488struct CreateTeamRequest {
489    name: String,
490}
491
492#[derive(Deserialize)]
493struct InviteRequest {
494    email: Option<String>,
495}
496
497#[derive(Serialize)]
498struct InviteResponse {
499    token: String,
500}
501
502#[derive(Deserialize)]
503struct JoinTeamRequest {
504    token: String,
505}
506
507#[derive(Deserialize)]
508struct KickRequest {
509    user_id: String,
510}
511
512#[derive(Deserialize)]
513struct CreateChannelRequest {
514    name: String,
515}
516
517// ── Team handlers ──
518
519async fn create_team(
520    State(state): State<SharedState>,
521    headers: HeaderMap,
522    Json(req): Json<CreateTeamRequest>,
523) -> Result<(StatusCode, Json<hub::Team>), (StatusCode, String)> {
524    let user = extract_user(&state, &headers)?;
525    let conn = state.lock().unwrap();
526    let now = crate::Relay::now();
527    let team_id = uuid::Uuid::new_v4().to_string();
528
529    conn.execute(
530        "INSERT INTO teams (id, name, owner_id, created_at) VALUES (?1, ?2, ?3, ?4)",
531        rusqlite::params![team_id, req.name, user.id, now],
532    )
533    .map_err(|e| {
534        if e.to_string().contains("UNIQUE") {
535            (
536                StatusCode::CONFLICT,
537                format!("Team name '{}' already taken", req.name),
538            )
539        } else {
540            (
541                StatusCode::INTERNAL_SERVER_ERROR,
542                format!("Database error: {}", e),
543            )
544        }
545    })?;
546
547    // Add creator as owner
548    conn.execute(
549        "INSERT INTO team_members (team_id, user_id, role, joined_at) VALUES (?1, ?2, ?3, ?4)",
550        rusqlite::params![team_id, user.id, "owner", now],
551    )
552    .map_err(|e| {
553        (
554            StatusCode::INTERNAL_SERVER_ERROR,
555            format!("Failed to add owner: {}", e),
556        )
557    })?;
558
559    // Create #general channel
560    let channel_id = uuid::Uuid::new_v4().to_string();
561    conn.execute(
562        "INSERT INTO channels (id, team_id, name, created_at) VALUES (?1, ?2, ?3, ?4)",
563        rusqlite::params![channel_id, team_id, "general", now],
564    )
565    .map_err(|e| {
566        (
567            StatusCode::INTERNAL_SERVER_ERROR,
568            format!("Failed to create #general channel: {}", e),
569        )
570    })?;
571
572    let team = hub::Team {
573        id: team_id,
574        name: req.name,
575        owner_id: user.id,
576        created_at: now,
577    };
578
579    Ok((StatusCode::CREATED, Json(team)))
580}
581
582async fn list_teams(
583    State(state): State<SharedState>,
584    headers: HeaderMap,
585) -> Result<Json<Vec<hub::Team>>, (StatusCode, String)> {
586    let user = extract_user(&state, &headers)?;
587    let conn = state.lock().unwrap();
588
589    let mut stmt = conn
590        .prepare(
591            "SELECT t.id, t.name, t.owner_id, t.created_at
592             FROM teams t
593             JOIN team_members tm ON tm.team_id = t.id
594             WHERE tm.user_id = ?1
595             ORDER BY t.created_at DESC",
596        )
597        .map_err(|e| {
598            (
599                StatusCode::INTERNAL_SERVER_ERROR,
600                format!("Query error: {}", e),
601            )
602        })?;
603
604    let teams: Vec<hub::Team> = stmt
605        .query_map([&user.id], |row| {
606            Ok(hub::Team {
607                id: row.get(0)?,
608                name: row.get(1)?,
609                owner_id: row.get(2)?,
610                created_at: row.get(3)?,
611            })
612        })
613        .map_err(|e| {
614            (
615                StatusCode::INTERNAL_SERVER_ERROR,
616                format!("Query error: {}", e),
617            )
618        })?
619        .filter_map(|r| r.ok())
620        .collect();
621
622    Ok(Json(teams))
623}
624
625async fn create_invite(
626    State(state): State<SharedState>,
627    headers: HeaderMap,
628    AxumPath(team_id): AxumPath<String>,
629    Json(req): Json<InviteRequest>,
630) -> Result<(StatusCode, Json<InviteResponse>), (StatusCode, String)> {
631    let user = extract_user(&state, &headers)?;
632    let conn = state.lock().unwrap();
633
634    // Check user is owner or admin
635    let role: String = conn
636        .query_row(
637            "SELECT role FROM team_members WHERE team_id = ?1 AND user_id = ?2",
638            rusqlite::params![team_id, user.id],
639            |row| row.get(0),
640        )
641        .map_err(|_| {
642            (
643                StatusCode::FORBIDDEN,
644                "You are not a member of this team".to_string(),
645            )
646        })?;
647
648    if role != "owner" && role != "admin" {
649        return Err((
650            StatusCode::FORBIDDEN,
651            "Only owners and admins can create invites".to_string(),
652        ));
653    }
654
655    let now = crate::Relay::now();
656    let token = format!(
657        "inv_{}",
658        uuid::Uuid::new_v4().to_string().replace('-', "")
659    );
660
661    conn.execute(
662        "INSERT INTO invites (token, team_id, inviter_id, email, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
663        rusqlite::params![token, team_id, user.id, req.email, now],
664    )
665    .map_err(|e| {
666        (
667            StatusCode::INTERNAL_SERVER_ERROR,
668            format!("Failed to create invite: {}", e),
669        )
670    })?;
671
672    Ok((StatusCode::CREATED, Json(InviteResponse { token })))
673}
674
675async fn join_team(
676    State(state): State<SharedState>,
677    headers: HeaderMap,
678    Json(req): Json<JoinTeamRequest>,
679) -> Result<Json<hub::Team>, (StatusCode, String)> {
680    let user = extract_user(&state, &headers)?;
681    let conn = state.lock().unwrap();
682
683    // Find the invite
684    let (team_id, invite_email): (String, Option<String>) = conn
685        .query_row(
686            "SELECT team_id, email FROM invites WHERE token = ?1 AND used_at IS NULL",
687            [&req.token],
688            |row| Ok((row.get(0)?, row.get(1)?)),
689        )
690        .map_err(|_| {
691            (
692                StatusCode::NOT_FOUND,
693                "Invalid or already used invite token".to_string(),
694            )
695        })?;
696
697    // If invite is email-scoped, check it matches
698    if let Some(ref email) = invite_email {
699        if email != &user.email {
700            return Err((
701                StatusCode::FORBIDDEN,
702                format!("This invite is for {}", email),
703            ));
704        }
705    }
706
707    let now = crate::Relay::now();
708
709    // Check not already a member
710    let already: bool = conn
711        .query_row(
712            "SELECT COUNT(*) FROM team_members WHERE team_id = ?1 AND user_id = ?2",
713            rusqlite::params![team_id, user.id],
714            |row| row.get::<_, i64>(0),
715        )
716        .map(|c| c > 0)
717        .unwrap_or(false);
718
719    if already {
720        return Err((
721            StatusCode::CONFLICT,
722            "You are already a member of this team".to_string(),
723        ));
724    }
725
726    // Add member
727    conn.execute(
728        "INSERT INTO team_members (team_id, user_id, role, joined_at) VALUES (?1, ?2, ?3, ?4)",
729        rusqlite::params![team_id, user.id, "member", now],
730    )
731    .map_err(|e| {
732        (
733            StatusCode::INTERNAL_SERVER_ERROR,
734            format!("Failed to join team: {}", e),
735        )
736    })?;
737
738    // Mark invite as used
739    let _ = conn.execute(
740        "UPDATE invites SET used_at = ?1 WHERE token = ?2",
741        rusqlite::params![now, req.token],
742    );
743
744    // Return team info
745    let team = conn
746        .query_row(
747            "SELECT id, name, owner_id, created_at FROM teams WHERE id = ?1",
748            [&team_id],
749            |row| {
750                Ok(hub::Team {
751                    id: row.get(0)?,
752                    name: row.get(1)?,
753                    owner_id: row.get(2)?,
754                    created_at: row.get(3)?,
755                })
756            },
757        )
758        .map_err(|e| {
759            (
760                StatusCode::INTERNAL_SERVER_ERROR,
761                format!("Failed to fetch team: {}", e),
762            )
763        })?;
764
765    Ok(Json(team))
766}
767
768async fn list_members(
769    State(state): State<SharedState>,
770    headers: HeaderMap,
771    AxumPath(team_id): AxumPath<String>,
772) -> Result<Json<Vec<hub::TeamMember>>, (StatusCode, String)> {
773    let user = extract_user(&state, &headers)?;
774    let conn = state.lock().unwrap();
775
776    // Check membership
777    conn.query_row(
778        "SELECT 1 FROM team_members WHERE team_id = ?1 AND user_id = ?2",
779        rusqlite::params![team_id, user.id],
780        |_| Ok(()),
781    )
782    .map_err(|_| {
783        (
784            StatusCode::FORBIDDEN,
785            "You are not a member of this team".to_string(),
786        )
787    })?;
788
789    let mut stmt = conn
790        .prepare(
791            "SELECT tm.user_id, tm.role, tm.joined_at, u.name, u.email
792             FROM team_members tm
793             JOIN users u ON u.id = tm.user_id
794             WHERE tm.team_id = ?1
795             ORDER BY tm.joined_at ASC",
796        )
797        .map_err(|e| {
798            (
799                StatusCode::INTERNAL_SERVER_ERROR,
800                format!("Query error: {}", e),
801            )
802        })?;
803
804    let members: Vec<hub::TeamMember> = stmt
805        .query_map([&team_id], |row| {
806            Ok(hub::TeamMember {
807                user_id: row.get(0)?,
808                role: row.get(1)?,
809                joined_at: row.get(2)?,
810                name: row.get(3)?,
811                email: row.get(4)?,
812            })
813        })
814        .map_err(|e| {
815            (
816                StatusCode::INTERNAL_SERVER_ERROR,
817                format!("Query error: {}", e),
818            )
819        })?
820        .filter_map(|r| r.ok())
821        .collect();
822
823    Ok(Json(members))
824}
825
826async fn kick_member(
827    State(state): State<SharedState>,
828    headers: HeaderMap,
829    AxumPath(team_id): AxumPath<String>,
830    Json(req): Json<KickRequest>,
831) -> Result<StatusCode, (StatusCode, String)> {
832    let user = extract_user(&state, &headers)?;
833    let conn = state.lock().unwrap();
834
835    // Check caller is owner or admin
836    let role: String = conn
837        .query_row(
838            "SELECT role FROM team_members WHERE team_id = ?1 AND user_id = ?2",
839            rusqlite::params![team_id, user.id],
840            |row| row.get(0),
841        )
842        .map_err(|_| {
843            (
844                StatusCode::FORBIDDEN,
845                "You are not a member of this team".to_string(),
846            )
847        })?;
848
849    if role != "owner" && role != "admin" {
850        return Err((
851            StatusCode::FORBIDDEN,
852            "Only owners and admins can kick members".to_string(),
853        ));
854    }
855
856    // Cannot kick yourself
857    if req.user_id == user.id {
858        return Err((
859            StatusCode::BAD_REQUEST,
860            "Cannot kick yourself".to_string(),
861        ));
862    }
863
864    // Cannot kick the owner
865    let target_role: String = conn
866        .query_row(
867            "SELECT role FROM team_members WHERE team_id = ?1 AND user_id = ?2",
868            rusqlite::params![team_id, req.user_id],
869            |row| row.get(0),
870        )
871        .map_err(|_| {
872            (
873                StatusCode::NOT_FOUND,
874                "User is not a member of this team".to_string(),
875            )
876        })?;
877
878    if target_role == "owner" {
879        return Err((
880            StatusCode::FORBIDDEN,
881            "Cannot kick the team owner".to_string(),
882        ));
883    }
884
885    conn.execute(
886        "DELETE FROM team_members WHERE team_id = ?1 AND user_id = ?2",
887        rusqlite::params![team_id, req.user_id],
888    )
889    .map_err(|e| {
890        (
891            StatusCode::INTERNAL_SERVER_ERROR,
892            format!("Failed to kick member: {}", e),
893        )
894    })?;
895
896    Ok(StatusCode::OK)
897}
898
899// ── Channel handlers ──
900
901async fn create_channel(
902    State(state): State<SharedState>,
903    headers: HeaderMap,
904    AxumPath(team_id): AxumPath<String>,
905    Json(req): Json<CreateChannelRequest>,
906) -> Result<(StatusCode, Json<hub::Channel>), (StatusCode, String)> {
907    let user = extract_user(&state, &headers)?;
908    let conn = state.lock().unwrap();
909
910    // Check membership
911    conn.query_row(
912        "SELECT 1 FROM team_members WHERE team_id = ?1 AND user_id = ?2",
913        rusqlite::params![team_id, user.id],
914        |_| Ok(()),
915    )
916    .map_err(|_| {
917        (
918            StatusCode::FORBIDDEN,
919            "You are not a member of this team".to_string(),
920        )
921    })?;
922
923    let now = crate::Relay::now();
924    let channel_id = uuid::Uuid::new_v4().to_string();
925
926    conn.execute(
927        "INSERT INTO channels (id, team_id, name, created_at) VALUES (?1, ?2, ?3, ?4)",
928        rusqlite::params![channel_id, team_id, req.name, now],
929    )
930    .map_err(|e| {
931        if e.to_string().contains("UNIQUE") {
932            (
933                StatusCode::CONFLICT,
934                format!("Channel '{}' already exists in this team", req.name),
935            )
936        } else {
937            (
938                StatusCode::INTERNAL_SERVER_ERROR,
939                format!("Database error: {}", e),
940            )
941        }
942    })?;
943
944    let channel = hub::Channel {
945        id: channel_id,
946        name: req.name,
947        team_id,
948    };
949
950    Ok((StatusCode::CREATED, Json(channel)))
951}
952
953async fn list_channels(
954    State(state): State<SharedState>,
955    headers: HeaderMap,
956    AxumPath(team_id): AxumPath<String>,
957) -> Result<Json<Vec<hub::Channel>>, (StatusCode, String)> {
958    let user = extract_user(&state, &headers)?;
959    let conn = state.lock().unwrap();
960
961    // Check membership
962    conn.query_row(
963        "SELECT 1 FROM team_members WHERE team_id = ?1 AND user_id = ?2",
964        rusqlite::params![team_id, user.id],
965        |_| Ok(()),
966    )
967    .map_err(|_| {
968        (
969            StatusCode::FORBIDDEN,
970            "You are not a member of this team".to_string(),
971        )
972    })?;
973
974    let mut stmt = conn
975        .prepare(
976            "SELECT id, name, team_id FROM channels WHERE team_id = ?1 ORDER BY created_at ASC",
977        )
978        .map_err(|e| {
979            (
980                StatusCode::INTERNAL_SERVER_ERROR,
981                format!("Query error: {}", e),
982            )
983        })?;
984
985    let channels: Vec<hub::Channel> = stmt
986        .query_map([&team_id], |row| {
987            Ok(hub::Channel {
988                id: row.get(0)?,
989                name: row.get(1)?,
990                team_id: row.get(2)?,
991            })
992        })
993        .map_err(|e| {
994            (
995                StatusCode::INTERNAL_SERVER_ERROR,
996                format!("Query error: {}", e),
997            )
998        })?
999        .filter_map(|r| r.ok())
1000        .collect();
1001
1002    Ok(Json(channels))
1003}