Scaffold tier 2 and 3, the schema, the website, and self-hosted-first
Store: the full Postgres schema as an embedded migration. UUIDv7 keys so `ORDER BY id` is a free chronological index; raw MIME and attachments live in object storage with only a key in the row; `pods` present from day one because retrofitting tenancy costs more than an unused column. API keys are stored as a SHA-256 hash — a database dump must not be a set of live credentials. API: the v0 route table, including `ingest`, which closes the receive→thread→extract loop with zero mail infrastructure and is what makes the agent layer testable in CI. Scopes are a closed enum rather than strings, so "can send mail" and "can mint keys" are not one typo apart. Internal errors are logged in full and reported as a bare string. MCP: the tool catalogue, six tools. Adding a row here is the only way an agent gains a capability — a new REST route is invisible until someone opts it in. Three tests guard the rule that no tool can ever reach key management; CI fails rather than production. ADR 0006: enterprise self-hosted first. A hosted offering comes only after we have run this ourselves long enough to have a deliverability record worth selling. `pods` stays in the schema as the thing that keeps that path open — do not remove it as dead code. Also: multi-stage Dockerfile running as a non-root system user with no shell in the runtime image, and the openmail.karti.ai static page. 17 tests, zero clippy warnings, fmt clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkyvfNJGTshJNE9FtwPLk7
This commit is contained in:
co-authored by
Claude Opus 5
parent
36b15ddcaf
commit
a42b798a0e
@@ -1,23 +1,32 @@
|
||||
//! MCP server — the thing nobody else has.
|
||||
//! MCP server — the surface nobody else has.
|
||||
//!
|
||||
//! A thin front-end over [`openmail_core`] that lets an agent own and operate
|
||||
//! its own mailbox as tools: `create_inbox`, `list_messages`, `get_thread`,
|
||||
//! `send_message`, `reply`, `search`.
|
||||
//! its own mailbox as tools.
|
||||
//!
|
||||
//! # The rule that keeps this safe
|
||||
//!
|
||||
//! Only routes that explicitly opt in become tools, every call re-checks the
|
||||
//! caller's scopes, and credential or key-management routes can **never** be
|
||||
//! exposed as tools regardless of opt-in. An agent may read and send its own
|
||||
//! mail; it may not mint itself a wider key.
|
||||
//! Only routes that explicitly opt in become tools; every call re-checks the
|
||||
//! caller's scopes; and credential or key-management routes can **never** be
|
||||
//! exposed regardless of opt-in. An agent may read and send its own mail. It
|
||||
//! may not mint itself a wider key — that is the one escape that would make
|
||||
//! every other limit decorative.
|
||||
//!
|
||||
//! The refusal is encoded in [`Exposure`] and enforced by
|
||||
//! [`Tool::is_representable`], which is a compile-time-checkable property
|
||||
//! rather than a config flag someone can flip.
|
||||
|
||||
/// Marker for a route's MCP exposure. Absence of an opt-in is a refusal, not a
|
||||
/// default — a new route is invisible to agents until someone says otherwise.
|
||||
pub mod tools;
|
||||
|
||||
pub use tools::{TOOLS, Tool};
|
||||
|
||||
/// How a capability may be reached from an agent.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Exposure {
|
||||
/// Callable as an MCP tool, subject to the caller's scopes.
|
||||
Tool,
|
||||
/// Reachable over REST but not offered to agents.
|
||||
Hidden,
|
||||
/// Credential-bearing. Never exposable; the type makes it unrepresentable.
|
||||
/// Credential-bearing. Never exposable, under any configuration.
|
||||
NeverExposable,
|
||||
}
|
||||
|
||||
@@ -25,6 +34,8 @@ pub enum Exposure {
|
||||
pub enum Error {
|
||||
#[error("tool not found: {0}")]
|
||||
UnknownTool(String),
|
||||
#[error("scope denied: {0}")]
|
||||
ScopeDenied(String),
|
||||
#[error("scope denied for {0}")]
|
||||
ScopeDenied(&'static str),
|
||||
#[error("invalid arguments: {0}")]
|
||||
InvalidArgs(String),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! The tool catalogue.
|
||||
//!
|
||||
//! Adding a capability here is the *only* way an agent gains one. A new REST
|
||||
//! route is invisible to agents until someone adds a row — absence of an opt-in
|
||||
//! is a refusal, not a default.
|
||||
|
||||
use crate::Exposure;
|
||||
|
||||
/// One MCP tool.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Tool {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub exposure: Exposure,
|
||||
/// The scope a caller must hold. Kept as a string here rather than
|
||||
/// importing `openmail-api` so the MCP crate does not depend on the HTTP
|
||||
/// layer — the two surfaces are siblings, not a stack.
|
||||
pub required_scope: &'static str,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
/// A tool row is only valid if its exposure permits being a tool at all.
|
||||
/// `NeverExposable` in this table is a bug, and this is the assertion that
|
||||
/// catches it — see the test below, which runs in CI.
|
||||
#[must_use]
|
||||
pub const fn is_representable(&self) -> bool {
|
||||
matches!(self.exposure, Exposure::Tool)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every tool an agent can call. Six, deliberately: an agent needs to create a
|
||||
/// mailbox, read what arrived, and answer it. Everything else is the operator's
|
||||
/// job.
|
||||
pub static TOOLS: &[Tool] = &[
|
||||
Tool {
|
||||
name: "create_inbox",
|
||||
description: "Provision a new mailbox and return its address.",
|
||||
exposure: Exposure::Tool,
|
||||
required_scope: "manage_inboxes",
|
||||
},
|
||||
Tool {
|
||||
name: "list_messages",
|
||||
description: "List messages in an inbox, newest first. Returns previews, not bodies.",
|
||||
exposure: Exposure::Tool,
|
||||
required_scope: "read",
|
||||
},
|
||||
Tool {
|
||||
name: "get_thread",
|
||||
description: "Fetch a conversation with each message's quoted history stripped.",
|
||||
exposure: Exposure::Tool,
|
||||
required_scope: "read",
|
||||
},
|
||||
Tool {
|
||||
name: "send_message",
|
||||
description: "Send a new message from an inbox.",
|
||||
exposure: Exposure::Tool,
|
||||
required_scope: "send",
|
||||
},
|
||||
Tool {
|
||||
name: "reply",
|
||||
description: "Reply in-thread, setting In-Reply-To and References correctly.",
|
||||
exposure: Exposure::Tool,
|
||||
required_scope: "send",
|
||||
},
|
||||
Tool {
|
||||
name: "search",
|
||||
description: "Full-text search across an inbox.",
|
||||
exposure: Exposure::Tool,
|
||||
required_scope: "read",
|
||||
},
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{TOOLS, Tool};
|
||||
use crate::Exposure;
|
||||
|
||||
#[test]
|
||||
fn no_tool_is_credential_bearing() {
|
||||
// The guard for the rule in the crate docs. If someone adds a
|
||||
// key-management tool, CI fails here rather than in production.
|
||||
for t in TOOLS {
|
||||
assert!(
|
||||
t.is_representable(),
|
||||
"{} must not be exposed as a tool",
|
||||
t.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_tool_grants_key_management() {
|
||||
for t in TOOLS {
|
||||
assert_ne!(
|
||||
t.required_scope, "manage_keys",
|
||||
"{} would let an agent mint credentials",
|
||||
t.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_names_are_unique() {
|
||||
let mut names: Vec<&str> = TOOLS.iter().map(|t| t.name).collect();
|
||||
names.sort_unstable();
|
||||
let before = names.len();
|
||||
names.dedup();
|
||||
assert_eq!(before, names.len(), "duplicate tool name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_exposable_is_not_representable() {
|
||||
let t = Tool {
|
||||
name: "create_api_key",
|
||||
description: "",
|
||||
exposure: Exposure::NeverExposable,
|
||||
required_scope: "manage_keys",
|
||||
};
|
||||
assert!(!t.is_representable());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user