agent-relay 0.4.0

Agent-to-agent messaging for AI coding tools. Local or networked — run a relay server and let Claude talk to Gemini across the internet.
Documentation
//! Hub: auth, teams, channels, and invite logic for agent-relay.

use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

// ── Data Types ──

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct User {
    pub id: String,
    pub email: String,
    pub name: String,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Team {
    pub id: String,
    pub name: String,
    pub owner_id: String,
    pub created_at: u64,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct TeamMember {
    pub user_id: String,
    pub role: String,
    pub joined_at: u64,
    pub name: String,
    pub email: String,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Invite {
    pub token: String,
    pub team_id: String,
    pub team_name: String,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Channel {
    pub id: String,
    pub name: String,
    pub team_id: String,
}

// ── Schema ──

/// Create all hub tables and migrate the messages table.
pub fn init_hub_tables(conn: &Connection) {
    conn.execute_batch(
        "CREATE TABLE IF NOT EXISTS users (
            id TEXT PRIMARY KEY,
            email TEXT UNIQUE NOT NULL,
            name TEXT NOT NULL,
            created_at INTEGER NOT NULL
        );

        CREATE TABLE IF NOT EXISTS api_keys (
            key_hash TEXT PRIMARY KEY,
            user_id TEXT NOT NULL REFERENCES users(id),
            label TEXT NOT NULL DEFAULT 'default',
            created_at INTEGER NOT NULL
        );

        CREATE TABLE IF NOT EXISTS teams (
            id TEXT PRIMARY KEY,
            name TEXT UNIQUE NOT NULL,
            owner_id TEXT NOT NULL REFERENCES users(id),
            created_at INTEGER NOT NULL
        );

        CREATE TABLE IF NOT EXISTS team_members (
            team_id TEXT NOT NULL REFERENCES teams(id),
            user_id TEXT NOT NULL REFERENCES users(id),
            role TEXT NOT NULL DEFAULT 'member',
            joined_at INTEGER NOT NULL,
            PRIMARY KEY (team_id, user_id)
        );

        CREATE TABLE IF NOT EXISTS invites (
            token TEXT PRIMARY KEY,
            team_id TEXT NOT NULL REFERENCES teams(id),
            inviter_id TEXT NOT NULL REFERENCES users(id),
            email TEXT,
            created_at INTEGER NOT NULL,
            used_at INTEGER
        );

        CREATE TABLE IF NOT EXISTS channels (
            id TEXT PRIMARY KEY,
            team_id TEXT NOT NULL REFERENCES teams(id),
            name TEXT NOT NULL,
            created_at INTEGER NOT NULL,
            UNIQUE(team_id, name)
        );",
    )
    .expect("Failed to create hub tables");

    // Migrate messages table: add team_id and channel columns if missing.
    // SQLite ALTER TABLE ADD COLUMN fails if column exists, so we catch the error.
    let _ = conn.execute_batch("ALTER TABLE messages ADD COLUMN team_id TEXT;");
    let _ = conn.execute_batch("ALTER TABLE messages ADD COLUMN channel TEXT DEFAULT 'general';");
}

// ── API Key Helpers ──

/// SHA256 hash of an API key for secure storage.
pub fn hash_api_key(key: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(key.as_bytes());
    hex::encode(hasher.finalize())
}

/// Generate a new API key with the `ar_` prefix.
pub fn generate_api_key() -> String {
    format!("ar_{}", uuid::Uuid::new_v4().to_string().replace('-', ""))
}

/// Look up an API key, return the associated user if valid.
pub fn verify_api_key(conn: &Connection, key: &str) -> Option<User> {
    let key_hash = hash_api_key(key);
    conn.query_row(
        "SELECT u.id, u.email, u.name
         FROM api_keys ak
         JOIN users u ON u.id = ak.user_id
         WHERE ak.key_hash = ?1",
        [&key_hash],
        |row| {
            Ok(User {
                id: row.get(0)?,
                email: row.get(1)?,
                name: row.get(2)?,
            })
        },
    )
    .ok()
}