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