Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,10 @@ FROID_EXTRACTION_WORKER_INTERVAL_SECONDS=300
FROID_MCP_ENABLED=false
FROID_MCP_BIND=127.0.0.1:8080

# Require bearer tokens on the HTTP listener (MCP and dashboard). Users mint
# their own token by sending /token to the Telegram bot (rotate with /token,
# The HTTP listener (MCP and dashboard) always requires bearer tokens. Users
# mint their own by sending /token to the Telegram bot (rotate with /token,
# disable with /token revoke); each request is served from the token owner's
# isolated database. When unset, the endpoints are unauthenticated; restrict
# access at the network level instead.
FROID_AUTH_ENABLED=false
# isolated database.

# Required when FROID_EMBEDDING_WORKER_ENABLED=true or FROID_EXTRACTION_WORKER_ENABLED=true
OPENAI_API_KEY=
Expand Down
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ docker run --env-file .env -v ./data:/app/data ghcr.io/asiniscalchi/froid:latest

## Exposing tools over MCP

Set `FROID_MCP_ENABLED=true` to expose the analyzer's read-only tools over the MCP Streamable HTTP transport at `http://127.0.0.1:8080/mcp`. The MCP server runs alongside the Telegram bot in the same process. Set `FROID_AUTH_ENABLED=true` to require an `Authorization: Bearer <token>` header on the HTTP listener, with tokens minted via the Telegram `/token` command (see [Authentication](#authentication)); when it is unset, the endpoints are unauthenticated, so restrict access at the network level — use the default loopback bind for local use, or a Docker internal network when running in Compose.
Set `FROID_MCP_ENABLED=true` to expose the analyzer's read-only tools over the MCP Streamable HTTP transport at `http://127.0.0.1:8080/mcp`. The MCP server runs alongside the Telegram bot in the same process. Every request must carry an `Authorization: Bearer <token>` header with a token minted via the Telegram `/token` command (see [Authentication](#authentication)).

```bash
FROID_MCP_ENABLED=true cargo run -- serve
Expand All @@ -49,15 +49,15 @@ Available tools: `journal_get`, `journal_get_recent`, `journal_search_text`, `jo

## Dashboard webapp

Set `FROID_DASHBOARD_ENABLED=true` to serve a small React webapp at `http://127.0.0.1:8080/`. Besides message export/import and prompt editing, the dashboard API exposes journal data directly: `GET /api/entries` (recent entries), `POST /api/messages` (capture a new entry from the browser; it flows through the same extraction/embedding/review pipeline as Telegram messages), and `GET /api/reviews/daily` / `GET /api/reviews/weekly` (completed reviews, optional `from`/`to` date filters). The dashboard shares the HTTP listener with the MCP endpoint (`FROID_MCP_BIND`, default `127.0.0.1:8080`) and can be enabled independently of MCP. Assets are embedded into the release binary, so the Docker image carries everything it needs. The dashboard is protected by the same bearer check as MCP (see [Authentication](#authentication)); when authentication is disabled, restrict access at the network level.
Set `FROID_DASHBOARD_ENABLED=true` to serve a small React webapp at `http://127.0.0.1:8080/`. Besides message export/import and prompt editing, the dashboard API exposes journal data directly: `GET /api/entries` (recent entries), `POST /api/messages` (capture a new entry from the browser; it flows through the same extraction/embedding/review pipeline as Telegram messages), and `GET /api/reviews/daily` / `GET /api/reviews/weekly` (completed reviews, optional `from`/`to` date filters). The dashboard shares the HTTP listener with the MCP endpoint (`FROID_MCP_BIND`, default `127.0.0.1:8080`) and can be enabled independently of MCP. Assets are embedded into the release binary, so the Docker image carries everything it needs. The dashboard is protected by the same bearer check as MCP (see [Authentication](#authentication)); on first visit it asks for your token.

```bash
FROID_DASHBOARD_ENABLED=true cargo run -- serve
```

## Authentication

Set `FROID_AUTH_ENABLED=true` and every request to `/mcp` and `/api` must carry a bearer token minted through the bot:
Authentication is always on: every request to `/mcp` and `/api` must carry a bearer token minted through the bot:

```
Authorization: Bearer <your-token>
Expand All @@ -67,9 +67,9 @@ Users mint their own tokens by sending `/token` to the Telegram bot. Telegram is

Requests without a valid token receive `401 Unauthorized`. The static dashboard shell (HTML/JS assets, which contain no journal data) and the `/health` probe are intentionally served without authentication; all data flows through the protected `/api` and `/mcp` routes.

When `FROID_AUTH_ENABLED` is unset, the endpoints are unauthenticated and Froid logs a warning at startup — in that case restrict access at the network level (loopback bind or a Docker internal network). Unauthenticated requests are served from a single database: in multiuser mode, the isolated database of the first whitelisted Telegram user; otherwise the default database.
Because Telegram access is what mints HTTP credentials, `TELEGRAM_ALLOWED_USER_IDS` is the gate that decides who can become a user — set it on any instance whose bot a stranger could message.

The static-token variables from earlier versions (`FROID_AUTH_TOKEN`, `FROID_AUTH_TOKENS`, `FROID_AUTH_DYNAMIC_TOKENS`) have been removed; Froid refuses to start if one is still set, to avoid silently running unauthenticated.
The auth variables from earlier versions (`FROID_AUTH_TOKEN`, `FROID_AUTH_TOKENS`, `FROID_AUTH_DYNAMIC_TOKENS`, `FROID_AUTH_ENABLED`) have been removed; Froid refuses to start if one is still set, so the behavior change cannot go unnoticed.

## Health endpoint

Expand Down Expand Up @@ -146,7 +146,6 @@ All workers are disabled by default and require `OPENAI_API_KEY`.
|---|---|---|
| `FROID_MCP_ENABLED` | `false` | Enable the MCP Streamable HTTP server |
| `FROID_MCP_BIND` | `127.0.0.1:8080` | Bind address (e.g. `0.0.0.0:8080` for Docker Compose) |
| `FROID_AUTH_ENABLED` | `false` | Require bearer tokens (minted via the Telegram `/token` command) on the HTTP listener; each request is served from the token owner's isolated database. Unset means no authentication |

### Models

Expand Down
17 changes: 9 additions & 8 deletions src/adapters/telegram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ impl<H: MessageHandler> TelegramAdapter<H> {
}
}

/// Enable the `/token` command, backed by the central token store.
pub fn with_token_issuer(mut self, token_issuer: Option<TokenIssuer>) -> Self {
self.token_issuer = token_issuer;
/// Attach the issuer backing the `/token` command (the central token store).
pub fn with_token_issuer(mut self, token_issuer: TokenIssuer) -> Self {
self.token_issuer = Some(token_issuer);
self
}

Expand Down Expand Up @@ -211,9 +211,10 @@ async fn handle_token_command(
chat_id: &str,
) -> String {
let Some(issuer) = issuer else {
return "Access tokens are not enabled on this server. Ask the operator to set \
FROID_AUTH_ENABLED=true."
.to_string();
// Defensive: serve() always attaches an issuer; this only triggers if
// the adapter was built without one.
error!(chat_id, "received /token but no token issuer is attached");
return "Access tokens are not available right now. Please try again later.".to_string();
};

match action {
Expand Down Expand Up @@ -628,10 +629,10 @@ mod tests {
}

#[tokio::test]
async fn replies_with_hint_when_dynamic_tokens_disabled() {
async fn replies_gracefully_when_no_issuer_is_attached() {
let reply = handle_token_command(None, TokenAction::Issue, "42").await;

assert!(reply.contains("not enabled"));
assert!(reply.contains("not available"));
}

#[tokio::test]
Expand Down
66 changes: 11 additions & 55 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,10 @@ pub async fn serve(config: ServeConfig) -> Result<(), Box<dyn Error>> {

// Central store for tokens issued via the Telegram /token command. It
// lives in the default database so one lookup resolves any token.
let issued_tokens = if config.http_auth.enabled {
let issued_tokens = {
let pool = database::connect_pool(&config.database_url).await?;
sqlx::migrate!().run(&pool).await?;
Some(crate::tokens::UserTokenStore::new(pool))
} else {
None
crate::tokens::UserTokenStore::new(pool)
};

// Spawn the global HTTP server (MCP and Dashboard)
Expand Down Expand Up @@ -134,7 +132,7 @@ pub async fn serve(config: ServeConfig) -> Result<(), Box<dyn Error>> {
config.telegram_allowed_user_ids.clone(),
journal_registry,
)
.with_token_issuer(issued_tokens.map(crate::tokens::TokenIssuer::new));
.with_token_issuer(crate::tokens::TokenIssuer::new(issued_tokens));
supervise(workers, shutdown, shutdown_signal(), adapter.run()).await
}

Expand All @@ -144,7 +142,7 @@ async fn spawn_http_server(
config: &ServeConfig,
embedding_config: Option<&EmbeddingConfig>,
registry: &crate::journal::registry::JournalServiceRegistry,
issued_tokens: Option<crate::tokens::UserTokenStore>,
issued_tokens: crate::tokens::UserTokenStore,
) -> Result<(), Box<dyn Error>> {
if !config.mcp_server.enabled && !config.dashboard.enabled {
return Ok(());
Expand All @@ -164,62 +162,20 @@ async fn spawn_http_server(
shutdown: shutdown.clone(),
};

let (router, auth_mode) = if let Some(issued_tokens) = issued_tokens {
// Auth enabled: every /mcp and /api request must carry a bearer token
// minted via the Telegram /token command, and is served from the
// database of the user who minted it.
let resolver = Arc::new(crate::auth::TokenResolver::new(issued_tokens));
let tenants = http::TenantRouters::new(registry.clone(), router_config);
(
http::build_per_user_app(tenants, resolver, config.dashboard.enabled),
"per-user",
)
} else {
// Auth disabled: unauthenticated single tenant. In multiuser mode
// with a whitelist, the administrative user is the first ID in the
// whitelist and we serve their isolated database. Otherwise, the
// default/legacy database.
warn!(
"HTTP server (MCP & Dashboard) is running without authentication; set FROID_AUTH_ENABLED=true to require bearer tokens minted via the Telegram /token command"
);
let (pool, capture_conversation_id) = if let Some(first_id) = config
.telegram_allowed_user_ids
.as_ref()
.and_then(|ids| ids.first())
{
info!(
chat_id = %first_id,
"HTTP server (MCP & Dashboard) running in multiuser mode; binding to first whitelisted user's database"
);
let chat_id = first_id.to_string();
let pool = registry
.pool(&chat_id)
.await
.map_err(|e| -> Box<dyn Error> { e })?;
(pool, chat_id)
} else {
let pool = database::connect_pool(&config.database_url).await?;
sqlx::migrate!().run(&pool).await?;
(pool, crate::messages::SINGLE_USER_ID.to_string())
};

let tenant_router =
http::build_tenant_router(&pool, &capture_conversation_id, &router_config)
.map_err(|e| -> Box<dyn Error> { e })?;
(
http::build_single_tenant_app(tenant_router, config.dashboard.enabled),
"none",
)
};
// Every /mcp and /api request must carry a bearer token minted via the
// Telegram /token command, and is served from the database of the user
// who minted it.
let resolver = Arc::new(crate::auth::TokenResolver::new(issued_tokens));
let tenants = http::TenantRouters::new(registry.clone(), router_config);
let router = http::build_per_user_app(tenants, resolver, config.dashboard.enabled);

let listener = tokio::net::TcpListener::bind(config.mcp_server.bind).await?;
let local_addr = listener.local_addr()?;
info!(
addr = %local_addr,
mcp = config.mcp_server.enabled,
dashboard = config.dashboard.enabled,
auth = auth_mode,
"HTTP server listening"
"HTTP server listening; bearer tokens are minted via the Telegram /token command"
);

let token = shutdown.clone();
Expand Down
10 changes: 4 additions & 6 deletions src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
//! Bearer-token authentication for the shared HTTP listener (MCP and dashboard).
//!
//! Tokens are minted by users themselves through the Telegram `/token`
//! command and stored hashed in the central database. A matching request is
//! tagged with an [`AuthenticatedTenant`] extension so the router can serve
//! it from that user's isolated database. When authentication is disabled
//! (`FROID_AUTH_ENABLED` unset), no middleware is installed and access must
//! be restricted at the network level.
//! Authentication is always on. Tokens are minted by users themselves
//! through the Telegram `/token` command and stored hashed in the central
//! database. A matching request is tagged with an [`AuthenticatedTenant`]
//! extension so the router can serve it from that user's isolated database.

use std::sync::Arc;

Expand Down
54 changes: 7 additions & 47 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,6 @@ pub struct Cli {

#[arg(long, env = "FROID_DASHBOARD_ENABLED", global = true)]
dashboard_enabled: Option<bool>,

#[arg(long, env = "FROID_AUTH_ENABLED", global = true)]
auth_enabled: Option<bool>,
}

#[derive(Debug, Subcommand)]
Expand Down Expand Up @@ -211,15 +208,6 @@ pub struct DashboardConfig {
pub enabled: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpAuthConfig {
/// Require bearer tokens (minted via the Telegram `/token` command) on
/// the HTTP listener, routing each request to the owning user's database.
/// When `false`, the endpoints are unauthenticated and access must be
/// restricted at the network level.
pub enabled: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServeConfig {
pub telegram_bot_token: String,
Expand All @@ -234,16 +222,16 @@ pub struct ServeConfig {
pub signal_worker: ReconciliationWorkerConfig,
pub mcp_server: McpServerConfig,
pub dashboard: DashboardConfig,
pub http_auth: HttpAuthConfig,
}

/// Environment variables from the removed static-token auth strategies.
/// Their presence is a hard error rather than a silent ignore: an operator
/// upgrading with one still set would otherwise run unauthenticated.
/// Environment variables from removed auth strategies. Their presence is a
/// hard error rather than a silent ignore, so an operator upgrading with one
/// still set notices that the behavior changed.
const REMOVED_AUTH_VARS: &[&str] = &[
"FROID_AUTH_TOKEN",
"FROID_AUTH_TOKENS",
"FROID_AUTH_DYNAMIC_TOKENS",
"FROID_AUTH_ENABLED",
];

fn reject_removed_auth_vars(lookup: impl Fn(&str) -> Option<String>) -> Result<(), clap::Error> {
Expand All @@ -252,9 +240,9 @@ fn reject_removed_auth_vars(lookup: impl Fn(&str) -> Option<String>) -> Result<(
return Err(clap::Error::raw(
clap::error::ErrorKind::ValueValidation,
format!(
"{name} is no longer supported: static bearer tokens were replaced by \
self-serve tokens. Set FROID_AUTH_ENABLED=true and have each user mint a \
token with the Telegram /token command."
"{name} is no longer supported: authentication is always enabled and every \
user mints their own bearer token with the Telegram /token command. Remove \
the variable."
),
));
}
Expand Down Expand Up @@ -356,10 +344,6 @@ impl Cli {

reject_removed_auth_vars(|name| std::env::var(name).ok())?;

let http_auth = HttpAuthConfig {
enabled: self.auth_enabled.unwrap_or(false),
};

Ok(ServeConfig {
telegram_bot_token: telegram_bot_token.clone(),
telegram_allowed_user_ids: self.telegram_allowed_user_ids.clone(),
Expand All @@ -373,7 +357,6 @@ impl Cli {
signal_worker,
mcp_server,
dashboard,
http_auth,
})
}
}
Expand Down Expand Up @@ -412,7 +395,6 @@ mod tests {
mcp_enabled: None,
mcp_bind: "127.0.0.1:8080".parse().unwrap(),
dashboard_enabled: None,
auth_enabled: None,
}
}

Expand Down Expand Up @@ -823,28 +805,6 @@ mod tests {
assert!(!config.dashboard.enabled);
}

#[test]
fn serve_config_auth_disabled_by_default() {
let config = cli_with_token("token").serve_config().unwrap();

assert!(!config.http_auth.enabled);
}

#[test]
fn serve_config_enables_auth_when_flag_set() {
let cli = Cli::parse_from([
"froid",
"--telegram-bot-token",
"token",
"--auth-enabled",
"true",
]);

let config = cli.serve_config().unwrap();

assert!(config.http_auth.enabled);
}

#[test]
fn rejects_removed_static_token_variables() {
for name in super::REMOVED_AUTH_VARS {
Expand Down
29 changes: 6 additions & 23 deletions src/http.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
//! Assembly of the shared HTTP listener (MCP endpoint + dashboard).
//!
//! Two layouts exist, selected by the auth configuration:
//!
//! - **Single tenant** (`FROID_AUTH_TOKEN` or no auth): every request is
//! served from one fixed database, optionally behind one bearer token.
//! - **Per user** (`FROID_AUTH_TOKENS`): the bearer token identifies a user
//! (Telegram chat id) and `/mcp` + `/api` requests are forwarded to a
//! lazily built router bound to that user's isolated database.
//!
//! In both layouts the `/health` probe and the static SPA shell are served
//! without authentication; only the data-bearing routes sit behind tokens.
//! Authentication is always on: the bearer token (minted via the Telegram
//! `/token` command) identifies a user, and `/mcp` + `/api` requests are
//! forwarded to a lazily built router bound to that user's isolated
//! database. The `/health` probe and the static SPA shell are served without
//! authentication; only the data-bearing routes sit behind tokens.

use std::{collections::HashMap, error::Error, sync::Arc};

Expand Down Expand Up @@ -54,7 +49,7 @@ pub struct TenantRouterConfig {
/// Build the protected routes (`/mcp`, `/api`) bound to one database.
/// `capture_conversation_id` is the conversation web-captured entries are
/// filed under (the owning user's chat id).
pub fn build_tenant_router(
fn build_tenant_router(
pool: &SqlitePool,
capture_conversation_id: &str,
config: &TenantRouterConfig,
Expand Down Expand Up @@ -187,15 +182,3 @@ pub fn build_per_user_app(
}
router
}

/// Full listener app for the unauthenticated single-tenant layout (no auth
/// configured): everything is served from one database and access is expected
/// to be restricted at the network level.
pub fn build_single_tenant_app(tenant_router: Router, dashboard_enabled: bool) -> Router {
let mut router = tenant_router.merge(crate::health::router());

if dashboard_enabled {
router = router.merge(dashboard::spa_router());
}
router
}
Loading
Loading