feat: OAuth 2.1 with PKCE, MCP auth, dead letters, channel management, and UX polish
- Add OAuth 2.1 identity provider with PKCE S256 (ory/fosite) - Add RFC 7591 dynamic client registration for MCP clients - Add RFC 8414 OAuth metadata discovery endpoint - Add branded OAuth login/authorize pages with SynapBus design - Add SYNAPBUS_BASE_URL env var for remote/LAN deployments - Add OAuth bearer token authentication for MCP connections - Add dead letter queue with Web UI management page - Add channel leave, member list, and improved channel management - Add agent auth middleware for MCP-authenticated requests - Add console printer for structured server startup output - Hide human accounts from agent management UI - Fix SSE through middleware (Flush/Unwrap support) - Fix graceful shutdown by closing SSE clients before server stop - Fix localhost/127.0.0.1 redirect URI normalization for OAuth - Remove agent self-registration MCP tools (manage via Web UI only) - Update README with OAuth setup guide and MCP client config example Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
69e926441e
commit
2575ce2626
@@ -64,6 +64,7 @@ make lint # Run linters
|
||||
|----------|-------------|---------|
|
||||
| `SYNAPBUS_PORT` | HTTP server port | `8080` |
|
||||
| `SYNAPBUS_DATA_DIR` | Data directory (SQLite DB, attachments, vector index) | `./data` |
|
||||
| `SYNAPBUS_BASE_URL` | Public base URL for OAuth metadata (required for LAN/remote) | auto-detect from Host header |
|
||||
| `SYNAPBUS_EMBEDDING_PROVIDER` | Embedding provider: `openai`, `gemini`, `ollama` | (none) |
|
||||
| `OPENAI_API_KEY` | OpenAI API key for embeddings | (none) |
|
||||
| `GEMINI_API_KEY` | Google Gemini API key for embeddings | (none) |
|
||||
@@ -88,3 +89,10 @@ make lint # Run linters
|
||||
4. **Multi-tenant with ownership** — every agent has a human owner
|
||||
5. **Observable by default** — all agent actions traced, searchable, auditable
|
||||
6. **Progressive complexity** — basic messaging first, advanced features layered on top
|
||||
|
||||
## Active Technologies
|
||||
- Go 1.23+ + ory/fosite (OAuth 2.1), mark3labs/mcp-go (MCP server), go-chi/chi (HTTP), Svelte 5 + Tailwind (Web UI) (002-mcp-auth-ux-polish)
|
||||
- modernc.org/sqlite (pure Go), TFMV/hnsw (vectors) (002-mcp-auth-ux-polish)
|
||||
|
||||
## Recent Changes
|
||||
- 002-mcp-auth-ux-polish: Added Go 1.23+ + ory/fosite (OAuth 2.1), mark3labs/mcp-go (MCP server), go-chi/chi (HTTP), Svelte 5 + Tailwind (Web UI)
|
||||
|
||||
@@ -37,7 +37,6 @@ Agents interact with SynapBus entirely through MCP tools:
|
||||
| `create_channel` | Create public/private channel |
|
||||
| `join_channel` | Join a public channel |
|
||||
| `list_channels` | List available channels |
|
||||
| `register_agent` | Self-register with capabilities |
|
||||
| `discover_agents` | Find agents by capability |
|
||||
| `post_task` | Post a task for auction |
|
||||
| `bid_task` | Bid on an open task |
|
||||
@@ -63,11 +62,55 @@ Agents interact with SynapBus entirely through MCP tools:
|
||||
|----------|-------------|---------|
|
||||
| `SYNAPBUS_PORT` | HTTP server port | `8080` |
|
||||
| `SYNAPBUS_DATA_DIR` | Data directory | `./data` |
|
||||
| `SYNAPBUS_BASE_URL` | Public base URL for OAuth (required for remote/LAN) | auto-detect |
|
||||
| `SYNAPBUS_EMBEDDING_PROVIDER` | `openai` / `gemini` / `ollama` | (none) |
|
||||
| `OPENAI_API_KEY` | OpenAI API key for embeddings | (none) |
|
||||
| `GEMINI_API_KEY` | Google Gemini API key for embeddings | (none) |
|
||||
| `SYNAPBUS_OLLAMA_URL` | Ollama server URL | `http://localhost:11434` |
|
||||
|
||||
## OAuth & MCP Authentication
|
||||
|
||||
SynapBus is its own OAuth 2.1 identity provider. MCP clients (Claude Code, Gemini CLI, etc.) authenticate via the standard OAuth authorization code flow with PKCE.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. MCP client discovers OAuth endpoints via `GET /.well-known/oauth-authorization-server`
|
||||
2. Client registers dynamically via `POST /oauth/register` (RFC 7591)
|
||||
3. User logs in through the SynapBus Web UI, selects an agent identity
|
||||
4. Client receives an access token and uses it for MCP `tools/call` requests
|
||||
|
||||
**Local setup** (default) — no extra config needed:
|
||||
|
||||
```bash
|
||||
./bin/synapbus serve --port 8080 --data ./data
|
||||
# MCP clients connect to http://localhost:8080/mcp
|
||||
```
|
||||
|
||||
**LAN or remote setup** — set `SYNAPBUS_BASE_URL` so OAuth metadata returns correct endpoints:
|
||||
|
||||
```bash
|
||||
# On a LAN server
|
||||
SYNAPBUS_BASE_URL=http://192.168.1.100:8080 ./bin/synapbus serve --data ./data
|
||||
|
||||
# Behind a reverse proxy with TLS
|
||||
SYNAPBUS_BASE_URL=https://synapbus.example.com ./bin/synapbus serve --data ./data
|
||||
```
|
||||
|
||||
**MCP client configuration** (e.g., `~/.claude/mcp_config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"synapbus": {
|
||||
"type": "url",
|
||||
"url": "http://localhost:8080/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For remote servers, replace `localhost:8080` with the server address. OAuth login will open in your browser automatically.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Go 1.23+** — single binary, zero CGO
|
||||
|
||||
+156
-7
@@ -1,10 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -213,6 +217,10 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
agentStore := agents.NewSQLiteAgentStore(db.DB)
|
||||
agentService := agents.NewAgentService(agentStore, tracer)
|
||||
|
||||
// Wire dead letter store into agent service for capture on deregistration
|
||||
deadLetterStore := messaging.NewDeadLetterStore(db.DB)
|
||||
agentService.SetDeadLetterStore(deadLetterStore)
|
||||
|
||||
channelStore := channels.NewSQLiteChannelStore(db.DB)
|
||||
channelService := channels.NewService(channelStore, msgService, tracer)
|
||||
|
||||
@@ -255,7 +263,11 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
authCfg := auth.DefaultConfig()
|
||||
authCfg.Secret = authSecret
|
||||
authCfg.DevMode = true
|
||||
authCfg.IssuerURL = fmt.Sprintf("http://localhost:%d", port)
|
||||
// Use SYNAPBUS_BASE_URL for remote/LAN deployments; otherwise derive from request Host header
|
||||
if baseURL := os.Getenv("SYNAPBUS_BASE_URL"); baseURL != "" {
|
||||
authCfg.IssuerURL = strings.TrimRight(baseURL, "/")
|
||||
}
|
||||
// Leave IssuerURL empty for localhost — metadata handler falls back to r.Host
|
||||
|
||||
userStore := auth.NewSQLiteUserStore(db.DB, authCfg.BcryptCost)
|
||||
sessionStore := auth.NewSQLiteSessionStore(db.DB)
|
||||
@@ -264,6 +276,12 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
oauthProvider := auth.NewOAuthProvider(authCfg, fositeStore)
|
||||
authHandlers := auth.NewHandlers(userStore, sessionStore, clientStore, oauthProvider, authCfg)
|
||||
|
||||
// Wire agent lister into auth handlers for OAuth authorize page
|
||||
authHandlers.SetAgentLister(&agentListerAdapter{agentService: agentService})
|
||||
|
||||
// Register default MCP OAuth client if it doesn't already exist (T016)
|
||||
ensureDefaultMCPClient(ctx, db.DB, authCfg.BcryptCost)
|
||||
|
||||
// Create initial admin user if no users exist
|
||||
userCount, err := userStore.CountUsers(ctx)
|
||||
if err != nil {
|
||||
@@ -406,13 +424,18 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Auth endpoints (public)
|
||||
r.Post("/auth/register", authHandlers.HandleRegister)
|
||||
r.Post("/auth/login", authHandlers.HandleLogin)
|
||||
r.Post("/auth/register", withHumanAgent(authHandlers.HandleRegister, userStore, agentService, channelService))
|
||||
r.Post("/auth/login", withHumanAgent(authHandlers.HandleLogin, userStore, agentService, channelService))
|
||||
|
||||
// OAuth metadata (public, per RFC 8414)
|
||||
r.Get("/.well-known/oauth-authorization-server", authHandlers.HandleOAuthMetadata)
|
||||
|
||||
// OAuth endpoints
|
||||
r.Get("/oauth/authorize", authHandlers.HandleAuthorize)
|
||||
r.Get("/oauth/authorize", authHandlers.HandleAuthorizeGet)
|
||||
r.Post("/oauth/authorize", authHandlers.HandleAuthorizePost)
|
||||
r.Post("/oauth/token", authHandlers.HandleToken)
|
||||
r.Post("/oauth/introspect", authHandlers.HandleIntrospect)
|
||||
r.Post("/oauth/register", authHandlers.HandleDynamicRegistration)
|
||||
|
||||
// Protected auth endpoints
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -422,9 +445,9 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
r.Put("/auth/password", authHandlers.HandleChangePassword)
|
||||
})
|
||||
|
||||
// MCP Streamable HTTP endpoint (with optional agent auth + API keys)
|
||||
// MCP Streamable HTTP endpoint (requires agent auth: API key, managed key, or OAuth bearer)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(agents.OptionalAuthMiddlewareWithAPIKeys(agentService, apiKeyService))
|
||||
r.Use(agents.RequiredAuthMiddlewareWithOAuth(agentService, apiKeyService, oauthProvider))
|
||||
r.Mount("/mcp", mcpSrv.Handler())
|
||||
})
|
||||
|
||||
@@ -441,6 +464,7 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
AgentService: agentService,
|
||||
ChannelService: channelService,
|
||||
APIKeyService: apiKeyService,
|
||||
DeadLetterStore: deadLetterStore,
|
||||
SSEHub: sseHub,
|
||||
SessionMiddleware: sessionMiddleware,
|
||||
})
|
||||
@@ -500,7 +524,7 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Shutdown with timeout
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
// Stop admin socket
|
||||
@@ -532,6 +556,9 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
slog.Error("MCP server shutdown error", "error", err)
|
||||
}
|
||||
|
||||
// Close SSE connections so HTTP server can drain
|
||||
sseHub.Close()
|
||||
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("HTTP server shutdown error", "error", err)
|
||||
}
|
||||
@@ -540,9 +567,131 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// withHumanAgent wraps an auth handler to ensure a human-type agent and
|
||||
// my-agents channel exist after successful login/register.
|
||||
func withHumanAgent(next http.HandlerFunc, userStore auth.UserStore, agentSvc *agents.AgentService, channelSvc *channels.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Buffer the request body so we can read the username and still pass it to the handler
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
// Extract username from request
|
||||
var reqBody struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
json.Unmarshal(bodyBytes, &reqBody)
|
||||
|
||||
// Wrap response writer to capture status code
|
||||
rec := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
|
||||
// On successful auth, ensure human agent and my-agents channel exist
|
||||
if rec.statusCode >= 200 && rec.statusCode < 300 && reqBody.Username != "" {
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
user, err := userStore.GetUserByUsername(ctx, reqBody.Username)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
humanAgent, err := agentSvc.EnsureHumanAgent(ctx, user.Username, user.DisplayName, user.ID)
|
||||
if err != nil || humanAgent == nil {
|
||||
return
|
||||
}
|
||||
if err := channelSvc.EnsureMyAgentsChannel(ctx, user.Username, humanAgent.Name); err != nil {
|
||||
slog.Warn("failed to ensure my-agents channel",
|
||||
"username", user.Username,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// statusRecorder wraps http.ResponseWriter to capture the status code.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.statusCode = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// generateRandomPassword creates a cryptographically random password.
|
||||
func generateRandomPassword() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// agentListerAdapter adapts agents.AgentService to auth.AgentLister.
|
||||
type agentListerAdapter struct {
|
||||
agentService *agents.AgentService
|
||||
}
|
||||
|
||||
func (a *agentListerAdapter) ListAgentsByOwner(ctx context.Context, ownerID int64) ([]auth.AgentInfo, error) {
|
||||
agentsList, err := a.agentService.ListAgents(ctx, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]auth.AgentInfo, 0, len(agentsList))
|
||||
for _, agent := range agentsList {
|
||||
if agent.Status != "active" || agent.Type == "human" {
|
||||
continue
|
||||
}
|
||||
result = append(result, auth.AgentInfo{
|
||||
Name: agent.Name,
|
||||
DisplayName: agent.DisplayName,
|
||||
Type: agent.Type,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ensureDefaultMCPClient creates the "mcp-default" public OAuth client if it doesn't exist.
|
||||
// This client is used by MCP clients connecting via OAuth 2.1.
|
||||
func ensureDefaultMCPClient(ctx context.Context, db *sql.DB, bcryptCost int) {
|
||||
const clientID = "mcp-default"
|
||||
const clientName = "MCP Default Client"
|
||||
|
||||
// Check if client already exists
|
||||
var exists int
|
||||
err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM oauth_clients WHERE id = ?", clientID).Scan(&exists)
|
||||
if err != nil {
|
||||
slog.Error("check mcp-default client failed", "error", err)
|
||||
return
|
||||
}
|
||||
if exists > 0 {
|
||||
slog.Debug("mcp-default OAuth client already exists")
|
||||
return
|
||||
}
|
||||
|
||||
// Create public client (empty secret hash = public)
|
||||
redirectURIs, _ := json.Marshal([]string{"http://localhost:*"})
|
||||
grantTypes, _ := json.Marshal([]string{"authorization_code", "refresh_token"})
|
||||
scopes, _ := json.Marshal([]string{"mcp"})
|
||||
|
||||
_, err = db.ExecContext(ctx,
|
||||
`INSERT INTO oauth_clients (id, secret_hash, name, redirect_uris, grant_types, scopes, owner_id, created_at)
|
||||
VALUES (?, '', ?, ?, ?, ?, NULL, CURRENT_TIMESTAMP)`,
|
||||
clientID, clientName, string(redirectURIs), string(grantTypes), string(scopes),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("create mcp-default OAuth client failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("created default MCP OAuth client",
|
||||
"client_id", clientID,
|
||||
"public", true,
|
||||
"redirect_uris", "http://localhost:*",
|
||||
"scopes", "mcp",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,11 +6,22 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/apikeys"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
)
|
||||
|
||||
// OAuthAgentResolver resolves an agent name from an OAuth bearer token.
|
||||
// It returns the agent name stored in the token session, or empty string if none.
|
||||
type OAuthAgentResolver interface {
|
||||
// ResolveAgentFromToken introspects a bearer token and returns the agent name
|
||||
// and owner ID if the token contains agent identity information.
|
||||
ResolveAgentFromToken(ctx context.Context, token string) (agentName string, ownerID string, ok bool)
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
@@ -116,6 +127,195 @@ func OptionalAuthMiddlewareWithAPIKeys(service *AgentService, keyService *apikey
|
||||
}
|
||||
}
|
||||
|
||||
// RequiredAuthMiddlewareWithOAuth creates HTTP middleware that requires authentication
|
||||
// via agent API keys, managed API keys, or OAuth bearer tokens.
|
||||
// If no auth is provided, returns 401 with WWW-Authenticate header pointing to OAuth metadata.
|
||||
// This is the required middleware for the /mcp route.
|
||||
func RequiredAuthMiddlewareWithOAuth(service *AgentService, keyService *apikeys.Service, oauthProvider fosite.OAuth2Provider) func(http.Handler) http.Handler {
|
||||
// If no OAuth provider, fall back to required API-key-only middleware
|
||||
if oauthProvider == nil {
|
||||
return AuthMiddlewareWithAPIKeys(service, keyService)
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="/.well-known/oauth-authorization-server"`)
|
||||
http.Error(w, `{"error":"unauthorized","message":"Authentication required. Use API key or OAuth 2.1 flow."}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="/.well-known/oauth-authorization-server"`)
|
||||
http.Error(w, `{"error":"unauthorized","message":"Invalid Authorization header. Use Bearer token."}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
bearerToken := parts[1]
|
||||
|
||||
// 1. Try existing agent API key auth
|
||||
agent, err := service.Authenticate(r.Context(), bearerToken)
|
||||
if err == nil {
|
||||
slog.Debug("agent authenticated (MCP)",
|
||||
"agent", agent.Name,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
)
|
||||
ctx := ContextWithAgent(r.Context(), agent)
|
||||
ctx = trace.ContextWithOwnerID(ctx, fmt.Sprintf("%d", agent.OwnerID))
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Try managed API key auth (sb_ prefixed keys)
|
||||
if keyService != nil && strings.HasPrefix(bearerToken, "sb_") {
|
||||
apiKey, keyErr := keyService.Authenticate(r.Context(), bearerToken)
|
||||
if keyErr == nil {
|
||||
ctx := r.Context()
|
||||
ctx = ContextWithAPIKey(ctx, apiKey)
|
||||
ctx = trace.ContextWithOwnerID(ctx, fmt.Sprintf("%d", apiKey.UserID))
|
||||
|
||||
if apiKey.AgentID != nil {
|
||||
agentByID, agentErr := service.GetAgentByID(r.Context(), *apiKey.AgentID)
|
||||
if agentErr == nil {
|
||||
ctx = ContextWithAgent(ctx, agentByID)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("API key authenticated (MCP)",
|
||||
"key_id", apiKey.ID,
|
||||
"key_name", apiKey.Name,
|
||||
"agent_id", apiKey.AgentID,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try OAuth bearer token (fallback for MCP OAuth 2.1 flow)
|
||||
if oauthProvider != nil {
|
||||
agentName, ownerID, ok := resolveOAuthToken(r.Context(), oauthProvider, bearerToken, service)
|
||||
if ok {
|
||||
slog.Debug("OAuth bearer token authenticated (MCP)",
|
||||
"agent", agentName,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
)
|
||||
ctx := r.Context()
|
||||
if ownerID != "" {
|
||||
ctx = trace.ContextWithOwnerID(ctx, ownerID)
|
||||
}
|
||||
// Look up the agent by name and inject into context
|
||||
if agentName != "" {
|
||||
agentObj, agentErr := service.GetAgent(ctx, agentName)
|
||||
if agentErr == nil {
|
||||
ctx = ContextWithAgent(ctx, agentObj)
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
slog.Warn("MCP authentication failed",
|
||||
"remote_addr", r.RemoteAddr,
|
||||
"error", err,
|
||||
)
|
||||
http.Error(w, `{"error":"unauthorized","message":"Invalid API key or token"}`, http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// resolveOAuthToken introspects an OAuth bearer token and extracts agent identity.
|
||||
func resolveOAuthToken(ctx context.Context, provider fosite.OAuth2Provider, token string, service *AgentService) (agentName string, ownerID string, ok bool) {
|
||||
// Use a fositeSession-compatible struct for introspection.
|
||||
// We import the type indirectly through the fosite interface.
|
||||
_, ar, err := provider.IntrospectToken(ctx, token, fosite.AccessToken, &oauthIntrospectSession{})
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Extract agent_name from session data (JSON)
|
||||
sess := ar.GetSession()
|
||||
if sess == nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// The session is stored as JSON in the database. We need to extract the agent_name.
|
||||
// Since we can't cast to fositeSession (it's in the auth package), we use the
|
||||
// Subject field which contains the username, and try to get agent_name via
|
||||
// the session's extra data.
|
||||
subject := sess.GetSubject()
|
||||
username := sess.GetUsername()
|
||||
_ = username
|
||||
|
||||
// Try type assertion for our session type
|
||||
type agentNameGetter interface {
|
||||
GetAgentName() string
|
||||
GetUserID() int64
|
||||
}
|
||||
if ang, ok := sess.(agentNameGetter); ok {
|
||||
name := ang.GetAgentName()
|
||||
uid := ang.GetUserID()
|
||||
oid := ""
|
||||
if uid > 0 {
|
||||
oid = fmt.Sprintf("%d", uid)
|
||||
}
|
||||
return name, oid, name != ""
|
||||
}
|
||||
|
||||
// Fallback: use subject as agent name if set
|
||||
if subject != "" {
|
||||
return subject, "", true
|
||||
}
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// oauthIntrospectSession is a minimal fosite.Session for introspection.
|
||||
// It mirrors the fositeSession in the auth package to allow JSON deserialization.
|
||||
type oauthIntrospectSession struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Subject string `json:"subject"`
|
||||
AgentName string `json:"agent_name,omitempty"`
|
||||
ExpiresAtMap map[fosite.TokenType]time.Time `json:"expires_at_map"`
|
||||
}
|
||||
|
||||
func (s *oauthIntrospectSession) SetExpiresAt(key fosite.TokenType, exp time.Time) {
|
||||
if s.ExpiresAtMap == nil {
|
||||
s.ExpiresAtMap = make(map[fosite.TokenType]time.Time)
|
||||
}
|
||||
s.ExpiresAtMap[key] = exp
|
||||
}
|
||||
|
||||
func (s *oauthIntrospectSession) GetExpiresAt(key fosite.TokenType) time.Time {
|
||||
if s.ExpiresAtMap == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return s.ExpiresAtMap[key]
|
||||
}
|
||||
|
||||
func (s *oauthIntrospectSession) GetUsername() string { return s.Username }
|
||||
func (s *oauthIntrospectSession) GetSubject() string { return s.Subject }
|
||||
func (s *oauthIntrospectSession) GetAgentName() string { return s.AgentName }
|
||||
func (s *oauthIntrospectSession) GetUserID() int64 { return s.UserID }
|
||||
|
||||
func (s *oauthIntrospectSession) Clone() fosite.Session {
|
||||
expiresAtMap := make(map[fosite.TokenType]time.Time)
|
||||
for k, v := range s.ExpiresAtMap {
|
||||
expiresAtMap[k] = v
|
||||
}
|
||||
return &oauthIntrospectSession{
|
||||
UserID: s.UserID,
|
||||
Username: s.Username,
|
||||
Subject: s.Subject,
|
||||
AgentName: s.AgentName,
|
||||
ExpiresAtMap: expiresAtMap,
|
||||
}
|
||||
}
|
||||
|
||||
// AuthMiddleware creates HTTP middleware that authenticates requests via API key.
|
||||
func AuthMiddleware(service *AgentService) func(http.Handler) http.Handler {
|
||||
return AuthMiddlewareWithAPIKeys(service, nil)
|
||||
|
||||
@@ -11,14 +11,16 @@ import (
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
)
|
||||
|
||||
// AgentService provides business logic for agent registry operations.
|
||||
type AgentService struct {
|
||||
store AgentStore
|
||||
tracer *trace.Tracer
|
||||
logger *slog.Logger
|
||||
store AgentStore
|
||||
tracer *trace.Tracer
|
||||
deadLetterStore *messaging.DeadLetterStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewAgentService creates a new agent service.
|
||||
@@ -30,6 +32,11 @@ func NewAgentService(store AgentStore, tracer *trace.Tracer) *AgentService {
|
||||
}
|
||||
}
|
||||
|
||||
// SetDeadLetterStore sets the dead letter store for capturing messages on agent deregistration.
|
||||
func (s *AgentService) SetDeadLetterStore(dls *messaging.DeadLetterStore) {
|
||||
s.deadLetterStore = dls
|
||||
}
|
||||
|
||||
// Register creates a new agent with a generated API key.
|
||||
// Returns the agent and the raw API key (shown once).
|
||||
func (s *AgentService) Register(ctx context.Context, name, displayName, agentType string, capabilities json.RawMessage, ownerID int64) (*Agent, string, error) {
|
||||
@@ -176,6 +183,7 @@ func (s *AgentService) UpdateAgent(ctx context.Context, name string, displayName
|
||||
}
|
||||
|
||||
// Deregister soft-deletes an agent. Only the owner can deregister.
|
||||
// Pending/processing messages are captured as dead letters before deactivation.
|
||||
func (s *AgentService) Deregister(ctx context.Context, name string, ownerID int64) error {
|
||||
agent, err := s.store.GetAgentByName(ctx, name)
|
||||
if err != nil {
|
||||
@@ -189,6 +197,20 @@ func (s *AgentService) Deregister(ctx context.Context, name string, ownerID int6
|
||||
return fmt.Errorf("only the agent's owner can deregister it")
|
||||
}
|
||||
|
||||
// Capture pending/processing messages as dead letters before deactivation
|
||||
var deadLetterCount int
|
||||
if s.deadLetterStore != nil {
|
||||
captured, err := s.deadLetterStore.CaptureDeadLetters(ctx, agent.OwnerID, name)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to capture dead letters",
|
||||
"agent", name,
|
||||
"error", err,
|
||||
)
|
||||
} else {
|
||||
deadLetterCount = captured
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.store.DeactivateAgent(ctx, name); err != nil {
|
||||
return fmt.Errorf("deactivate agent: %w", err)
|
||||
}
|
||||
@@ -196,12 +218,14 @@ func (s *AgentService) Deregister(ctx context.Context, name string, ownerID int6
|
||||
s.logger.Info("agent deregistered",
|
||||
"name", name,
|
||||
"owner_id", ownerID,
|
||||
"dead_letters_captured", deadLetterCount,
|
||||
)
|
||||
|
||||
if s.tracer != nil {
|
||||
s.tracer.Record(ctx, name, "deregister_agent", map[string]any{
|
||||
"agent_id": agent.ID,
|
||||
"owner_id": ownerID,
|
||||
"agent_id": agent.ID,
|
||||
"owner_id": ownerID,
|
||||
"dead_letters_captured": deadLetterCount,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -267,6 +291,60 @@ func (s *AgentService) RevokeKey(ctx context.Context, name string, ownerID int64
|
||||
return agent, apiKey, nil
|
||||
}
|
||||
|
||||
// GetHumanAgentForUser returns the human-type agent for a given owner.
|
||||
// Returns nil, nil if no human agent exists for this user.
|
||||
func (s *AgentService) GetHumanAgentForUser(ctx context.Context, ownerID int64) (*Agent, error) {
|
||||
agent, err := s.store.GetHumanAgentByOwner(ctx, ownerID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("get human agent: %w", err)
|
||||
}
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// EnsureHumanAgent creates a human-type agent matching the username if one doesn't already exist.
|
||||
// This is called on login to ensure every user has a human identity for sending messages from the UI.
|
||||
func (s *AgentService) EnsureHumanAgent(ctx context.Context, username, displayName string, ownerID int64) (*Agent, error) {
|
||||
// Check if user already has a human agent
|
||||
agents, err := s.store.ListAgentsByOwner(ctx, ownerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list agents: %w", err)
|
||||
}
|
||||
|
||||
for _, a := range agents {
|
||||
if a.Type == "human" && a.Status == "active" {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create human agent matching the username
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
|
||||
// Try username first, then username-human if taken
|
||||
agentName := username
|
||||
agent, _, err := s.Register(ctx, agentName, displayName, "human", nil, ownerID)
|
||||
if err != nil {
|
||||
// Name collision — try with suffix
|
||||
agentName = username + "-human"
|
||||
agent, _, err = s.Register(ctx, agentName, displayName, "human", nil, ownerID)
|
||||
if err != nil {
|
||||
s.logger.Warn("could not auto-create human agent", "username", username, "error", err)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("auto-created human agent for user",
|
||||
"username", username,
|
||||
"agent_name", agent.Name,
|
||||
"owner_id", ownerID,
|
||||
)
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// generateAPIKey creates a cryptographically random API key (32 bytes, hex encoded).
|
||||
func generateAPIKey() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
)
|
||||
|
||||
@@ -246,3 +248,136 @@ func TestAgentService_ListAgents(t *testing.T) {
|
||||
t.Errorf("got %d agents, want 2", len(agents))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentService_DeregisterCapturesDeadLetters(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
agentStore := NewSQLiteAgentStore(db)
|
||||
tracer := trace.NewTracer(db)
|
||||
t.Cleanup(func() { tracer.Close() })
|
||||
|
||||
agentSvc := NewAgentService(agentStore, tracer)
|
||||
|
||||
// Wire up dead letter store
|
||||
dls := messaging.NewDeadLetterStore(db)
|
||||
agentSvc.SetDeadLetterStore(dls)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Register sender and target agents
|
||||
agentSvc.Register(ctx, "dl-sender", "DL Sender", "ai", nil, 1)
|
||||
agentSvc.Register(ctx, "dl-target", "DL Target", "ai", nil, 1)
|
||||
|
||||
// Insert pending messages to dl-target
|
||||
db.Exec(`INSERT INTO conversations (id, subject, created_by, created_at, updated_at) VALUES (100, 'test', 'dl-sender', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
db.Exec(`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, created_at, updated_at) VALUES (100, 'dl-sender', 'dl-target', 'Pending message 1', 5, 'pending', '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
db.Exec(`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, created_at, updated_at) VALUES (100, 'dl-sender', 'dl-target', 'Pending message 2', 8, 'pending', '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
// Insert a done message (should NOT be captured)
|
||||
db.Exec(`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, created_at, updated_at) VALUES (100, 'dl-sender', 'dl-target', 'Done message', 5, 'done', '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
|
||||
// Deregister the target agent
|
||||
err := agentSvc.Deregister(ctx, "dl-target", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Deregister: %v", err)
|
||||
}
|
||||
|
||||
// Verify dead letters were captured
|
||||
letters, total, err := dls.ListDeadLetters(ctx, 1, false, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDeadLetters: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Errorf("total unacknowledged = %d, want 2", total)
|
||||
}
|
||||
if len(letters) != 2 {
|
||||
t.Fatalf("len(letters) = %d, want 2", len(letters))
|
||||
}
|
||||
|
||||
// Verify data correctness
|
||||
for _, dl := range letters {
|
||||
if dl.ToAgent != "dl-target" {
|
||||
t.Errorf("to_agent = %q, want dl-target", dl.ToAgent)
|
||||
}
|
||||
if dl.FromAgent != "dl-sender" {
|
||||
t.Errorf("from_agent = %q, want dl-sender", dl.FromAgent)
|
||||
}
|
||||
if dl.OwnerID != 1 {
|
||||
t.Errorf("owner_id = %d, want 1", dl.OwnerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentService_RegisterAndJoinMyAgents(t *testing.T) {
|
||||
// Integration test: register an agent, then join it to the my-agents channel.
|
||||
// This simulates what the API handler does after registration.
|
||||
db := newTestDB(t)
|
||||
agentStore := NewSQLiteAgentStore(db)
|
||||
tracer := trace.NewTracer(db)
|
||||
t.Cleanup(func() { tracer.Close() })
|
||||
|
||||
agentSvc := NewAgentService(agentStore, tracer)
|
||||
|
||||
// Create channel service
|
||||
channelStore := channels.NewSQLiteChannelStore(db)
|
||||
msgStore := messaging.NewSQLiteMessageStore(db)
|
||||
msgSvc := messaging.NewMessagingService(msgStore, tracer)
|
||||
channelSvc := channels.NewService(channelStore, msgSvc, tracer)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Step 1: Ensure human agent (simulates login)
|
||||
humanAgent, err := agentSvc.EnsureHumanAgent(ctx, "testowner", "Test Owner", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureHumanAgent: %v", err)
|
||||
}
|
||||
if humanAgent == nil {
|
||||
t.Fatal("expected human agent, got nil")
|
||||
}
|
||||
|
||||
// Step 2: Ensure my-agents channel (simulates login)
|
||||
err = channelSvc.EnsureMyAgentsChannel(ctx, "testowner", humanAgent.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureMyAgentsChannel: %v", err)
|
||||
}
|
||||
|
||||
// Step 3: Register an AI agent
|
||||
newAgent, _, err := agentSvc.Register(ctx, "my-bot", "My Bot", "ai", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
|
||||
// Step 4: Join agent to my-agents channel (simulates API handler post-registration)
|
||||
err = channelSvc.JoinMyAgentsChannel(ctx, "testowner", newAgent.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("JoinMyAgentsChannel: %v", err)
|
||||
}
|
||||
|
||||
// Verify the agent is a member of the my-agents channel
|
||||
ch, err := channelSvc.GetChannelByName(ctx, "my-agents-testowner")
|
||||
if err != nil {
|
||||
t.Fatalf("GetChannelByName: %v", err)
|
||||
}
|
||||
|
||||
members, err := channelSvc.GetMembers(ctx, ch.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMembers: %v", err)
|
||||
}
|
||||
|
||||
// Should have 2 members: human agent (owner) + my-bot (member)
|
||||
if len(members) != 2 {
|
||||
t.Errorf("got %d members, want 2", len(members))
|
||||
}
|
||||
|
||||
// Verify my-bot is a member
|
||||
found := false
|
||||
for _, m := range members {
|
||||
if m.AgentName == "my-bot" {
|
||||
found = true
|
||||
if m.Role != channels.RoleMember {
|
||||
t.Errorf("my-bot role = %s, want member", m.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("my-bot should be a member of my-agents channel")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type AgentStore interface {
|
||||
ListActiveAgents(ctx context.Context) ([]*Agent, error)
|
||||
ListAgentsByOwner(ctx context.Context, ownerID int64) ([]*Agent, error)
|
||||
SearchAgentsByCapability(ctx context.Context, query string) ([]*Agent, error)
|
||||
GetHumanAgentByOwner(ctx context.Context, ownerID int64) (*Agent, error)
|
||||
}
|
||||
|
||||
// SQLiteAgentStore implements AgentStore using SQLite.
|
||||
@@ -138,6 +139,13 @@ func (s *SQLiteAgentStore) SearchAgentsByCapability(ctx context.Context, query s
|
||||
return s.scanAgents(rows)
|
||||
}
|
||||
|
||||
func (s *SQLiteAgentStore) GetHumanAgentByOwner(ctx context.Context, ownerID int64) (*Agent, error) {
|
||||
return s.scanAgent(s.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, display_name, type, capabilities, owner_id, api_key_hash, status, created_at, updated_at
|
||||
FROM agents WHERE owner_id = ? AND type = 'human' AND status = 'active' LIMIT 1`, ownerID,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *SQLiteAgentStore) scanAgent(row *sql.Row) (*Agent, error) {
|
||||
var agent Agent
|
||||
var caps string
|
||||
|
||||
@@ -8,23 +8,30 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/auth"
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
)
|
||||
|
||||
// AgentsHandler handles REST API requests for agents.
|
||||
type AgentsHandler struct {
|
||||
agentService *agents.AgentService
|
||||
traceStore trace.TraceStore
|
||||
logger *slog.Logger
|
||||
agentService *agents.AgentService
|
||||
channelService *channels.Service
|
||||
traceStore trace.TraceStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewAgentsHandler creates a new agents handler.
|
||||
func NewAgentsHandler(agentService *agents.AgentService, traceStore trace.TraceStore) *AgentsHandler {
|
||||
return &AgentsHandler{
|
||||
func NewAgentsHandler(agentService *agents.AgentService, traceStore trace.TraceStore, channelService ...*channels.Service) *AgentsHandler {
|
||||
h := &AgentsHandler{
|
||||
agentService: agentService,
|
||||
traceStore: traceStore,
|
||||
logger: slog.Default().With("component", "api.agents"),
|
||||
}
|
||||
if len(channelService) > 0 {
|
||||
h.channelService = channelService[0]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// ListAgents handles GET /api/agents.
|
||||
@@ -117,6 +124,19 @@ func (h *AgentsHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-join agent to the owner's my-agents channel
|
||||
if h.channelService != nil {
|
||||
if user, ok := auth.UserFromContext(r.Context()); ok {
|
||||
if err := h.channelService.JoinMyAgentsChannel(r.Context(), user.Username, agent.Name); err != nil {
|
||||
h.logger.Warn("failed to join agent to my-agents channel",
|
||||
"agent", agent.Name,
|
||||
"username", user.Username,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"agent": agent,
|
||||
"api_key": apiKey,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
)
|
||||
|
||||
// DeadLettersHandler handles REST API requests for the dead letter queue.
|
||||
type DeadLettersHandler struct {
|
||||
deadLetterStore *messaging.DeadLetterStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewDeadLettersHandler creates a new dead letters handler.
|
||||
func NewDeadLettersHandler(dls *messaging.DeadLetterStore) *DeadLettersHandler {
|
||||
return &DeadLettersHandler{
|
||||
deadLetterStore: dls,
|
||||
logger: slog.Default().With("component", "api.deadletters"),
|
||||
}
|
||||
}
|
||||
|
||||
// List handles GET /api/dead-letters.
|
||||
func (h *DeadLettersHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := OwnerIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
includeAcknowledged := r.URL.Query().Get("acknowledged") == "true"
|
||||
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
letters, total, err := h.deadLetterStore.ListDeadLetters(r.Context(), ownerID, includeAcknowledged, limit)
|
||||
if err != nil {
|
||||
h.logger.Error("list dead letters failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list dead letters"))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"dead_letters": letters,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// Acknowledge handles POST /api/dead-letters/{id}/acknowledge.
|
||||
func (h *DeadLettersHandler) Acknowledge(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := OwnerIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid dead letter ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.deadLetterStore.AcknowledgeDeadLetter(r.Context(), id, ownerID); err != nil {
|
||||
h.logger.Error("acknowledge dead letter failed", "error", err)
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("acknowledge_failed", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"acknowledged": true})
|
||||
}
|
||||
|
||||
// Count handles GET /api/dead-letters/count.
|
||||
func (h *DeadLettersHandler) Count(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := OwnerIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
count, err := h.deadLetterStore.CountUnacknowledged(r.Context(), ownerID)
|
||||
if err != nil {
|
||||
h.logger.Error("count dead letters failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to count dead letters"))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"count": count})
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
"github.com/synapbus/synapbus/internal/storage"
|
||||
)
|
||||
|
||||
func newTestDBFull(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||
t.Fatalf("enable foreign keys: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := storage.RunMigrations(ctx, db); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
|
||||
// Seed test users
|
||||
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (1, 'owner1', 'hash', 'Owner 1')`)
|
||||
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (2, 'owner2', 'hash', 'Owner 2')`)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func seedTestAgent(t *testing.T, db *sql.DB, name string, ownerID int64) {
|
||||
t.Helper()
|
||||
_, err := db.Exec(
|
||||
`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES (?, ?, 'ai', '{}', ?, 'testhash', 'active')`,
|
||||
name, name, ownerID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed agent %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedTestMessage(t *testing.T, db *sql.DB, from, to, body string, priority int) int64 {
|
||||
t.Helper()
|
||||
// Ensure a conversation exists
|
||||
var convID int64
|
||||
result, err := db.Exec(
|
||||
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
"test", from,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed conversation: %v", err)
|
||||
}
|
||||
convID, _ = result.LastInsertId()
|
||||
|
||||
result, err = db.Exec(
|
||||
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'pending', '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
convID, from, to, body, priority,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
func seedDeadLetter(t *testing.T, db *sql.DB, ownerID int64, toAgent, fromAgent, body string, priority int) int64 {
|
||||
t.Helper()
|
||||
result, err := db.Exec(
|
||||
`INSERT INTO dead_letters (owner_id, original_message_id, to_agent, from_agent, body, subject, priority, metadata) VALUES (?, 0, ?, ?, ?, '', ?, '{}')`,
|
||||
ownerID, toAgent, fromAgent, body, priority,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed dead letter: %v", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
func makeDeadLetterRequest(t *testing.T, handler http.Handler, method, path string, ownerID string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if ownerID != "" {
|
||||
req.Header.Set("X-Owner-ID", ownerID)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestDeadLetterCaptureOnAgentDeletion(t *testing.T) {
|
||||
db := newTestDBFull(t)
|
||||
|
||||
seedTestAgent(t, db, "sender", 1)
|
||||
seedTestAgent(t, db, "target-bot", 1)
|
||||
|
||||
// Send pending messages to target-bot
|
||||
seedTestMessage(t, db, "sender", "target-bot", "Hello, are you there?", 5)
|
||||
seedTestMessage(t, db, "sender", "target-bot", "Urgent task for you", 8)
|
||||
|
||||
// Create dead letter store and capture
|
||||
dls := messaging.NewDeadLetterStore(db)
|
||||
|
||||
captured, err := dls.CaptureDeadLetters(context.Background(), 1, "target-bot")
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureDeadLetters: %v", err)
|
||||
}
|
||||
if captured != 2 {
|
||||
t.Errorf("captured = %d, want 2", captured)
|
||||
}
|
||||
|
||||
// Verify dead letters were stored
|
||||
letters, total, err := dls.ListDeadLetters(context.Background(), 1, false, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDeadLetters: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Errorf("total = %d, want 2", total)
|
||||
}
|
||||
if len(letters) != 2 {
|
||||
t.Fatalf("len(letters) = %d, want 2", len(letters))
|
||||
}
|
||||
|
||||
// Verify dead letter data
|
||||
foundHello := false
|
||||
foundUrgent := false
|
||||
for _, dl := range letters {
|
||||
if dl.ToAgent != "target-bot" {
|
||||
t.Errorf("to_agent = %q, want target-bot", dl.ToAgent)
|
||||
}
|
||||
if dl.FromAgent != "sender" {
|
||||
t.Errorf("from_agent = %q, want sender", dl.FromAgent)
|
||||
}
|
||||
if dl.OwnerID != 1 {
|
||||
t.Errorf("owner_id = %d, want 1", dl.OwnerID)
|
||||
}
|
||||
if dl.Body == "Hello, are you there?" {
|
||||
foundHello = true
|
||||
}
|
||||
if dl.Body == "Urgent task for you" {
|
||||
foundUrgent = true
|
||||
if dl.Priority != 8 {
|
||||
t.Errorf("priority = %d, want 8", dl.Priority)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundHello {
|
||||
t.Error("expected to find 'Hello, are you there?' dead letter")
|
||||
}
|
||||
if !foundUrgent {
|
||||
t.Error("expected to find 'Urgent task for you' dead letter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeadLetterListAPI(t *testing.T) {
|
||||
db := newTestDBFull(t)
|
||||
dls := messaging.NewDeadLetterStore(db)
|
||||
|
||||
// Seed dead letters for owner 1
|
||||
seedDeadLetter(t, db, 1, "deleted-bot", "sender", "Message 1", 5)
|
||||
seedDeadLetter(t, db, 1, "deleted-bot", "sender", "Message 2", 3)
|
||||
// Seed one for owner 2 (should not appear)
|
||||
seedDeadLetter(t, db, 2, "other-bot", "other-sender", "Other message", 5)
|
||||
|
||||
deadLettersHandler := NewDeadLettersHandler(dls)
|
||||
|
||||
// Build router with dead letters handler
|
||||
router := NewRouter(nil, nil, nil)
|
||||
router.Group(func(r chi.Router) {
|
||||
r.Use(OwnerAuthMiddleware)
|
||||
r.Get("/api/dead-letters", deadLettersHandler.List)
|
||||
r.Get("/api/dead-letters/count", deadLettersHandler.Count)
|
||||
r.Post("/api/dead-letters/{id}/acknowledge", deadLettersHandler.Acknowledge)
|
||||
})
|
||||
|
||||
t.Run("list returns owner dead letters", func(t *testing.T) {
|
||||
rr := makeDeadLetterRequest(t, router, "GET", "/api/dead-letters", "1")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
DeadLetters []messaging.DeadLetter `json:"dead_letters"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(resp.DeadLetters) != 2 {
|
||||
t.Errorf("got %d dead letters, want 2", len(resp.DeadLetters))
|
||||
}
|
||||
if resp.Total != 2 {
|
||||
t.Errorf("total = %d, want 2", resp.Total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("owner isolation", func(t *testing.T) {
|
||||
rr := makeDeadLetterRequest(t, router, "GET", "/api/dead-letters", "2")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
DeadLetters []messaging.DeadLetter `json:"dead_letters"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
if len(resp.DeadLetters) != 1 {
|
||||
t.Errorf("got %d dead letters, want 1", len(resp.DeadLetters))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unauthenticated returns 401", func(t *testing.T) {
|
||||
rr := makeDeadLetterRequest(t, router, "GET", "/api/dead-letters", "")
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rr.Code, http.StatusUnauthorized)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeadLetterAcknowledgeAPI(t *testing.T) {
|
||||
db := newTestDBFull(t)
|
||||
dls := messaging.NewDeadLetterStore(db)
|
||||
|
||||
dlID := seedDeadLetter(t, db, 1, "deleted-bot", "sender", "Unread message", 5)
|
||||
|
||||
deadLettersHandler := NewDeadLettersHandler(dls)
|
||||
|
||||
router := NewRouter(nil, nil, nil)
|
||||
router.Group(func(r chi.Router) {
|
||||
r.Use(OwnerAuthMiddleware)
|
||||
r.Get("/api/dead-letters", deadLettersHandler.List)
|
||||
r.Get("/api/dead-letters/count", deadLettersHandler.Count)
|
||||
r.Post("/api/dead-letters/{id}/acknowledge", deadLettersHandler.Acknowledge)
|
||||
})
|
||||
|
||||
t.Run("acknowledge dead letter", func(t *testing.T) {
|
||||
rr := makeDeadLetterRequest(t, router, "POST", fmt.Sprintf("/api/dead-letters/%d/acknowledge", dlID), "1")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
if resp["acknowledged"] != true {
|
||||
t.Errorf("acknowledged = %v, want true", resp["acknowledged"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("acknowledged dead letter not in default list", func(t *testing.T) {
|
||||
rr := makeDeadLetterRequest(t, router, "GET", "/api/dead-letters", "1")
|
||||
var resp struct {
|
||||
DeadLetters []messaging.DeadLetter `json:"dead_letters"`
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
if len(resp.DeadLetters) != 0 {
|
||||
t.Errorf("got %d dead letters, want 0 (acknowledged should be hidden)", len(resp.DeadLetters))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("acknowledged dead letter visible when including acknowledged", func(t *testing.T) {
|
||||
rr := makeDeadLetterRequest(t, router, "GET", "/api/dead-letters?acknowledged=true", "1")
|
||||
var resp struct {
|
||||
DeadLetters []messaging.DeadLetter `json:"dead_letters"`
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
if len(resp.DeadLetters) != 1 {
|
||||
t.Errorf("got %d dead letters, want 1", len(resp.DeadLetters))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong owner cannot acknowledge", func(t *testing.T) {
|
||||
// Seed another dead letter for owner 1
|
||||
newID := seedDeadLetter(t, db, 1, "deleted-bot", "sender", "Another message", 5)
|
||||
rr := makeDeadLetterRequest(t, router, "POST", fmt.Sprintf("/api/dead-letters/%d/acknowledge", newID), "2")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", rr.Code, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeadLetterCountAPI(t *testing.T) {
|
||||
db := newTestDBFull(t)
|
||||
dls := messaging.NewDeadLetterStore(db)
|
||||
|
||||
seedDeadLetter(t, db, 1, "deleted-bot", "sender", "Message 1", 5)
|
||||
seedDeadLetter(t, db, 1, "deleted-bot", "sender", "Message 2", 3)
|
||||
|
||||
deadLettersHandler := NewDeadLettersHandler(dls)
|
||||
|
||||
router := NewRouter(nil, nil, nil)
|
||||
router.Group(func(r chi.Router) {
|
||||
r.Use(OwnerAuthMiddleware)
|
||||
r.Get("/api/dead-letters/count", deadLettersHandler.Count)
|
||||
r.Post("/api/dead-letters/{id}/acknowledge", deadLettersHandler.Acknowledge)
|
||||
})
|
||||
|
||||
t.Run("count returns unacknowledged count", func(t *testing.T) {
|
||||
rr := makeDeadLetterRequest(t, router, "GET", "/api/dead-letters/count", "1")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
if resp["count"].(float64) != 2 {
|
||||
t.Errorf("count = %v, want 2", resp["count"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("count decreases after acknowledge", func(t *testing.T) {
|
||||
// Get the first dead letter's ID
|
||||
var dlID int64
|
||||
db.QueryRow(`SELECT id FROM dead_letters WHERE owner_id = 1 LIMIT 1`).Scan(&dlID)
|
||||
|
||||
makeDeadLetterRequest(t, router, "POST", fmt.Sprintf("/api/dead-letters/%d/acknowledge", dlID), "1")
|
||||
|
||||
rr := makeDeadLetterRequest(t, router, "GET", "/api/dead-letters/count", "1")
|
||||
var resp map[string]any
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
if resp["count"].(float64) != 1 {
|
||||
t.Errorf("count = %v, want 1", resp["count"])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/auth"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
)
|
||||
|
||||
@@ -256,13 +257,35 @@ func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.From == "" {
|
||||
// For session-authenticated users (Web UI), always send as the human agent
|
||||
// regardless of what `from` was provided in the request.
|
||||
if _, isSession := auth.SessionIDFromContext(r.Context()); isSession {
|
||||
humanAgent, err := h.agentService.GetHumanAgentForUser(r.Context(), ownerID)
|
||||
if err != nil {
|
||||
h.logger.Error("get human agent failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to determine sender"))
|
||||
return
|
||||
}
|
||||
if humanAgent == nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("no_human_agent", "No human agent found. Please log in again to auto-create one."))
|
||||
return
|
||||
}
|
||||
req.From = humanAgent.Name
|
||||
} else if req.From == "" {
|
||||
// Non-session auth (API key, bearer token): fall back to finding an agent
|
||||
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
|
||||
if err != nil || len(ownedAgents) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("no_agents", "No agents registered. Register an agent first."))
|
||||
return
|
||||
}
|
||||
// Prefer human-type agent
|
||||
req.From = ownedAgents[0].Name
|
||||
for _, a := range ownedAgents {
|
||||
if a.Type == "human" {
|
||||
req.From = a.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !h.isAgentOwnedBy(r, req.From, ownerID) {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/auth"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
"github.com/synapbus/synapbus/internal/storage"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
)
|
||||
|
||||
func newMessagesTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||
t.Fatalf("enable foreign keys: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := storage.RunMigrations(ctx, db); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, db *sql.DB, id int64, username string) {
|
||||
t.Helper()
|
||||
_, err := db.Exec(
|
||||
`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (?, ?, 'hash', ?)`,
|
||||
id, username, username,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed user %s: %v", username, err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedTestAgentWithType(t *testing.T, db *sql.DB, name, agentType string, ownerID int64) {
|
||||
t.Helper()
|
||||
_, err := db.Exec(
|
||||
`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status)
|
||||
VALUES (?, ?, ?, '{}', ?, 'testhash', 'active')`,
|
||||
name, name, agentType, ownerID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed agent %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessage_SessionAuth_OverridesFrom(t *testing.T) {
|
||||
db := newMessagesTestDB(t)
|
||||
|
||||
// Create users and agents
|
||||
seedUser(t, db, 1, "alice")
|
||||
seedUser(t, db, 2, "bob")
|
||||
seedTestAgentWithType(t, db, "alice", "human", 1) // Human agent for alice
|
||||
seedTestAgentWithType(t, db, "alice-bot", "ai", 1) // AI agent for alice
|
||||
seedTestAgentWithType(t, db, "bob", "human", 2) // Need bob for messaging target
|
||||
|
||||
agentStore := agents.NewSQLiteAgentStore(db)
|
||||
tracer := trace.NewTracer(db)
|
||||
t.Cleanup(func() { tracer.Close() })
|
||||
agentService := agents.NewAgentService(agentStore, tracer)
|
||||
|
||||
msgStore := messaging.NewSQLiteMessageStore(db)
|
||||
msgService := messaging.NewMessagingService(msgStore, tracer)
|
||||
|
||||
handler := NewMessagesHandler(msgService, agentService)
|
||||
|
||||
t.Run("session auth overrides from with human agent", func(t *testing.T) {
|
||||
// Send a message with from="alice-bot" but session auth should override to "alice"
|
||||
body := `{"from":"alice-bot","to":"bob","body":"Hello from UI"}`
|
||||
req := httptest.NewRequest("POST", "/api/messages", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Set up session auth context: set ownerID and session ID
|
||||
ctx := ContextWithOwnerID(req.Context(), 1)
|
||||
ctx = auth.ContextWithSessionID(ctx, "test-session-id")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.SendMessage(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusCreated, rr.Body.String())
|
||||
}
|
||||
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &msg); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
// Verify the from_agent was overridden to the human agent
|
||||
if msg["from_agent"] != "alice" {
|
||||
t.Errorf("from_agent = %v, want 'alice' (human agent)", msg["from_agent"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("session auth without from field uses human agent", func(t *testing.T) {
|
||||
body := `{"to":"bob","body":"Hello without from"}`
|
||||
req := httptest.NewRequest("POST", "/api/messages", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
ctx := ContextWithOwnerID(req.Context(), 1)
|
||||
ctx = auth.ContextWithSessionID(ctx, "test-session-id")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.SendMessage(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusCreated, rr.Body.String())
|
||||
}
|
||||
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &msg); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if msg["from_agent"] != "alice" {
|
||||
t.Errorf("from_agent = %v, want 'alice' (human agent)", msg["from_agent"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-session auth respects from field", func(t *testing.T) {
|
||||
// Without session ID in context, the from field should be used as-is
|
||||
body := `{"from":"alice-bot","to":"bob","body":"Hello from API"}`
|
||||
req := httptest.NewRequest("POST", "/api/messages", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Only set ownerID (no session ID) — simulates API key auth
|
||||
ctx := ContextWithOwnerID(req.Context(), 1)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.SendMessage(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusCreated, rr.Body.String())
|
||||
}
|
||||
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &msg); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
// Should use the provided from field since it's not a session auth
|
||||
if msg["from_agent"] != "alice-bot" {
|
||||
t.Errorf("from_agent = %v, want 'alice-bot'", msg["from_agent"])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -75,6 +75,18 @@ func (w *responseWriter) WriteHeader(code int) {
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Flush implements http.Flusher so SSE streaming works through the logging middleware.
|
||||
func (w *responseWriter) Flush() {
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying ResponseWriter for http.ResponseController.
|
||||
func (w *responseWriter) Unwrap() http.ResponseWriter {
|
||||
return w.ResponseWriter
|
||||
}
|
||||
|
||||
// OwnerAuthMiddleware is a simple middleware that extracts owner_id from an authenticated session.
|
||||
// In the full system this would validate session tokens. For now it extracts from
|
||||
// a header or query param for testing purposes. In production, this integrates with
|
||||
|
||||
+14
-1
@@ -23,6 +23,7 @@ type RouterConfig struct {
|
||||
AgentService *agents.AgentService
|
||||
ChannelService *channels.Service
|
||||
APIKeyService *apikeys.Service
|
||||
DeadLetterStore *messaging.DeadLetterStore
|
||||
SSEHub *SSEHub
|
||||
SessionMiddleware func(http.Handler) http.Handler
|
||||
}
|
||||
@@ -77,7 +78,7 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
|
||||
// Web UI API routes (messages, agents, channels, SSE)
|
||||
if cfg.MsgService != nil && cfg.AgentService != nil {
|
||||
messagesHandler := NewMessagesHandler(cfg.MsgService, cfg.AgentService)
|
||||
agentsHandler := NewAgentsHandler(cfg.AgentService, cfg.TraceStore)
|
||||
agentsHandler := NewAgentsHandler(cfg.AgentService, cfg.TraceStore, cfg.ChannelService)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authMiddleware)
|
||||
@@ -139,6 +140,18 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
|
||||
r.Get("/api/events", cfg.SSEHub.HandleEvents)
|
||||
})
|
||||
}
|
||||
|
||||
// Dead Letters
|
||||
if cfg.DeadLetterStore != nil {
|
||||
deadLettersHandler := NewDeadLettersHandler(cfg.DeadLetterStore)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authMiddleware)
|
||||
|
||||
r.Get("/api/dead-letters", deadLettersHandler.List)
|
||||
r.Get("/api/dead-letters/count", deadLettersHandler.Count)
|
||||
r.Post("/api/dead-letters/{id}/acknowledge", deadLettersHandler.Acknowledge)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics endpoint (unauthenticated, only registered when enabled)
|
||||
|
||||
@@ -65,6 +65,20 @@ func (h *SSEHub) BroadcastAll(event SSEEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// Close disconnects all SSE clients so in-flight HTTP connections can drain.
|
||||
func (h *SSEHub) Close() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
for ownerID, clientSet := range h.clients {
|
||||
for ch := range clientSet {
|
||||
close(ch)
|
||||
}
|
||||
delete(h.clients, ownerID)
|
||||
}
|
||||
h.logger.Info("all SSE clients disconnected")
|
||||
}
|
||||
|
||||
func (h *SSEHub) addClient(ownerID int64) chan SSEEvent {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -84,12 +98,14 @@ func (h *SSEHub) removeClient(ownerID int64, ch chan SSEEvent) {
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if clientSet, ok := h.clients[ownerID]; ok {
|
||||
delete(clientSet, ch)
|
||||
if _, exists := clientSet[ch]; exists {
|
||||
delete(clientSet, ch)
|
||||
close(ch)
|
||||
}
|
||||
if len(clientSet) == 0 {
|
||||
delete(h.clients, ownerID)
|
||||
}
|
||||
}
|
||||
close(ch)
|
||||
|
||||
h.logger.Info("SSE client disconnected", "owner_id", ownerID)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
// ClientStore defines the storage interface for OAuth client operations.
|
||||
type ClientStore interface {
|
||||
CreateClient(ctx context.Context, name string, redirectURIs, grantTypes, scopes []string, ownerID int64) (*OAuthClient, string, error)
|
||||
CreatePublicClient(ctx context.Context, name string, redirectURIs, grantTypes, scopes []string) (*OAuthClient, error)
|
||||
GetClient(ctx context.Context, clientID string) (*OAuthClient, error)
|
||||
ListClientsByOwner(ctx context.Context, ownerID int64) ([]*OAuthClient, error)
|
||||
VerifyClientSecret(ctx context.Context, clientID, secret string) (*OAuthClient, error)
|
||||
@@ -90,6 +91,46 @@ func (s *SQLiteClientStore) CreateClient(ctx context.Context, name string, redir
|
||||
return client, secret, nil
|
||||
}
|
||||
|
||||
// CreatePublicClient registers a public OAuth client (no secret) for dynamic registration (RFC 7591).
|
||||
func (s *SQLiteClientStore) CreatePublicClient(ctx context.Context, name string, redirectURIs, grantTypes, scopes []string) (*OAuthClient, error) {
|
||||
clientID, err := generateClientID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate client_id: %w", err)
|
||||
}
|
||||
|
||||
if redirectURIs == nil {
|
||||
redirectURIs = []string{}
|
||||
}
|
||||
if grantTypes == nil {
|
||||
grantTypes = []string{"authorization_code", "refresh_token"}
|
||||
}
|
||||
if scopes == nil {
|
||||
scopes = []string{"mcp"}
|
||||
}
|
||||
|
||||
redirectURIsJSON, _ := json.Marshal(redirectURIs)
|
||||
grantTypesJSON, _ := json.Marshal(grantTypes)
|
||||
scopesJSON, _ := json.Marshal(scopes)
|
||||
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`INSERT INTO oauth_clients (id, secret_hash, name, redirect_uris, grant_types, scopes, owner_id, created_at)
|
||||
VALUES (?, '', ?, ?, ?, ?, NULL, CURRENT_TIMESTAMP)`,
|
||||
clientID, name, string(redirectURIsJSON), string(grantTypesJSON), string(scopesJSON),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert client: %w", err)
|
||||
}
|
||||
|
||||
return &OAuthClient{
|
||||
ID: clientID,
|
||||
Name: name,
|
||||
RedirectURIs: redirectURIs,
|
||||
GrantTypes: grantTypes,
|
||||
Scopes: scopes,
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClient retrieves an OAuth client by its ID.
|
||||
func (s *SQLiteClientStore) GetClient(ctx context.Context, clientID string) (*OAuthClient, error) {
|
||||
var redirectURIsJSON, grantTypesJSON, scopesJSON string
|
||||
|
||||
@@ -38,9 +38,10 @@ func NewFositeStore(db *sql.DB, bcryptCost int) *FositeStore {
|
||||
|
||||
// fositeSession implements fosite.Session for token storage.
|
||||
type fositeSession struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Subject string `json:"subject"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Subject string `json:"subject"`
|
||||
AgentName string `json:"agent_name,omitempty"`
|
||||
ExpiresAtMap map[fosite.TokenType]time.Time `json:"expires_at_map"`
|
||||
}
|
||||
|
||||
@@ -80,6 +81,7 @@ func (s *fositeSession) Clone() fosite.Session {
|
||||
UserID: s.UserID,
|
||||
Username: s.Username,
|
||||
Subject: s.Subject,
|
||||
AgentName: s.AgentName,
|
||||
ExpiresAtMap: expiresAtMap,
|
||||
}
|
||||
}
|
||||
@@ -324,8 +326,25 @@ func (s *FositeStore) RevokeAccessToken(ctx context.Context, requestID string) e
|
||||
// --- pkce.PKCERequestStorage ---
|
||||
|
||||
// CreatePKCERequestSession stores PKCE data for an authorization code.
|
||||
// Fosite calls this after CreateAuthorizeCodeSession with the same code signature.
|
||||
// We update the auth code row with the PKCE challenge from the request form.
|
||||
func (s *FositeStore) CreatePKCERequestSession(ctx context.Context, signature string, request fosite.Requester) error {
|
||||
// PKCE data is stored as part of the authorization code session
|
||||
form := request.GetRequestForm()
|
||||
codeChallenge := form.Get("code_challenge")
|
||||
codeChallengeMethod := form.Get("code_challenge_method")
|
||||
|
||||
if codeChallenge == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE oauth_authorization_codes SET code_challenge = ?, code_challenge_method = ? WHERE code = ?`,
|
||||
codeChallenge, codeChallengeMethod, signature,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error("store PKCE session failed", "error", err)
|
||||
return fosite.ErrServerError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -487,7 +506,7 @@ func (c *fositeClient) GetRedirectURIs() []string { return c.redirectUR
|
||||
func (c *fositeClient) GetGrantTypes() fosite.Arguments { return fosite.Arguments(c.grantTypes) }
|
||||
func (c *fositeClient) GetResponseTypes() fosite.Arguments { return fosite.Arguments{"code"} }
|
||||
func (c *fositeClient) GetScopes() fosite.Arguments { return fosite.Arguments(c.scopes) }
|
||||
func (c *fositeClient) IsPublic() bool { return false }
|
||||
func (c *fositeClient) IsPublic() bool { return len(c.secretHash) == 0 }
|
||||
func (c *fositeClient) GetAudience() fosite.Arguments { return fosite.Arguments{} }
|
||||
|
||||
// GetResponseModes implements fosite.ResponseModeClient.
|
||||
|
||||
+610
-17
@@ -1,15 +1,32 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
)
|
||||
|
||||
// AgentLister provides a way to list agents by owner for the authorize page.
|
||||
// This avoids a direct dependency on the agents package.
|
||||
type AgentLister interface {
|
||||
ListAgentsByOwner(ctx context.Context, ownerID int64) ([]AgentInfo, error)
|
||||
}
|
||||
|
||||
// AgentInfo is a minimal agent representation used by the authorize page.
|
||||
type AgentInfo struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
Type string
|
||||
}
|
||||
|
||||
// Handlers holds the HTTP handlers for the auth subsystem.
|
||||
type Handlers struct {
|
||||
userStore UserStore
|
||||
@@ -17,6 +34,7 @@ type Handlers struct {
|
||||
clientStore ClientStore
|
||||
provider fosite.OAuth2Provider
|
||||
config Config
|
||||
agentLister AgentLister
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
@@ -38,6 +56,11 @@ func NewHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
// SetAgentLister sets the agent lister for the OAuth authorize page.
|
||||
func (h *Handlers) SetAgentLister(lister AgentLister) {
|
||||
h.agentLister = lister
|
||||
}
|
||||
|
||||
// HandleRegister handles POST /auth/register.
|
||||
func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -263,38 +286,608 @@ func (h *Handlers) HandleChangePassword(w http.ResponseWriter, r *http.Request)
|
||||
w.Write([]byte(`{"status":"password_changed"}`))
|
||||
}
|
||||
|
||||
// HandleAuthorize handles GET /oauth/authorize.
|
||||
func (h *Handlers) HandleAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
// HandleOAuthMetadata handles GET /.well-known/oauth-authorization-server.
|
||||
// Returns OAuth 2.1 server metadata as per RFC 8414.
|
||||
func (h *Handlers) HandleOAuthMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "GET required")
|
||||
return
|
||||
}
|
||||
|
||||
ar, err := h.provider.NewAuthorizeRequest(ctx, r)
|
||||
baseURL := h.config.IssuerURL
|
||||
if baseURL == "" {
|
||||
// Fall back to request Host header
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
baseURL = fmt.Sprintf("%s://%s", scheme, r.Host)
|
||||
}
|
||||
|
||||
metadata := map[string]any{
|
||||
"issuer": baseURL,
|
||||
"authorization_endpoint": baseURL + "/oauth/authorize",
|
||||
"token_endpoint": baseURL + "/oauth/token",
|
||||
"registration_endpoint": baseURL + "/oauth/register",
|
||||
"token_endpoint_auth_methods_supported": []string{"none"},
|
||||
"response_types_supported": []string{"code"},
|
||||
"grant_types_supported": []string{"authorization_code", "refresh_token"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"scopes_supported": []string{"mcp"},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(metadata)
|
||||
}
|
||||
|
||||
// HandleDynamicRegistration handles POST /oauth/register (RFC 7591).
|
||||
// MCP clients use this to register themselves as public OAuth clients.
|
||||
func (h *Handlers) HandleDynamicRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST required")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ClientName string `json:"client_name"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_client_metadata", "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.ClientName == "" {
|
||||
req.ClientName = "dynamic-client"
|
||||
}
|
||||
|
||||
// Default redirect URIs for MCP clients
|
||||
if len(req.RedirectURIs) == 0 {
|
||||
req.RedirectURIs = []string{"http://127.0.0.1"}
|
||||
}
|
||||
|
||||
// Normalize localhost to 127.0.0.1 for fosite's loopback matching (RFC 8252 Section 7.3).
|
||||
// Fosite allows dynamic ports for loopback IPs but not for "localhost" hostname.
|
||||
normalizedURIs := make([]string, 0, len(req.RedirectURIs))
|
||||
for _, uri := range req.RedirectURIs {
|
||||
parsed, err := url.Parse(uri)
|
||||
if err == nil && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1") {
|
||||
// Store without port — fosite ignores port for loopback addresses
|
||||
normalized := fmt.Sprintf("http://127.0.0.1%s", parsed.Path)
|
||||
normalizedURIs = append(normalizedURIs, normalized)
|
||||
} else {
|
||||
normalizedURIs = append(normalizedURIs, uri)
|
||||
}
|
||||
}
|
||||
req.RedirectURIs = normalizedURIs
|
||||
|
||||
// Only allow authorization_code + refresh_token for public clients
|
||||
grantTypes := []string{"authorization_code", "refresh_token"}
|
||||
scopes := []string{"mcp"}
|
||||
|
||||
client, err := h.clientStore.CreatePublicClient(r.Context(), req.ClientName, req.RedirectURIs, grantTypes, scopes)
|
||||
if err != nil {
|
||||
h.logger.Error("dynamic client registration failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "server_error", "Failed to register client")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("dynamic client registered",
|
||||
"client_id", client.ID,
|
||||
"client_name", client.Name,
|
||||
"redirect_uris", client.RedirectURIs,
|
||||
)
|
||||
|
||||
// RFC 7591 response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"client_id": client.ID,
|
||||
"client_name": client.Name,
|
||||
"redirect_uris": client.RedirectURIs,
|
||||
"grant_types": client.GrantTypes,
|
||||
"scope": "mcp",
|
||||
"token_endpoint_auth_method": "none",
|
||||
})
|
||||
}
|
||||
|
||||
// HandleAuthorizeGet handles GET /oauth/authorize.
|
||||
// Renders the login form or agent selector depending on session state.
|
||||
func (h *Handlers) HandleAuthorizeGet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "GET required")
|
||||
return
|
||||
}
|
||||
|
||||
// Collect OAuth params from query string
|
||||
oauthParams := authorizeParams{
|
||||
ResponseType: r.URL.Query().Get("response_type"),
|
||||
ClientID: r.URL.Query().Get("client_id"),
|
||||
RedirectURI: r.URL.Query().Get("redirect_uri"),
|
||||
State: r.URL.Query().Get("state"),
|
||||
Scope: r.URL.Query().Get("scope"),
|
||||
CodeChallenge: r.URL.Query().Get("code_challenge"),
|
||||
CodeChallengeMethod: r.URL.Query().Get("code_challenge_method"),
|
||||
}
|
||||
|
||||
// Check if user has a valid session
|
||||
user, loggedIn := h.trySessionAuth(r)
|
||||
|
||||
data := authorizePageData{
|
||||
Params: oauthParams,
|
||||
LoggedIn: loggedIn,
|
||||
}
|
||||
|
||||
if loggedIn {
|
||||
data.Username = user.Username
|
||||
// Fetch user's agents
|
||||
agentsList, err := h.listUserAgents(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("list user agents failed", "error", err)
|
||||
}
|
||||
data.Agents = agentsList
|
||||
if len(agentsList) == 0 {
|
||||
data.NoAgents = true
|
||||
}
|
||||
}
|
||||
|
||||
h.renderAuthorizePage(w, data)
|
||||
}
|
||||
|
||||
// HandleAuthorizePost handles POST /oauth/authorize.
|
||||
// Processes login or authorization approval.
|
||||
func (h *Handlers) HandleAuthorizePost(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid form data")
|
||||
return
|
||||
}
|
||||
|
||||
// Collect OAuth params from form (passed as hidden fields)
|
||||
oauthParams := authorizeParams{
|
||||
ResponseType: r.FormValue("response_type"),
|
||||
ClientID: r.FormValue("client_id"),
|
||||
RedirectURI: r.FormValue("redirect_uri"),
|
||||
State: r.FormValue("state"),
|
||||
Scope: r.FormValue("scope"),
|
||||
CodeChallenge: r.FormValue("code_challenge"),
|
||||
CodeChallengeMethod: r.FormValue("code_challenge_method"),
|
||||
}
|
||||
|
||||
action := r.FormValue("action")
|
||||
|
||||
switch action {
|
||||
case "login":
|
||||
h.handleAuthorizeLogin(w, r, oauthParams)
|
||||
case "authorize":
|
||||
h.handleAuthorizeApprove(w, r, oauthParams)
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Unknown action")
|
||||
}
|
||||
}
|
||||
|
||||
// handleAuthorizeLogin processes the login form submission within the authorize flow.
|
||||
func (h *Handlers) handleAuthorizeLogin(w http.ResponseWriter, r *http.Request, params authorizeParams) {
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
|
||||
user, err := h.userStore.VerifyPassword(r.Context(), username, password)
|
||||
if err != nil {
|
||||
data := authorizePageData{
|
||||
Params: params,
|
||||
LoggedIn: false,
|
||||
LoginError: "Invalid username or password",
|
||||
}
|
||||
h.renderAuthorizePage(w, data)
|
||||
return
|
||||
}
|
||||
|
||||
// Create session
|
||||
session, err := h.sessionStore.CreateSession(r.Context(), user.ID, h.config.SessionLifetime)
|
||||
if err != nil {
|
||||
h.logger.Error("create session failed during authorize", "error", err)
|
||||
data := authorizePageData{
|
||||
Params: params,
|
||||
LoggedIn: false,
|
||||
LoginError: "Server error, please try again",
|
||||
}
|
||||
h.renderAuthorizePage(w, data)
|
||||
return
|
||||
}
|
||||
|
||||
// Set session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: session.SessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: !h.config.DevMode,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(h.config.SessionLifetime.Seconds()),
|
||||
})
|
||||
|
||||
LogAuthEvent(r.Context(), h.logger, AuthEvent{
|
||||
Type: EventLoginSuccess,
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
RemoteIP: remoteIP(r),
|
||||
})
|
||||
|
||||
// Now show the agent selector
|
||||
agentsList, err := h.listUserAgents(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("list user agents failed", "error", err)
|
||||
}
|
||||
|
||||
data := authorizePageData{
|
||||
Params: params,
|
||||
LoggedIn: true,
|
||||
Username: user.Username,
|
||||
Agents: agentsList,
|
||||
NoAgents: len(agentsList) == 0,
|
||||
}
|
||||
|
||||
h.renderAuthorizePage(w, data)
|
||||
}
|
||||
|
||||
// handleAuthorizeApprove processes the "Authorize" button click.
|
||||
func (h *Handlers) handleAuthorizeApprove(w http.ResponseWriter, r *http.Request, params authorizeParams) {
|
||||
// Verify session
|
||||
user, ok := h.trySessionAuth(r)
|
||||
if !ok {
|
||||
data := authorizePageData{
|
||||
Params: params,
|
||||
LoggedIn: false,
|
||||
LoginError: "Session expired, please log in again",
|
||||
}
|
||||
h.renderAuthorizePage(w, data)
|
||||
return
|
||||
}
|
||||
|
||||
agentName := r.FormValue("agent_name")
|
||||
if agentName == "" {
|
||||
agentsList, _ := h.listUserAgents(r.Context(), user.ID)
|
||||
data := authorizePageData{
|
||||
Params: params,
|
||||
LoggedIn: true,
|
||||
Username: user.Username,
|
||||
Agents: agentsList,
|
||||
LoginError: "Please select an agent",
|
||||
}
|
||||
h.renderAuthorizePage(w, data)
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize localhost to 127.0.0.1 for fosite's loopback matching (RFC 8252)
|
||||
redirectURI := params.RedirectURI
|
||||
if parsed, err := url.Parse(redirectURI); err == nil && parsed.Hostname() == "localhost" {
|
||||
parsed.Host = "127.0.0.1:" + parsed.Port()
|
||||
if parsed.Port() == "" {
|
||||
parsed.Host = "127.0.0.1"
|
||||
}
|
||||
redirectURI = parsed.String()
|
||||
}
|
||||
|
||||
// Build a synthetic GET request with the OAuth params for fosite
|
||||
q := url.Values{
|
||||
"response_type": {params.ResponseType},
|
||||
"client_id": {params.ClientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
"state": {params.State},
|
||||
"scope": {params.Scope},
|
||||
"code_challenge": {params.CodeChallenge},
|
||||
"code_challenge_method": {params.CodeChallengeMethod},
|
||||
}
|
||||
syntheticReq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, "/oauth/authorize?"+q.Encode(), nil)
|
||||
|
||||
ar, err := h.provider.NewAuthorizeRequest(r.Context(), syntheticReq)
|
||||
if err != nil {
|
||||
h.logger.Debug("authorize request failed", "error", err)
|
||||
h.provider.WriteAuthorizeError(ctx, w, ar, err)
|
||||
h.provider.WriteAuthorizeError(r.Context(), w, ar, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is authenticated via session
|
||||
user, ok := UserFromContext(ctx)
|
||||
if !ok {
|
||||
// User needs to log in first
|
||||
// In a real implementation, this would redirect to a login page
|
||||
writeError(w, http.StatusUnauthorized, "login_required", "Please log in first")
|
||||
return
|
||||
// Grant requested scopes
|
||||
for _, scope := range ar.GetRequestedScopes() {
|
||||
ar.GrantScope(scope)
|
||||
}
|
||||
|
||||
// Create session for fosite
|
||||
sess := NewSession(user)
|
||||
response, err := h.provider.NewAuthorizeResponse(ctx, ar, sess)
|
||||
// Create fosite session with agent_name
|
||||
sess := NewSessionWithAgent(user, agentName)
|
||||
response, err := h.provider.NewAuthorizeResponse(r.Context(), ar, sess)
|
||||
if err != nil {
|
||||
h.logger.Debug("authorize response failed", "error", err)
|
||||
h.provider.WriteAuthorizeError(ctx, w, ar, err)
|
||||
h.provider.WriteAuthorizeError(r.Context(), w, ar, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.provider.WriteAuthorizeResponse(ctx, w, ar, response)
|
||||
h.provider.WriteAuthorizeResponse(r.Context(), w, ar, response)
|
||||
}
|
||||
|
||||
// trySessionAuth checks if the request has a valid session cookie.
|
||||
func (h *Handlers) trySessionAuth(r *http.Request) (*User, bool) {
|
||||
cookie, err := r.Cookie(SessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
session, err := h.sessionStore.GetSession(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
user, err := h.userStore.GetUserByID(r.Context(), session.UserID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return user, true
|
||||
}
|
||||
|
||||
// listUserAgents returns a list of agents owned by the user.
|
||||
func (h *Handlers) listUserAgents(ctx context.Context, userID int64) ([]AgentInfo, error) {
|
||||
if h.agentLister == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return h.agentLister.ListAgentsByOwner(ctx, userID)
|
||||
}
|
||||
|
||||
// authorizeParams holds the OAuth query/form parameters.
|
||||
type authorizeParams struct {
|
||||
ResponseType string
|
||||
ClientID string
|
||||
RedirectURI string
|
||||
State string
|
||||
Scope string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
}
|
||||
|
||||
// authorizePageData holds template data for the authorize page.
|
||||
type authorizePageData struct {
|
||||
Params authorizeParams
|
||||
LoggedIn bool
|
||||
Username string
|
||||
Agents []AgentInfo
|
||||
NoAgents bool
|
||||
LoginError string
|
||||
}
|
||||
|
||||
// renderAuthorizePage renders the authorize HTML template.
|
||||
func (h *Handlers) renderAuthorizePage(w http.ResponseWriter, data authorizePageData) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := authorizeTemplate.Execute(w, data); err != nil {
|
||||
h.logger.Error("render authorize template failed", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeTemplate is the HTML template for the OAuth authorize page.
|
||||
var authorizeTemplate = template.Must(template.New("authorize").Parse(authorizeTemplateHTML))
|
||||
|
||||
const authorizeTemplateHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SynapBus — Authorize</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@500;700&family=DM+Sans:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #1a1d21;
|
||||
color: #e8e8e8;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 50%, rgba(124,58,237,0.08) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 50%, rgba(54,197,240,0.06) 0%, transparent 50%);
|
||||
}
|
||||
.card {
|
||||
background: #222529;
|
||||
border: 1px solid #383b40;
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 2rem 2rem;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.03) inset;
|
||||
}
|
||||
.logo {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.logo svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 0.75rem;
|
||||
filter: drop-shadow(0 0 8px rgba(54,197,240,0.3));
|
||||
}
|
||||
.logo h1 {
|
||||
font-family: 'Instrument Sans', sans-serif;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #36c5f0, #7c3aed);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.logo p {
|
||||
font-size: 0.82rem;
|
||||
color: #9b9da0;
|
||||
margin-top: 0.3rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.error {
|
||||
background: rgba(224,30,90,0.12);
|
||||
color: #f87171;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid rgba(224,30,90,0.2);
|
||||
}
|
||||
.info {
|
||||
background: rgba(54,197,240,0.08);
|
||||
color: #7dd3fc;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid rgba(54,197,240,0.15);
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #9b9da0;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: #1a1d21;
|
||||
border: 1px solid #383b40;
|
||||
border-radius: 8px;
|
||||
color: #e8e8e8;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.92rem;
|
||||
margin-bottom: 1.1rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: #36c5f0;
|
||||
box-shadow: 0 0 0 2px rgba(54,197,240,0.15);
|
||||
}
|
||||
select option { background: #1a1d21; }
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.72rem;
|
||||
background: linear-gradient(135deg, #2eb67d, #1a9e6a);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
button:hover { opacity: 0.9; }
|
||||
button:active { transform: scale(0.99); }
|
||||
.meta {
|
||||
font-size: 0.72rem;
|
||||
color: #545760;
|
||||
margin-top: 1.25rem;
|
||||
text-align: center;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
.user-info {
|
||||
font-size: 0.88rem;
|
||||
color: #9b9da0;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: rgba(124,58,237,0.08);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(124,58,237,0.15);
|
||||
}
|
||||
.user-info strong { color: #e8e8e8; }
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #383b40;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.8">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" stroke="#36c5f0"/>
|
||||
<path d="M2 17l10 5 10-5" stroke="#7c3aed"/>
|
||||
<path d="M2 12l10 5 10-5" stroke="url(#g)"/>
|
||||
<defs><linearGradient id="g" x1="2" y1="12" x2="22" y2="17" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#36c5f0"/><stop offset="1" stop-color="#7c3aed"/>
|
||||
</linearGradient></defs>
|
||||
</svg>
|
||||
<h1>SynapBus</h1>
|
||||
<p>Agent Authorization</p>
|
||||
</div>
|
||||
|
||||
{{if .LoginError}}<div class="error">{{.LoginError}}</div>{{end}}
|
||||
|
||||
{{if not .LoggedIn}}
|
||||
<form method="POST" action="/oauth/authorize">
|
||||
<input type="hidden" name="action" value="login">
|
||||
<input type="hidden" name="response_type" value="{{.Params.ResponseType}}">
|
||||
<input type="hidden" name="client_id" value="{{.Params.ClientID}}">
|
||||
<input type="hidden" name="redirect_uri" value="{{.Params.RedirectURI}}">
|
||||
<input type="hidden" name="state" value="{{.Params.State}}">
|
||||
<input type="hidden" name="scope" value="{{.Params.Scope}}">
|
||||
<input type="hidden" name="code_challenge" value="{{.Params.CodeChallenge}}">
|
||||
<input type="hidden" name="code_challenge_method" value="{{.Params.CodeChallengeMethod}}">
|
||||
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus placeholder="Enter your username">
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required placeholder="Enter your password">
|
||||
|
||||
<button type="submit">Log In</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<div class="user-info">Logged in as <strong>{{.Username}}</strong></div>
|
||||
|
||||
{{if .NoAgents}}
|
||||
<div class="info">No agents registered yet. Create an agent in the SynapBus Web UI first, then return here to authorize.</div>
|
||||
{{else}}
|
||||
<form method="POST" action="/oauth/authorize">
|
||||
<input type="hidden" name="action" value="authorize">
|
||||
<input type="hidden" name="response_type" value="{{.Params.ResponseType}}">
|
||||
<input type="hidden" name="client_id" value="{{.Params.ClientID}}">
|
||||
<input type="hidden" name="redirect_uri" value="{{.Params.RedirectURI}}">
|
||||
<input type="hidden" name="state" value="{{.Params.State}}">
|
||||
<input type="hidden" name="scope" value="{{.Params.Scope}}">
|
||||
<input type="hidden" name="code_challenge" value="{{.Params.CodeChallenge}}">
|
||||
<input type="hidden" name="code_challenge_method" value="{{.Params.CodeChallengeMethod}}">
|
||||
|
||||
<label for="agent_name">Select Agent</label>
|
||||
<select id="agent_name" name="agent_name" required>
|
||||
{{if eq (len .Agents) 1}}
|
||||
{{range .Agents}}<option value="{{.Name}}" selected>{{.DisplayName}} ({{.Name}})</option>{{end}}
|
||||
{{else}}
|
||||
<option value="">— choose an agent —</option>
|
||||
{{range .Agents}}
|
||||
<option value="{{.Name}}">{{.DisplayName}} ({{.Name}})</option>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</select>
|
||||
|
||||
<button type="submit">Authorize</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<div class="meta">{{.Params.ClientID}}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
// HandleToken handles POST /oauth/token.
|
||||
func (h *Handlers) HandleToken(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -331,3 +332,227 @@ func TestHandlers_ChangePassword(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// T018: Test OAuth metadata endpoint returns valid JSON with required fields.
|
||||
func TestHandlers_OAuthMetadata(t *testing.T) {
|
||||
h, _ := setupHandlers(t)
|
||||
h.config.IssuerURL = "http://localhost:8080"
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.HandleOAuthMetadata(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
contentType := rr.Header().Get("Content-Type")
|
||||
if !strings.HasPrefix(contentType, "application/json") {
|
||||
t.Errorf("Content-Type = %q, want application/json", contentType)
|
||||
}
|
||||
|
||||
var metadata map[string]any
|
||||
if err := json.NewDecoder(rr.Body).Decode(&metadata); err != nil {
|
||||
t.Fatalf("decode metadata: %v", err)
|
||||
}
|
||||
|
||||
// Verify required fields
|
||||
requiredFields := []string{
|
||||
"issuer",
|
||||
"authorization_endpoint",
|
||||
"token_endpoint",
|
||||
"token_endpoint_auth_methods_supported",
|
||||
"response_types_supported",
|
||||
"grant_types_supported",
|
||||
"code_challenge_methods_supported",
|
||||
"scopes_supported",
|
||||
}
|
||||
|
||||
for _, field := range requiredFields {
|
||||
if metadata[field] == nil {
|
||||
t.Errorf("missing required field: %s", field)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify issuer matches config
|
||||
if metadata["issuer"] != "http://localhost:8080" {
|
||||
t.Errorf("issuer = %v, want http://localhost:8080", metadata["issuer"])
|
||||
}
|
||||
|
||||
// Verify endpoints contain base URL
|
||||
if authEndpoint, ok := metadata["authorization_endpoint"].(string); ok {
|
||||
if authEndpoint != "http://localhost:8080/oauth/authorize" {
|
||||
t.Errorf("authorization_endpoint = %q, want http://localhost:8080/oauth/authorize", authEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
if tokenEndpoint, ok := metadata["token_endpoint"].(string); ok {
|
||||
if tokenEndpoint != "http://localhost:8080/oauth/token" {
|
||||
t.Errorf("token_endpoint = %q, want http://localhost:8080/oauth/token", tokenEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify S256 is supported
|
||||
if methods, ok := metadata["code_challenge_methods_supported"].([]any); ok {
|
||||
found := false
|
||||
for _, m := range methods {
|
||||
if m == "S256" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("S256 should be in code_challenge_methods_supported")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify "mcp" scope is supported
|
||||
if scopes, ok := metadata["scopes_supported"].([]any); ok {
|
||||
found := false
|
||||
for _, s := range scopes {
|
||||
if s == "mcp" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("mcp should be in scopes_supported")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// T018: Test OAuth metadata with POST returns 405.
|
||||
func TestHandlers_OAuthMetadata_MethodNotAllowed(t *testing.T) {
|
||||
h, _ := setupHandlers(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/oauth-authorization-server", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.HandleOAuthMetadata(rr, req)
|
||||
|
||||
if rr.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want %d", rr.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// mockAgentLister implements AgentLister for testing.
|
||||
type mockAgentLister struct {
|
||||
agents []AgentInfo
|
||||
}
|
||||
|
||||
func (m *mockAgentLister) ListAgentsByOwner(ctx context.Context, ownerID int64) ([]AgentInfo, error) {
|
||||
return m.agents, nil
|
||||
}
|
||||
|
||||
// T019: Test authorize page renders HTML with login form when unauthenticated.
|
||||
func TestHandlers_AuthorizeGet_LoginForm(t *testing.T) {
|
||||
h, _ := setupHandlers(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?response_type=code&client_id=test-client&redirect_uri=http://localhost:3000&state=abc123&scope=mcp&code_challenge=challenge&code_challenge_method=S256", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.HandleAuthorizeGet(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
body := rr.Body.String()
|
||||
|
||||
// Content-Type header should be HTML
|
||||
ct := rr.Header().Get("Content-Type")
|
||||
if !strings.Contains(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q, want text/html", ct)
|
||||
}
|
||||
// Should contain login form elements
|
||||
if !strings.Contains(body, "<form") {
|
||||
t.Error("expected HTML form in response")
|
||||
}
|
||||
if !strings.Contains(body, "username") {
|
||||
t.Error("expected username field in login form")
|
||||
}
|
||||
if !strings.Contains(body, "password") {
|
||||
t.Error("expected password field in login form")
|
||||
}
|
||||
// Should contain hidden OAuth params
|
||||
if !strings.Contains(body, "test-client") {
|
||||
t.Error("expected client_id in hidden fields")
|
||||
}
|
||||
if !strings.Contains(body, "abc123") {
|
||||
t.Error("expected state in hidden fields")
|
||||
}
|
||||
}
|
||||
|
||||
// T019: Test authorize page renders agent selector when logged in.
|
||||
func TestHandlers_AuthorizeGet_AgentSelector(t *testing.T) {
|
||||
h, db := setupHandlers(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Set up agent lister
|
||||
h.agentLister = &mockAgentLister{
|
||||
agents: []AgentInfo{
|
||||
{Name: "my-bot", DisplayName: "My Bot", Type: "ai"},
|
||||
{Name: "my-human", DisplayName: "My Human", Type: "human"},
|
||||
},
|
||||
}
|
||||
|
||||
// Create user and session
|
||||
userStore := NewSQLiteUserStore(db, 10)
|
||||
sessStore := NewSQLiteSessionStore(db)
|
||||
user, _ := userStore.CreateUser(ctx, "authzuser", "password123", "Authz User")
|
||||
session, _ := sessStore.CreateSession(ctx, user.ID, 24*time.Hour)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?response_type=code&client_id=test-client&redirect_uri=http://localhost:3000&state=xyz&scope=mcp&code_challenge=ch&code_challenge_method=S256", nil)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: session.SessionID})
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.HandleAuthorizeGet(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
body := rr.Body.String()
|
||||
|
||||
// Should show agent selector, not login form
|
||||
if !strings.Contains(body, "agent_name") {
|
||||
t.Error("expected agent_name selector in response")
|
||||
}
|
||||
if !strings.Contains(body, "my-bot") {
|
||||
t.Error("expected agent name 'my-bot' in dropdown")
|
||||
}
|
||||
if !strings.Contains(body, "my-human") {
|
||||
t.Error("expected agent name 'my-human' in dropdown")
|
||||
}
|
||||
// Should show logged-in user
|
||||
if !strings.Contains(body, "authzuser") {
|
||||
t.Error("expected username in response")
|
||||
}
|
||||
// Should have Authorize button
|
||||
if !strings.Contains(body, "Authorize") {
|
||||
t.Error("expected Authorize button")
|
||||
}
|
||||
}
|
||||
|
||||
// T019: Test authorize page shows message when user has no agents.
|
||||
func TestHandlers_AuthorizeGet_NoAgents(t *testing.T) {
|
||||
h, db := setupHandlers(t)
|
||||
ctx := context.Background()
|
||||
|
||||
h.agentLister = &mockAgentLister{agents: []AgentInfo{}}
|
||||
|
||||
userStore := NewSQLiteUserStore(db, 10)
|
||||
sessStore := NewSQLiteSessionStore(db)
|
||||
user, _ := userStore.CreateUser(ctx, "noagentuser", "password123", "")
|
||||
session, _ := sessStore.CreateSession(ctx, user.ID, 24*time.Hour)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?response_type=code&client_id=test-client&redirect_uri=http://localhost:3000&state=xyz&scope=mcp&code_challenge=ch&code_challenge_method=S256", nil)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: session.SessionID})
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.HandleAuthorizeGet(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "No agents registered yet") {
|
||||
t.Error("expected 'No agents registered yet' message when user has no agents")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ func RequireSession(userStore UserStore, sessionStore SessionStore) func(http.Ha
|
||||
|
||||
// RequireBearer creates middleware that validates an OAuth access token.
|
||||
// If valid, it injects the user/client identity into the context.
|
||||
// When the token session includes an agent_name, it is stored in the context
|
||||
// so that MCP tool handlers can resolve the authenticated agent.
|
||||
func RequireBearer(provider fosite.OAuth2Provider, userStore UserStore) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -117,11 +119,17 @@ func RequireBearer(provider fosite.OAuth2Provider, userStore UserStore) func(htt
|
||||
ctx := r.Context()
|
||||
ctx = ContextWithClient(ctx, ar.GetClient().GetID())
|
||||
|
||||
// If the token has a user session, load the user
|
||||
if sess, ok := ar.GetSession().(*fositeSession); ok && sess.UserID > 0 {
|
||||
user, err := userStore.GetUserByID(ctx, sess.UserID)
|
||||
if err == nil {
|
||||
ctx = ContextWithUser(ctx, user)
|
||||
// If the token has a user session, load the user and extract agent_name
|
||||
if sess, ok := ar.GetSession().(*fositeSession); ok {
|
||||
if sess.UserID > 0 {
|
||||
user, err := userStore.GetUserByID(ctx, sess.UserID)
|
||||
if err == nil {
|
||||
ctx = ContextWithUser(ctx, user)
|
||||
}
|
||||
}
|
||||
// Store agent_name from OAuth session for MCP identity resolution
|
||||
if sess.AgentName != "" {
|
||||
ctx = ContextWithOAuthAgentName(ctx, sess.AgentName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +138,20 @@ func RequireBearer(provider fosite.OAuth2Provider, userStore UserStore) func(htt
|
||||
}
|
||||
}
|
||||
|
||||
// oauthAgentNameContextKey is the context key for the agent name from OAuth tokens.
|
||||
const oauthAgentNameContextKey contextKey = "oauth_agent_name"
|
||||
|
||||
// ContextWithOAuthAgentName stores the agent name from an OAuth token session.
|
||||
func ContextWithOAuthAgentName(ctx context.Context, agentName string) context.Context {
|
||||
return context.WithValue(ctx, oauthAgentNameContextKey, agentName)
|
||||
}
|
||||
|
||||
// OAuthAgentNameFromContext extracts the agent name stored by the Bearer middleware.
|
||||
func OAuthAgentNameFromContext(ctx context.Context) (string, bool) {
|
||||
name, ok := ctx.Value(oauthAgentNameContextKey).(string)
|
||||
return name, ok
|
||||
}
|
||||
|
||||
// RequireAuth creates middleware that accepts either a session cookie or bearer token.
|
||||
func RequireAuth(userStore UserStore, sessionStore SessionStore, provider fosite.OAuth2Provider) func(http.Handler) http.Handler {
|
||||
sessionMW := RequireSession(userStore, sessionStore)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
@@ -20,16 +22,23 @@ func NewOAuthProvider(cfg Config, store *FositeStore) fosite.OAuth2Provider {
|
||||
}
|
||||
|
||||
config := &fosite.Config{
|
||||
AccessTokenLifespan: cfg.AccessTokenTTL,
|
||||
RefreshTokenLifespan: cfg.RefreshTokenLifetime,
|
||||
AuthorizeCodeLifespan: 10 * time.Minute,
|
||||
GlobalSecret: secret,
|
||||
SendDebugMessagesToClients: cfg.DevMode,
|
||||
EnforcePKCE: true,
|
||||
EnforcePKCEForPublicClients: true,
|
||||
AccessTokenLifespan: cfg.AccessTokenTTL,
|
||||
RefreshTokenLifespan: cfg.RefreshTokenLifetime,
|
||||
AuthorizeCodeLifespan: 10 * time.Minute,
|
||||
GlobalSecret: secret,
|
||||
SendDebugMessagesToClients: cfg.DevMode,
|
||||
EnforcePKCE: true,
|
||||
EnforcePKCEForPublicClients: true,
|
||||
EnablePKCEPlainChallengeMethod: false,
|
||||
TokenURL: cfg.IssuerURL + "/oauth/token",
|
||||
HashCost: cfg.BcryptCost,
|
||||
TokenURL: tokenURL(cfg.IssuerURL),
|
||||
HashCost: cfg.BcryptCost,
|
||||
// Allow localhost with any port for native MCP clients (RFC 8252 Section 7.3)
|
||||
RedirectSecureChecker: func(_ context.Context, u *url.URL) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
return u.Scheme == "https" || u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"
|
||||
},
|
||||
}
|
||||
|
||||
// HMACSHAStrategy for token generation
|
||||
@@ -53,6 +62,16 @@ func NewOAuthProvider(cfg Config, store *FositeStore) fosite.OAuth2Provider {
|
||||
)
|
||||
}
|
||||
|
||||
// tokenURL returns the token endpoint URL. When issuerURL is set, it's used as the base.
|
||||
// When empty (auto-detect mode), fosite's TokenURL is set to a relative path since
|
||||
// the actual URL is exposed via OAuth metadata from the request Host header.
|
||||
func tokenURL(issuerURL string) string {
|
||||
if issuerURL != "" {
|
||||
return issuerURL + "/oauth/token"
|
||||
}
|
||||
return "/oauth/token"
|
||||
}
|
||||
|
||||
// NewSession creates a new fosite session for a user.
|
||||
func NewSession(user *User) fosite.Session {
|
||||
return &fositeSession{
|
||||
@@ -61,3 +80,13 @@ func NewSession(user *User) fosite.Session {
|
||||
Subject: user.Username,
|
||||
}
|
||||
}
|
||||
|
||||
// NewSessionWithAgent creates a new fosite session for a user with an agent identity.
|
||||
func NewSessionWithAgent(user *User, agentName string) fosite.Session {
|
||||
return &fositeSession{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Subject: user.Username,
|
||||
AgentName: agentName,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import "errors"
|
||||
|
||||
// Sentinel errors for channel operations.
|
||||
var (
|
||||
ErrChannelNotFound = errors.New("channel not found")
|
||||
ErrChannelNotFound = errors.New("channel not found")
|
||||
ErrChannelNameConflict = errors.New("channel name already exists")
|
||||
ErrNotChannelMember = errors.New("not a channel member")
|
||||
ErrNotChannelOwner = errors.New("not the channel owner")
|
||||
ErrOwnerCannotLeave = errors.New("channel owner cannot leave; transfer ownership or delete the channel first")
|
||||
ErrNotInvited = errors.New("not invited to this private channel")
|
||||
ErrInvalidChannelName = errors.New("invalid channel name")
|
||||
ErrNotChannelMember = errors.New("not a channel member")
|
||||
ErrNotChannelOwner = errors.New("not the channel owner")
|
||||
ErrOwnerCannotLeave = errors.New("channel owner cannot leave; transfer ownership or delete the channel first")
|
||||
ErrNotInvited = errors.New("not invited to this private channel")
|
||||
ErrInvalidChannelName = errors.New("invalid channel name")
|
||||
ErrSystemChannel = errors.New("cannot modify a system channel")
|
||||
)
|
||||
|
||||
@@ -48,6 +48,7 @@ func (s *Service) CreateChannel(ctx context.Context, req CreateChannelRequest) (
|
||||
Topic: req.Topic,
|
||||
Type: chType,
|
||||
IsPrivate: req.IsPrivate,
|
||||
IsSystem: req.IsSystem,
|
||||
CreatedBy: req.CreatedBy,
|
||||
}
|
||||
|
||||
@@ -147,6 +148,11 @@ func (s *Service) LeaveChannel(ctx context.Context, channelID int64, agentName s
|
||||
return err
|
||||
}
|
||||
|
||||
// System channels cannot be left
|
||||
if ch.IsSystem {
|
||||
return ErrSystemChannel
|
||||
}
|
||||
|
||||
// Check membership and role
|
||||
member, err := s.store.GetMember(ctx, channelID, agentName)
|
||||
if err != nil {
|
||||
@@ -176,6 +182,29 @@ func (s *Service) LeaveChannel(ctx context.Context, channelID int64, agentName s
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteChannel deletes a channel by ID. System channels cannot be deleted.
|
||||
func (s *Service) DeleteChannel(ctx context.Context, channelID int64) error {
|
||||
ch, err := s.store.GetChannel(ctx, channelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ch.IsSystem {
|
||||
return ErrSystemChannel
|
||||
}
|
||||
|
||||
if err := s.store.DeleteChannel(ctx, channelID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("channel deleted",
|
||||
"channel_id", channelID,
|
||||
"channel_name", ch.Name,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InviteToChannel invites an agent to a private channel. Only the owner can invite.
|
||||
func (s *Service) InviteToChannel(ctx context.Context, channelID int64, agentName, inviterAgent string) error {
|
||||
ch, err := s.store.GetChannel(ctx, channelID)
|
||||
@@ -435,3 +464,101 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
|
||||
func (s *Service) GetMembers(ctx context.Context, channelID int64) ([]*Membership, error) {
|
||||
return s.store.GetMembers(ctx, channelID)
|
||||
}
|
||||
|
||||
// MyAgentsChannelName returns the canonical my-agents channel name for a username.
|
||||
func MyAgentsChannelName(username string) string {
|
||||
return NormalizeChannelName("my-agents-" + username)
|
||||
}
|
||||
|
||||
// EnsureMyAgentsChannel creates the private system channel "my-agents-{username}"
|
||||
// if it does not already exist, and ensures the human agent is the owner.
|
||||
// This method is idempotent.
|
||||
func (s *Service) EnsureMyAgentsChannel(ctx context.Context, username string, humanAgentName string) error {
|
||||
channelName := MyAgentsChannelName(username)
|
||||
|
||||
ch, err := s.store.GetChannelByName(ctx, channelName)
|
||||
if err == nil {
|
||||
// Channel already exists — ensure human agent is a member
|
||||
isMember, err := s.store.IsMember(ctx, ch.ID, humanAgentName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check membership: %w", err)
|
||||
}
|
||||
if !isMember {
|
||||
member := &Membership{
|
||||
ChannelID: ch.ID,
|
||||
AgentName: humanAgentName,
|
||||
Role: RoleOwner,
|
||||
}
|
||||
if err := s.store.AddMember(ctx, member); err != nil {
|
||||
return fmt.Errorf("add owner to my-agents channel: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != ErrChannelNotFound {
|
||||
return fmt.Errorf("check my-agents channel: %w", err)
|
||||
}
|
||||
|
||||
// Channel does not exist — create it
|
||||
_, err = s.CreateChannel(ctx, CreateChannelRequest{
|
||||
Name: channelName,
|
||||
Description: fmt.Sprintf("Private command channel for all agents owned by %s", username),
|
||||
Type: TypeStandard,
|
||||
IsPrivate: true,
|
||||
IsSystem: true,
|
||||
CreatedBy: humanAgentName,
|
||||
})
|
||||
if err != nil {
|
||||
// Another goroutine may have created it concurrently
|
||||
if err == ErrChannelNameConflict {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("create my-agents channel: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("my-agents channel created",
|
||||
"channel", channelName,
|
||||
"owner", humanAgentName,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// JoinMyAgentsChannel adds an agent to the owner's my-agents channel.
|
||||
// If the channel does not exist, this is a no-op (best effort).
|
||||
func (s *Service) JoinMyAgentsChannel(ctx context.Context, username string, agentName string) error {
|
||||
channelName := MyAgentsChannelName(username)
|
||||
|
||||
ch, err := s.store.GetChannelByName(ctx, channelName)
|
||||
if err != nil {
|
||||
if err == ErrChannelNotFound {
|
||||
return nil // channel doesn't exist yet, no-op
|
||||
}
|
||||
return fmt.Errorf("get my-agents channel: %w", err)
|
||||
}
|
||||
|
||||
// Check if already a member (idempotent)
|
||||
isMember, err := s.store.IsMember(ctx, ch.ID, agentName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check membership: %w", err)
|
||||
}
|
||||
if isMember {
|
||||
return nil
|
||||
}
|
||||
|
||||
member := &Membership{
|
||||
ChannelID: ch.ID,
|
||||
AgentName: agentName,
|
||||
Role: RoleMember,
|
||||
}
|
||||
if err := s.store.AddMember(ctx, member); err != nil {
|
||||
return fmt.Errorf("add agent to my-agents channel: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("agent joined my-agents channel",
|
||||
"channel", channelName,
|
||||
"agent", agentName,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -634,5 +634,169 @@ func TestService_TracesRecorded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- EnsureMyAgentsChannel tests ---
|
||||
|
||||
func TestService_EnsureMyAgentsChannel_CreatesOnFirstCall(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
err := svc.EnsureMyAgentsChannel(ctx, "testowner", "agent-a")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureMyAgentsChannel: %v", err)
|
||||
}
|
||||
|
||||
ch, err := svc.GetChannelByName(ctx, "my-agents-testowner")
|
||||
if err != nil {
|
||||
t.Fatalf("GetChannelByName: %v", err)
|
||||
}
|
||||
|
||||
if ch.Name != "my-agents-testowner" {
|
||||
t.Errorf("name = %s, want my-agents-testowner", ch.Name)
|
||||
}
|
||||
if !ch.IsPrivate {
|
||||
t.Error("channel should be private")
|
||||
}
|
||||
if !ch.IsSystem {
|
||||
t.Error("channel should be system")
|
||||
}
|
||||
if ch.CreatedBy != "agent-a" {
|
||||
t.Errorf("created_by = %s, want agent-a", ch.CreatedBy)
|
||||
}
|
||||
|
||||
// Verify agent-a is the owner
|
||||
member, err := svc.store.GetMember(ctx, ch.ID, "agent-a")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMember: %v", err)
|
||||
}
|
||||
if member.Role != RoleOwner {
|
||||
t.Errorf("role = %s, want owner", member.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_EnsureMyAgentsChannel_Idempotent(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// First call creates the channel
|
||||
err := svc.EnsureMyAgentsChannel(ctx, "testowner", "agent-a")
|
||||
if err != nil {
|
||||
t.Fatalf("first EnsureMyAgentsChannel: %v", err)
|
||||
}
|
||||
|
||||
ch1, _ := svc.GetChannelByName(ctx, "my-agents-testowner")
|
||||
|
||||
// Second call should be a no-op
|
||||
err = svc.EnsureMyAgentsChannel(ctx, "testowner", "agent-a")
|
||||
if err != nil {
|
||||
t.Fatalf("second EnsureMyAgentsChannel: %v", err)
|
||||
}
|
||||
|
||||
ch2, _ := svc.GetChannelByName(ctx, "my-agents-testowner")
|
||||
|
||||
if ch1.ID != ch2.ID {
|
||||
t.Errorf("channel IDs should match: %d != %d", ch1.ID, ch2.ID)
|
||||
}
|
||||
|
||||
// Should still only have one member (the owner)
|
||||
members, err := svc.GetMembers(ctx, ch1.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMembers: %v", err)
|
||||
}
|
||||
if len(members) != 1 {
|
||||
t.Errorf("got %d members, want 1", len(members))
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_EnsureMyAgentsChannel_PrivateAndSystem(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
svc.EnsureMyAgentsChannel(ctx, "testowner", "agent-a")
|
||||
|
||||
ch, _ := svc.GetChannelByName(ctx, "my-agents-testowner")
|
||||
if !ch.IsPrivate {
|
||||
t.Error("my-agents channel should be private")
|
||||
}
|
||||
if !ch.IsSystem {
|
||||
t.Error("my-agents channel should be system")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_LeaveChannel_SystemChannelBlocked(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
svc.EnsureMyAgentsChannel(ctx, "testowner", "agent-a")
|
||||
ch, _ := svc.GetChannelByName(ctx, "my-agents-testowner")
|
||||
|
||||
// Add agent-b as a member directly
|
||||
svc.store.AddMember(ctx, &Membership{
|
||||
ChannelID: ch.ID,
|
||||
AgentName: "agent-b",
|
||||
Role: RoleMember,
|
||||
})
|
||||
|
||||
// Any member should not be able to leave a system channel
|
||||
err := svc.LeaveChannel(ctx, ch.ID, "agent-b")
|
||||
if !errors.Is(err, ErrSystemChannel) {
|
||||
t.Errorf("expected ErrSystemChannel, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_DeleteChannel_SystemChannelBlocked(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
svc.EnsureMyAgentsChannel(ctx, "testowner", "agent-a")
|
||||
ch, _ := svc.GetChannelByName(ctx, "my-agents-testowner")
|
||||
|
||||
err := svc.DeleteChannel(ctx, ch.ID)
|
||||
if !errors.Is(err, ErrSystemChannel) {
|
||||
t.Errorf("expected ErrSystemChannel, got %v", err)
|
||||
}
|
||||
|
||||
// Regular channel should be deletable
|
||||
regularCh, _ := svc.CreateChannel(ctx, CreateChannelRequest{
|
||||
Name: "deletable",
|
||||
Type: TypeStandard,
|
||||
CreatedBy: "agent-a",
|
||||
})
|
||||
err = svc.DeleteChannel(ctx, regularCh.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteChannel regular: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_JoinMyAgentsChannel(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create my-agents channel
|
||||
svc.EnsureMyAgentsChannel(ctx, "testowner", "agent-a")
|
||||
|
||||
// Join agent-b to the my-agents channel
|
||||
err := svc.JoinMyAgentsChannel(ctx, "testowner", "agent-b")
|
||||
if err != nil {
|
||||
t.Fatalf("JoinMyAgentsChannel: %v", err)
|
||||
}
|
||||
|
||||
ch, _ := svc.GetChannelByName(ctx, "my-agents-testowner")
|
||||
isMember, _ := svc.store.IsMember(ctx, ch.ID, "agent-b")
|
||||
if !isMember {
|
||||
t.Error("agent-b should be a member of my-agents channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_JoinMyAgentsChannel_NoChannelIsNoop(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// If the channel doesn't exist yet, this should be a no-op
|
||||
err := svc.JoinMyAgentsChannel(ctx, "nonexistent", "agent-a")
|
||||
if err != nil {
|
||||
t.Fatalf("JoinMyAgentsChannel should be no-op when channel missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// suppress unused import warning
|
||||
var _ = storage.RunMigrations
|
||||
|
||||
+19
-12
@@ -48,11 +48,15 @@ func (s *SQLiteChannelStore) CreateChannel(ctx context.Context, ch *Channel) err
|
||||
if ch.IsPrivate {
|
||||
isPrivate = 1
|
||||
}
|
||||
isSystem := 0
|
||||
if ch.IsSystem {
|
||||
isSystem = 1
|
||||
}
|
||||
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO channels (name, description, topic, type, is_private, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
ch.Name, ch.Description, ch.Topic, ch.Type, isPrivate, ch.CreatedBy,
|
||||
`INSERT INTO channels (name, description, topic, type, is_private, is_system, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
ch.Name, ch.Description, ch.Topic, ch.Type, isPrivate, isSystem, ch.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
@@ -74,11 +78,11 @@ func (s *SQLiteChannelStore) CreateChannel(ctx context.Context, ch *Channel) err
|
||||
// GetChannel returns a channel by ID.
|
||||
func (s *SQLiteChannelStore) GetChannel(ctx context.Context, id int64) (*Channel, error) {
|
||||
var ch Channel
|
||||
var isPrivate int
|
||||
var isPrivate, isSystem int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, description, topic, type, is_private, created_by, created_at, updated_at
|
||||
`SELECT id, name, description, topic, type, is_private, is_system, created_by, created_at, updated_at
|
||||
FROM channels WHERE id = ?`, id,
|
||||
).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &ch.CreatedBy, &ch.CreatedAt, &ch.UpdatedAt)
|
||||
).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.CreatedAt, &ch.UpdatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrChannelNotFound
|
||||
@@ -86,17 +90,18 @@ func (s *SQLiteChannelStore) GetChannel(ctx context.Context, id int64) (*Channel
|
||||
return nil, fmt.Errorf("get channel: %w", err)
|
||||
}
|
||||
ch.IsPrivate = isPrivate != 0
|
||||
ch.IsSystem = isSystem != 0
|
||||
return &ch, nil
|
||||
}
|
||||
|
||||
// GetChannelByName returns a channel by name (case-insensitive).
|
||||
func (s *SQLiteChannelStore) GetChannelByName(ctx context.Context, name string) (*Channel, error) {
|
||||
var ch Channel
|
||||
var isPrivate int
|
||||
var isPrivate, isSystem int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, description, topic, type, is_private, created_by, created_at, updated_at
|
||||
`SELECT id, name, description, topic, type, is_private, is_system, created_by, created_at, updated_at
|
||||
FROM channels WHERE LOWER(name) = LOWER(?)`, name,
|
||||
).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &ch.CreatedBy, &ch.CreatedAt, &ch.UpdatedAt)
|
||||
).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.CreatedAt, &ch.UpdatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrChannelNotFound
|
||||
@@ -104,6 +109,7 @@ func (s *SQLiteChannelStore) GetChannelByName(ctx context.Context, name string)
|
||||
return nil, fmt.Errorf("get channel by name: %w", err)
|
||||
}
|
||||
ch.IsPrivate = isPrivate != 0
|
||||
ch.IsSystem = isSystem != 0
|
||||
return &ch, nil
|
||||
}
|
||||
|
||||
@@ -111,7 +117,7 @@ func (s *SQLiteChannelStore) GetChannelByName(ctx context.Context, name string)
|
||||
// is a member or has a pending invite.
|
||||
func (s *SQLiteChannelStore) ListChannels(ctx context.Context, agentName string) ([]*Channel, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT c.id, c.name, c.description, c.topic, c.type, c.is_private, c.created_by, c.created_at, c.updated_at
|
||||
`SELECT DISTINCT c.id, c.name, c.description, c.topic, c.type, c.is_private, c.is_system, c.created_by, c.created_at, c.updated_at
|
||||
FROM channels c
|
||||
WHERE c.is_private = 0
|
||||
OR EXISTS (SELECT 1 FROM channel_members cm WHERE cm.channel_id = c.id AND cm.agent_name = ?)
|
||||
@@ -127,11 +133,12 @@ func (s *SQLiteChannelStore) ListChannels(ctx context.Context, agentName string)
|
||||
var channels []*Channel
|
||||
for rows.Next() {
|
||||
var ch Channel
|
||||
var isPrivate int
|
||||
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &ch.CreatedBy, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
|
||||
var isPrivate, isSystem int
|
||||
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan channel: %w", err)
|
||||
}
|
||||
ch.IsPrivate = isPrivate != 0
|
||||
ch.IsSystem = isSystem != 0
|
||||
channels = append(channels, &ch)
|
||||
}
|
||||
if channels == nil {
|
||||
|
||||
@@ -31,6 +31,7 @@ type Channel struct {
|
||||
Topic string `json:"topic"`
|
||||
Type string `json:"type"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -68,6 +69,7 @@ type CreateChannelRequest struct {
|
||||
Topic string `json:"topic"`
|
||||
Type string `json:"type"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package console provides pretty terminal output for the SynapBus server.
|
||||
package console
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ANSI color codes
|
||||
const (
|
||||
reset = "\033[0m"
|
||||
green = "\033[32m"
|
||||
cyan = "\033[36m"
|
||||
yellow = "\033[33m"
|
||||
gray = "\033[90m"
|
||||
bold = "\033[1m"
|
||||
red = "\033[31m"
|
||||
)
|
||||
|
||||
// Printer writes pretty-formatted console output.
|
||||
type Printer struct {
|
||||
mu sync.Mutex
|
||||
out io.Writer
|
||||
}
|
||||
|
||||
// New creates a Printer writing to stdout.
|
||||
func New() *Printer {
|
||||
return &Printer{out: os.Stdout}
|
||||
}
|
||||
|
||||
// NewWithWriter creates a Printer writing to the given writer (for testing).
|
||||
func NewWithWriter(w io.Writer) *Printer {
|
||||
return &Printer{out: w}
|
||||
}
|
||||
|
||||
// Success prints a green checkmark line: ✓ message
|
||||
func (p *Printer) Success(msg string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
fmt.Fprintf(p.out, "%s✓%s %s\n", green, reset, msg)
|
||||
}
|
||||
|
||||
// Arrow prints a cyan arrow line: → message
|
||||
func (p *Printer) Arrow(msg string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
fmt.Fprintf(p.out, "%s→%s %s\n", cyan, reset, msg)
|
||||
}
|
||||
|
||||
// Info prints a gray info line.
|
||||
func (p *Printer) Info(msg string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
fmt.Fprintf(p.out, "%s%s%s\n", gray, msg, reset)
|
||||
}
|
||||
|
||||
// Warn prints a yellow warning line.
|
||||
func (p *Printer) Warn(msg string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
fmt.Fprintf(p.out, "%s⚠ %s%s\n", yellow, msg, reset)
|
||||
}
|
||||
|
||||
// Error prints a red error line.
|
||||
func (p *Printer) Error(msg string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
fmt.Fprintf(p.out, "%s✗ %s%s\n", red, msg, reset)
|
||||
}
|
||||
|
||||
// Blank prints an empty line.
|
||||
func (p *Printer) Blank() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
fmt.Fprintln(p.out)
|
||||
}
|
||||
|
||||
// AgentConnected prints a formatted agent connection event.
|
||||
func (p *Printer) AgentConnected(name, clientName, clientVersion string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if clientName != "" {
|
||||
version := ""
|
||||
if clientVersion != "" {
|
||||
version = " " + clientVersion
|
||||
}
|
||||
fmt.Fprintf(p.out, "%s→%s Agent %s\"%s\"%s connected %s(%s%s)%s\n",
|
||||
cyan, reset, bold, name, reset, gray, clientName, version, reset)
|
||||
} else {
|
||||
fmt.Fprintf(p.out, "%s→%s Agent %s\"%s\"%s connected\n",
|
||||
cyan, reset, bold, name, reset)
|
||||
}
|
||||
}
|
||||
|
||||
// AgentDisconnected prints a formatted agent disconnection event.
|
||||
func (p *Printer) AgentDisconnected(name string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
fmt.Fprintf(p.out, "%s←%s Agent %s\"%s\"%s disconnected\n",
|
||||
yellow, reset, bold, name, reset)
|
||||
}
|
||||
|
||||
// ClientConnected prints an anonymous client connection (no agent identity).
|
||||
func (p *Printer) ClientConnected(clientName, clientVersion string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if clientName != "" {
|
||||
version := ""
|
||||
if clientVersion != "" {
|
||||
version = " " + clientVersion
|
||||
}
|
||||
fmt.Fprintf(p.out, "%s→%s Client connected %s(%s%s)%s\n",
|
||||
cyan, reset, gray, clientName, version, reset)
|
||||
} else {
|
||||
fmt.Fprintf(p.out, "%s→%s Client connected\n", cyan, reset)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSuccess(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
p := NewWithWriter(&buf)
|
||||
p.Success("SynapBus listening on :8900")
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "✓") {
|
||||
t.Errorf("expected checkmark, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "SynapBus listening on :8900") {
|
||||
t.Errorf("expected message, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrow(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
p := NewWithWriter(&buf)
|
||||
p.Arrow("something happened")
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "→") {
|
||||
t.Errorf("expected arrow, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentConnectedWithClient(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
p := NewWithWriter(&buf)
|
||||
p.AgentConnected("planner", "claude-code", "1.2.3")
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `"planner"`) {
|
||||
t.Errorf("expected agent name, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "claude-code") {
|
||||
t.Errorf("expected client name, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "1.2.3") {
|
||||
t.Errorf("expected client version, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentConnectedWithoutClient(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
p := NewWithWriter(&buf)
|
||||
p.AgentConnected("coder", "", "")
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `"coder"`) {
|
||||
t.Errorf("expected agent name, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "(") {
|
||||
t.Errorf("should not have parens without client info, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentDisconnected(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
p := NewWithWriter(&buf)
|
||||
p.AgentDisconnected("reviewer")
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "←") {
|
||||
t.Errorf("expected left arrow, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"reviewer"`) {
|
||||
t.Errorf("expected agent name, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientConnected(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
p := NewWithWriter(&buf)
|
||||
p.ClientConnected("mcp-inspector", "0.5.0")
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Client connected") {
|
||||
t.Errorf("expected client connected, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "mcp-inspector") {
|
||||
t.Errorf("expected client name, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfoAndWarn(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
p := NewWithWriter(&buf)
|
||||
p.Info("Waiting for agents...")
|
||||
p.Warn("something")
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Waiting for agents...") {
|
||||
t.Errorf("expected info message, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "⚠") {
|
||||
t.Errorf("expected warning symbol, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlank(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
p := NewWithWriter(&buf)
|
||||
p.Blank()
|
||||
if buf.String() != "\n" {
|
||||
t.Errorf("expected blank line, got: %q", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcplib "github.com/mark3labs/mcp-go/mcp"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/apikeys"
|
||||
"github.com/synapbus/synapbus/internal/console"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
)
|
||||
|
||||
func TestNewMCPServerWithConsole(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
|
||||
tracer := trace.NewTracer(db)
|
||||
t.Cleanup(func() { tracer.Close() })
|
||||
|
||||
msgStore := messaging.NewSQLiteMessageStore(db)
|
||||
msgService := messaging.NewMessagingService(msgStore, tracer)
|
||||
|
||||
agentStore := agents.NewSQLiteAgentStore(db)
|
||||
agentService := agents.NewAgentService(agentStore, tracer)
|
||||
|
||||
con := console.New()
|
||||
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, con)
|
||||
if srv == nil {
|
||||
t.Fatal("expected non-nil MCPServer")
|
||||
}
|
||||
if srv.console != con {
|
||||
t.Error("expected console printer to be set")
|
||||
}
|
||||
if srv.connMgr == nil {
|
||||
t.Error("expected connection manager to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMCPServerNilConsole(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
|
||||
tracer := trace.NewTracer(db)
|
||||
t.Cleanup(func() { tracer.Close() })
|
||||
|
||||
msgStore := messaging.NewSQLiteMessageStore(db)
|
||||
msgService := messaging.NewMessagingService(msgStore, tracer)
|
||||
|
||||
agentStore := agents.NewSQLiteAgentStore(db)
|
||||
agentService := agents.NewAgentService(agentStore, tracer)
|
||||
|
||||
// nil console should not panic
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil)
|
||||
if srv == nil {
|
||||
t.Fatal("expected non-nil MCPServer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionManagerClientInfo(t *testing.T) {
|
||||
cm := NewConnectionManager()
|
||||
|
||||
conn := &Connection{
|
||||
ID: "session-1",
|
||||
AgentName: "planner",
|
||||
Transport: "streamable-http",
|
||||
ClientName: "claude-code",
|
||||
ClientVersion: "1.2.3",
|
||||
ProtocolVersion: "2025-03-26",
|
||||
ClientCapabilities: []string{"roots", "sampling"},
|
||||
}
|
||||
cm.Add(conn)
|
||||
|
||||
got, ok := cm.Get("session-1")
|
||||
if !ok {
|
||||
t.Fatal("expected connection to be found")
|
||||
}
|
||||
if got.ClientName != "claude-code" {
|
||||
t.Errorf("expected client name 'claude-code', got %q", got.ClientName)
|
||||
}
|
||||
if got.ClientVersion != "1.2.3" {
|
||||
t.Errorf("expected client version '1.2.3', got %q", got.ClientVersion)
|
||||
}
|
||||
if got.ProtocolVersion != "2025-03-26" {
|
||||
t.Errorf("expected protocol version '2025-03-26', got %q", got.ProtocolVersion)
|
||||
}
|
||||
if len(got.ClientCapabilities) != 2 {
|
||||
t.Errorf("expected 2 capabilities, got %d", len(got.ClientCapabilities))
|
||||
}
|
||||
|
||||
cm.Remove("session-1")
|
||||
if cm.Count() != 0 {
|
||||
t.Error("expected 0 connections after remove")
|
||||
}
|
||||
}
|
||||
|
||||
// T007: Test MCP tool calls with valid API key — agent identity is correctly resolved.
|
||||
func TestMCPToolCall_WithValidAPIKey(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tracer := trace.NewTracer(db)
|
||||
t.Cleanup(func() { tracer.Close() })
|
||||
|
||||
msgStore := messaging.NewSQLiteMessageStore(db)
|
||||
msgService := messaging.NewMessagingService(msgStore, tracer)
|
||||
|
||||
agentStore := agents.NewSQLiteAgentStore(db)
|
||||
agentService := agents.NewAgentService(agentStore, tracer)
|
||||
|
||||
apiKeyStore := apikeys.NewSQLiteStore(db)
|
||||
apiKeyService := apikeys.NewService(apiKeyStore)
|
||||
|
||||
// Register an agent and get the raw API key
|
||||
_, apiKey, err := agentService.Register(ctx, "test-mcp-agent", "Test MCP Agent", "ai", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("register agent: %v", err)
|
||||
}
|
||||
|
||||
// Also register a receiver
|
||||
agentService.Register(ctx, "receiver", "Receiver", "ai", nil, 1)
|
||||
|
||||
// Create MCP server
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil)
|
||||
|
||||
// Mount with auth middleware, just like main.go does
|
||||
mux := http.NewServeMux()
|
||||
handler := agents.OptionalAuthMiddlewareWithAPIKeys(agentService, apiKeyService)(srv.Handler())
|
||||
mux.Handle("/mcp", handler)
|
||||
mux.Handle("/mcp/", handler)
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
// Send an initialize request to confirm we can connect with API key
|
||||
initPayload := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test-client","version":"0.1"}}}`
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/mcp", strings.NewReader(initPayload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("MCP request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should get 200 OK (MCP accepted the connection)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify the agent was authenticated by checking the connection manager
|
||||
// (the AfterInitialize hook would have captured the agent name)
|
||||
// The init should have succeeded — verify by checking no 401 was returned
|
||||
t.Log("MCP connection with valid API key succeeded")
|
||||
}
|
||||
|
||||
// T008: Test MCP tool calls without auth return 401 when auth is required.
|
||||
// Note: With the current OptionalAuthMiddleware, unauthenticated requests pass through
|
||||
// (returning tool-level errors). This test verifies that an invalid API key is rejected.
|
||||
func TestMCPToolCall_InvalidAPIKeyReturns401(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
|
||||
tracer := trace.NewTracer(db)
|
||||
t.Cleanup(func() { tracer.Close() })
|
||||
|
||||
msgStore := messaging.NewSQLiteMessageStore(db)
|
||||
msgService := messaging.NewMessagingService(msgStore, tracer)
|
||||
|
||||
agentStore := agents.NewSQLiteAgentStore(db)
|
||||
agentService := agents.NewAgentService(agentStore, tracer)
|
||||
|
||||
apiKeyStore := apikeys.NewSQLiteStore(db)
|
||||
apiKeyService := apikeys.NewService(apiKeyStore)
|
||||
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
handler := agents.OptionalAuthMiddlewareWithAPIKeys(agentService, apiKeyService)(srv.Handler())
|
||||
mux.Handle("/mcp", handler)
|
||||
mux.Handle("/mcp/", handler)
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
initPayload := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test-client","version":"0.1"}}}`
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/mcp", strings.NewReader(initPayload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
req.Header.Set("Authorization", "Bearer invalid-key-12345")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("MCP request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Invalid API key should be rejected with 401
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for invalid API key, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// T008 (continued): Test that unauthenticated MCP tool calls (no auth header at all)
|
||||
// are rejected at the tool handler level.
|
||||
func TestMCPToolCall_NoAuthReturnsToolError(t *testing.T) {
|
||||
tr, _, agentSvc, _ := newTestRegistrar(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Register a receiver so the send would work if auth was present
|
||||
agentSvc.Register(ctx, "receiver", "Receiver", "ai", nil, 1)
|
||||
|
||||
// Call send_message without any authenticated context
|
||||
req := makeRequest(map[string]any{
|
||||
"to": "receiver",
|
||||
"body": "should fail",
|
||||
})
|
||||
|
||||
result, err := tr.handleSendMessage(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleSendMessage returned error: %v", err)
|
||||
}
|
||||
if !result.IsError {
|
||||
t.Fatal("expected tool error for unauthenticated request")
|
||||
}
|
||||
|
||||
text := result.Content[0].(mcplib.TextContent).Text
|
||||
if !strings.Contains(text, "authentication required") {
|
||||
t.Errorf("expected 'authentication required' error, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// T009: Verify send_message enforces from_agent from the authenticated context.
|
||||
// The send_message tool does NOT expose a "from" parameter — the sender is always
|
||||
// derived from the authenticated agent identity in the context.
|
||||
func TestSendMessage_EnforcesAuthenticatedAgent(t *testing.T) {
|
||||
tr, _, agentSvc, _ := newTestRegistrar(t)
|
||||
ctx := context.Background()
|
||||
|
||||
agentSvc.Register(ctx, "real-sender", "Real Sender", "ai", nil, 1)
|
||||
agentSvc.Register(ctx, "impersonated", "Impersonated", "ai", nil, 1)
|
||||
agentSvc.Register(ctx, "receiver", "Receiver", "ai", nil, 1)
|
||||
|
||||
// Authenticate as "real-sender"
|
||||
authCtx := ContextWithAgentName(ctx, "real-sender")
|
||||
|
||||
// Try to send a message — even if someone could supply a "from" field,
|
||||
// the handler should use the authenticated agent name, not a user-supplied value.
|
||||
req := makeRequest(map[string]any{
|
||||
"to": "receiver",
|
||||
"body": "message from real sender",
|
||||
// Note: there is no "from" parameter in the send_message tool definition,
|
||||
// but even if extra args are passed, the handler ignores them.
|
||||
})
|
||||
|
||||
result, err := tr.handleSendMessage(authCtx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleSendMessage: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %v", result.Content)
|
||||
}
|
||||
|
||||
// Verify the message was sent from "real-sender" by reading receiver's inbox
|
||||
inboxCtx := ContextWithAgentName(ctx, "receiver")
|
||||
inboxReq := makeRequest(map[string]any{})
|
||||
inboxResult, _ := tr.handleReadInbox(inboxCtx, inboxReq)
|
||||
|
||||
text := inboxResult.Content[0].(mcplib.TextContent).Text
|
||||
var resp map[string]any
|
||||
json.Unmarshal([]byte(text), &resp)
|
||||
|
||||
messages := resp["messages"].([]any)
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(messages))
|
||||
}
|
||||
|
||||
msg := messages[0].(map[string]any)
|
||||
fromAgent := msg["from_agent"].(string)
|
||||
if fromAgent != "real-sender" {
|
||||
t.Errorf("message from_agent = %q, want %q — send_message must enforce authenticated agent", fromAgent, "real-sender")
|
||||
}
|
||||
}
|
||||
+3
-111
@@ -37,18 +37,17 @@ func (tr *ToolRegistrar) SetSearchService(svc *search.Service) {
|
||||
}
|
||||
|
||||
// RegisterAll registers all tools on the MCP server.
|
||||
// Note: Agent management tools (register, update, deregister) are NOT exposed via MCP.
|
||||
// Agents are managed exclusively through the Web UI. MCP is for messaging only.
|
||||
func (tr *ToolRegistrar) RegisterAll(s *server.MCPServer) {
|
||||
s.AddTool(tr.sendMessageTool(), tr.handleSendMessage)
|
||||
s.AddTool(tr.readInboxTool(), tr.handleReadInbox)
|
||||
s.AddTool(tr.claimMessagesTool(), tr.handleClaimMessages)
|
||||
s.AddTool(tr.markDoneTool(), tr.handleMarkDone)
|
||||
s.AddTool(tr.searchMessagesTool(), tr.handleSearchMessages)
|
||||
s.AddTool(tr.registerAgentTool(), tr.handleRegisterAgent)
|
||||
s.AddTool(tr.discoverAgentsTool(), tr.handleDiscoverAgents)
|
||||
s.AddTool(tr.updateAgentTool(), tr.handleUpdateAgent)
|
||||
s.AddTool(tr.deregisterAgentTool(), tr.handleDeregisterAgent)
|
||||
|
||||
tr.logger.Info("all MCP tools registered", "count", 9)
|
||||
tr.logger.Info("all MCP tools registered", "count", 6)
|
||||
}
|
||||
|
||||
// --- Tool Definitions ---
|
||||
@@ -106,16 +105,6 @@ func (tr *ToolRegistrar) searchMessagesTool() mcp.Tool {
|
||||
)
|
||||
}
|
||||
|
||||
func (tr *ToolRegistrar) registerAgentTool() mcp.Tool {
|
||||
return mcp.NewTool("register_agent",
|
||||
mcp.WithDescription("Register a new agent and receive an API key"),
|
||||
mcp.WithString("name", mcp.Description("Unique agent name"), mcp.Required()),
|
||||
mcp.WithString("display_name", mcp.Description("Human-readable display name")),
|
||||
mcp.WithString("type", mcp.Description("Agent type: 'ai' or 'human' (default 'ai')")),
|
||||
mcp.WithString("capabilities", mcp.Description("JSON capabilities object")),
|
||||
)
|
||||
}
|
||||
|
||||
func (tr *ToolRegistrar) discoverAgentsTool() mcp.Tool {
|
||||
return mcp.NewTool("discover_agents",
|
||||
mcp.WithDescription("Discover agents by capability keywords"),
|
||||
@@ -123,19 +112,6 @@ func (tr *ToolRegistrar) discoverAgentsTool() mcp.Tool {
|
||||
)
|
||||
}
|
||||
|
||||
func (tr *ToolRegistrar) updateAgentTool() mcp.Tool {
|
||||
return mcp.NewTool("update_agent",
|
||||
mcp.WithDescription("Update the authenticated agent's display name or capabilities"),
|
||||
mcp.WithString("display_name", mcp.Description("New display name")),
|
||||
mcp.WithString("capabilities", mcp.Description("New JSON capabilities object")),
|
||||
)
|
||||
}
|
||||
|
||||
func (tr *ToolRegistrar) deregisterAgentTool() mcp.Tool {
|
||||
return mcp.NewTool("deregister_agent",
|
||||
mcp.WithDescription("Deregister the authenticated agent (soft delete)"),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Tool Handlers ---
|
||||
|
||||
@@ -352,39 +328,6 @@ func (tr *ToolRegistrar) handleSearchMessages(ctx context.Context, req mcp.CallT
|
||||
})
|
||||
}
|
||||
|
||||
func (tr *ToolRegistrar) handleRegisterAgent(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
// register_agent does not require authentication
|
||||
name := req.GetString("name", "")
|
||||
if name == "" {
|
||||
return mcp.NewToolResultError("'name' parameter is required"), nil
|
||||
}
|
||||
|
||||
displayName := req.GetString("display_name", name)
|
||||
agentType := req.GetString("type", "ai")
|
||||
capsStr := req.GetString("capabilities", "{}")
|
||||
|
||||
var caps json.RawMessage
|
||||
if capsStr != "" {
|
||||
if !json.Valid([]byte(capsStr)) {
|
||||
return mcp.NewToolResultError("capabilities must be valid JSON"), nil
|
||||
}
|
||||
caps = json.RawMessage(capsStr)
|
||||
}
|
||||
|
||||
// Use owner_id=1 as default (first user). In production, this would come from auth.
|
||||
agent, apiKey, err := tr.agentService.Register(ctx, name, displayName, agentType, caps, 1)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("register_agent failed: %s", err)), nil
|
||||
}
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
"agent_id": agent.ID,
|
||||
"name": agent.Name,
|
||||
"api_key": apiKey,
|
||||
"created_at": agent.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (tr *ToolRegistrar) handleDiscoverAgents(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
agentName, ok := extractAgentName(ctx)
|
||||
if !ok {
|
||||
@@ -417,57 +360,6 @@ func (tr *ToolRegistrar) handleDiscoverAgents(ctx context.Context, req mcp.CallT
|
||||
})
|
||||
}
|
||||
|
||||
func (tr *ToolRegistrar) handleUpdateAgent(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
agentName, ok := extractAgentName(ctx)
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("authentication required"), nil
|
||||
}
|
||||
|
||||
displayName := req.GetString("display_name", "")
|
||||
capsStr := req.GetString("capabilities", "")
|
||||
|
||||
var caps json.RawMessage
|
||||
if capsStr != "" {
|
||||
if !json.Valid([]byte(capsStr)) {
|
||||
return mcp.NewToolResultError("capabilities must be valid JSON"), nil
|
||||
}
|
||||
caps = json.RawMessage(capsStr)
|
||||
}
|
||||
|
||||
agent, err := tr.agentService.UpdateAgent(ctx, agentName, displayName, caps)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("update_agent failed: %s", err)), nil
|
||||
}
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
"name": agent.Name,
|
||||
"display_name": agent.DisplayName,
|
||||
"capabilities": agent.Capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
func (tr *ToolRegistrar) handleDeregisterAgent(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
agentName, ok := extractAgentName(ctx)
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("authentication required"), nil
|
||||
}
|
||||
|
||||
// Get the agent to find owner_id
|
||||
agent, err := tr.agentService.GetAgent(ctx, agentName)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("deregister_agent failed: %s", err)), nil
|
||||
}
|
||||
|
||||
if err := tr.agentService.Deregister(ctx, agentName, agent.OwnerID); err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("deregister_agent failed: %s", err)), nil
|
||||
}
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
"name": agentName,
|
||||
"status": "deregistered",
|
||||
})
|
||||
}
|
||||
|
||||
// resultJSON marshals data to a JSON text MCP result.
|
||||
func resultJSON(data any) (*mcp.CallToolResult, error) {
|
||||
b, err := json.Marshal(data)
|
||||
|
||||
@@ -65,52 +65,6 @@ func makeRequest(args map[string]any) mcplib.CallToolRequest {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolHandler_RegisterAgent(t *testing.T) {
|
||||
tr, _, _, _ := newTestRegistrar(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("successful registration", func(t *testing.T) {
|
||||
req := makeRequest(map[string]any{
|
||||
"name": "test-agent",
|
||||
"display_name": "Test Agent",
|
||||
"type": "ai",
|
||||
})
|
||||
|
||||
result, err := tr.handleRegisterAgent(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleRegisterAgent: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %v", result.Content)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var resp map[string]any
|
||||
text := result.Content[0].(mcplib.TextContent).Text
|
||||
if err := json.Unmarshal([]byte(text), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp["api_key"] == nil || resp["api_key"] == "" {
|
||||
t.Error("expected api_key in response")
|
||||
}
|
||||
if resp["name"] != "test-agent" {
|
||||
t.Errorf("name = %v, want test-agent", resp["name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing name", func(t *testing.T) {
|
||||
req := makeRequest(map[string]any{})
|
||||
|
||||
result, err := tr.handleRegisterAgent(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleRegisterAgent: %v", err)
|
||||
}
|
||||
if !result.IsError {
|
||||
t.Error("expected error for missing name")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestToolHandler_SendMessage(t *testing.T) {
|
||||
tr, _, agentSvc, _ := newTestRegistrar(t)
|
||||
ctx := context.Background()
|
||||
@@ -339,52 +293,4 @@ func TestToolHandler_DiscoverAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolHandler_UpdateAgent(t *testing.T) {
|
||||
tr, _, agentSvc, _ := newTestRegistrar(t)
|
||||
ctx := context.Background()
|
||||
|
||||
agentSvc.Register(ctx, "update-me", "Update Me", "ai", nil, 1)
|
||||
|
||||
authCtx := ContextWithAgentName(ctx, "update-me")
|
||||
|
||||
req := makeRequest(map[string]any{
|
||||
"display_name": "Updated Name",
|
||||
"capabilities": `{"skills":["new-skill"]}`,
|
||||
})
|
||||
|
||||
result, err := tr.handleUpdateAgent(authCtx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleUpdateAgent: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %v", result.Content)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
text := result.Content[0].(mcplib.TextContent).Text
|
||||
json.Unmarshal([]byte(text), &resp)
|
||||
if resp["display_name"] != "Updated Name" {
|
||||
t.Errorf("display_name = %v, want Updated Name", resp["display_name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolHandler_DeregisterAgent(t *testing.T) {
|
||||
tr, _, agentSvc, _ := newTestRegistrar(t)
|
||||
ctx := context.Background()
|
||||
|
||||
agentSvc.Register(ctx, "bye-bot", "Bye Bot", "ai", nil, 1)
|
||||
|
||||
authCtx := ContextWithAgentName(ctx, "bye-bot")
|
||||
|
||||
req := makeRequest(map[string]any{})
|
||||
|
||||
result, err := tr.handleDeregisterAgent(authCtx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleDeregisterAgent: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %v", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = storage.RunMigrations
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DeadLetterStore provides storage operations for the dead letter queue.
|
||||
type DeadLetterStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewDeadLetterStore creates a new dead letter store.
|
||||
func NewDeadLetterStore(db *sql.DB) *DeadLetterStore {
|
||||
return &DeadLetterStore{db: db}
|
||||
}
|
||||
|
||||
// CaptureDeadLetters moves pending/processing messages for an agent to the dead_letters table.
|
||||
// Returns the number of messages captured.
|
||||
func (s *DeadLetterStore) CaptureDeadLetters(ctx context.Context, ownerID int64, agentName string) (int, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT m.id, m.from_agent, m.body, COALESCE(c.subject, ''), m.priority, m.metadata
|
||||
FROM messages m
|
||||
LEFT JOIN conversations c ON c.id = m.conversation_id
|
||||
WHERE m.to_agent = ? AND m.status IN ('pending', 'processing')`,
|
||||
agentName,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("query pending messages: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type pendingMsg struct {
|
||||
id int64
|
||||
fromAgent string
|
||||
body string
|
||||
subject string
|
||||
priority int
|
||||
metadata string
|
||||
}
|
||||
|
||||
var pending []pendingMsg
|
||||
for rows.Next() {
|
||||
var msg pendingMsg
|
||||
if err := rows.Scan(&msg.id, &msg.fromAgent, &msg.body, &msg.subject, &msg.priority, &msg.metadata); err != nil {
|
||||
return 0, fmt.Errorf("scan pending message: %w", err)
|
||||
}
|
||||
pending = append(pending, msg)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, fmt.Errorf("iterate pending messages: %w", err)
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, msg := range pending {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO dead_letters (owner_id, original_message_id, to_agent, from_agent, body, subject, priority, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
ownerID, msg.id, agentName, msg.fromAgent, msg.body, msg.subject, msg.priority, msg.metadata,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert dead letter: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return len(pending), nil
|
||||
}
|
||||
|
||||
// ListDeadLetters returns dead letters for an owner, optionally including acknowledged ones.
|
||||
// Returns the list and the total count of unacknowledged dead letters.
|
||||
func (s *DeadLetterStore) ListDeadLetters(ctx context.Context, ownerID int64, includeAcknowledged bool, limit int) ([]DeadLetter, int, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
var query string
|
||||
var args []any
|
||||
|
||||
if includeAcknowledged {
|
||||
query = `SELECT id, owner_id, original_message_id, to_agent, from_agent, body, subject, priority, metadata, acknowledged, created_at
|
||||
FROM dead_letters WHERE owner_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`
|
||||
args = []any{ownerID, limit}
|
||||
} else {
|
||||
query = `SELECT id, owner_id, original_message_id, to_agent, from_agent, body, subject, priority, metadata, acknowledged, created_at
|
||||
FROM dead_letters WHERE owner_id = ? AND acknowledged = 0
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`
|
||||
args = []any{ownerID, limit}
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("query dead letters: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var letters []DeadLetter
|
||||
for rows.Next() {
|
||||
var dl DeadLetter
|
||||
var acknowledged int
|
||||
var metadata string
|
||||
if err := rows.Scan(&dl.ID, &dl.OwnerID, &dl.OriginalMessageID, &dl.ToAgent, &dl.FromAgent,
|
||||
&dl.Body, &dl.Subject, &dl.Priority, &metadata, &acknowledged, &dl.CreatedAt); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan dead letter: %w", err)
|
||||
}
|
||||
dl.Acknowledged = acknowledged != 0
|
||||
dl.Metadata = json.RawMessage(metadata)
|
||||
letters = append(letters, dl)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("iterate dead letters: %w", err)
|
||||
}
|
||||
|
||||
if letters == nil {
|
||||
letters = []DeadLetter{}
|
||||
}
|
||||
|
||||
// Get unacknowledged count
|
||||
unackCount, err := s.CountUnacknowledged(ctx, ownerID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return letters, unackCount, nil
|
||||
}
|
||||
|
||||
// AcknowledgeDeadLetter marks a dead letter as acknowledged, verifying ownership.
|
||||
func (s *DeadLetterStore) AcknowledgeDeadLetter(ctx context.Context, id int64, ownerID int64) error {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`UPDATE dead_letters SET acknowledged = 1 WHERE id = ? AND owner_id = ?`,
|
||||
id, ownerID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acknowledge dead letter: %w", err)
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get rows affected: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("dead letter not found or not owned by user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountUnacknowledged returns the count of unacknowledged dead letters for an owner.
|
||||
func (s *DeadLetterStore) CountUnacknowledged(ctx context.Context, ownerID int64) (int, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM dead_letters WHERE owner_id = ? AND acknowledged = 0`,
|
||||
ownerID,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count unacknowledged dead letters: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
@@ -42,6 +42,21 @@ type Conversation struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DeadLetter represents an undeliverable message captured when an agent is deleted.
|
||||
type DeadLetter struct {
|
||||
ID int64 `json:"id"`
|
||||
OwnerID int64 `json:"owner_id"`
|
||||
OriginalMessageID int64 `json:"original_message_id"`
|
||||
ToAgent string `json:"to_agent"`
|
||||
FromAgent string `json:"from_agent"`
|
||||
Body string `json:"body"`
|
||||
Subject string `json:"subject"`
|
||||
Priority int `json:"priority"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
Acknowledged bool `json:"acknowledged"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// InboxState tracks per-agent, per-conversation read position.
|
||||
type InboxState struct {
|
||||
AgentName string `json:"agent_name"`
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Dead letter queue for unread messages of deleted agents
|
||||
CREATE TABLE IF NOT EXISTS dead_letters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||
original_message_id INTEGER NOT NULL,
|
||||
to_agent TEXT NOT NULL,
|
||||
from_agent TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
subject TEXT DEFAULT '',
|
||||
priority INTEGER DEFAULT 5,
|
||||
metadata TEXT DEFAULT '',
|
||||
acknowledged INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dead_letters_owner ON dead_letters(owner_id, acknowledged);
|
||||
CREATE INDEX IF NOT EXISTS idx_dead_letters_agent ON dead_letters(to_agent);
|
||||
|
||||
-- Add is_system flag to channels
|
||||
ALTER TABLE channels ADD COLUMN is_system INTEGER DEFAULT 0;
|
||||
|
||||
INSERT INTO schema_migrations (version) VALUES (8);
|
||||
Vendored
+11
-11
@@ -8,29 +8,29 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link href="/_app/immutable/entry/start.JdeQkmMs.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DLPtJI9G.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Drat7yco.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BMfnc9-7.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DRaHnfMQ.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/XQRMlXop.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/nOTKGoxu.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.CPDStlOo.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/start.DevS70lF.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/xg5IQRR7.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/ClIHNghI.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DQXcvzYS.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/C1o7UclM.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/D691nVsB.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/D273pRpB.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.4R8OzzpR.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_14joe6 = {
|
||||
__sveltekit_n5x4yc = {
|
||||
base: ""
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("/_app/immutable/entry/start.JdeQkmMs.js"),
|
||||
import("/_app/immutable/entry/app.CPDStlOo.js")
|
||||
import("/_app/immutable/entry/start.DevS70lF.js"),
|
||||
import("/_app/immutable/entry/app.4R8OzzpR.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Dead letter queue for unread messages of deleted agents
|
||||
CREATE TABLE IF NOT EXISTS dead_letters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||
original_message_id INTEGER NOT NULL,
|
||||
to_agent TEXT NOT NULL,
|
||||
from_agent TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
subject TEXT DEFAULT '',
|
||||
priority INTEGER DEFAULT 5,
|
||||
metadata TEXT DEFAULT '',
|
||||
acknowledged INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dead_letters_owner ON dead_letters(owner_id, acknowledged);
|
||||
CREATE INDEX IF NOT EXISTS idx_dead_letters_agent ON dead_letters(to_agent);
|
||||
|
||||
-- Add is_system flag to channels
|
||||
ALTER TABLE channels ADD COLUMN is_system INTEGER DEFAULT 0;
|
||||
|
||||
INSERT INTO schema_migrations (version) VALUES (8);
|
||||
@@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: MCP Auth, UX Polish & Agent Lifecycle
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-14
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for planning.
|
||||
- Assumptions section documents all reasonable defaults made where the feature description was ambiguous.
|
||||
- No [NEEDS CLARIFICATION] markers — all ambiguities resolved with documented assumptions.
|
||||
@@ -0,0 +1,148 @@
|
||||
# REST API Contracts: MCP Auth, UX Polish & Agent Lifecycle
|
||||
|
||||
**Date**: 2026-03-14
|
||||
**Feature**: 002-mcp-auth-ux-polish
|
||||
|
||||
## New Endpoints
|
||||
|
||||
### Dead Letters
|
||||
|
||||
#### GET /api/dead-letters
|
||||
|
||||
List dead letters for the authenticated user.
|
||||
|
||||
**Auth**: Session cookie (Web UI)
|
||||
|
||||
**Query Parameters**:
|
||||
| Param | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| acknowledged | boolean | false | Include acknowledged dead letters |
|
||||
| limit | integer | 50 | Max results |
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{
|
||||
"dead_letters": [
|
||||
{
|
||||
"id": 1,
|
||||
"to_agent": "my-bot",
|
||||
"from_agent": "other-agent",
|
||||
"body": "Hello, are you there?",
|
||||
"subject": "Task update",
|
||||
"priority": 5,
|
||||
"metadata": {},
|
||||
"acknowledged": false,
|
||||
"created_at": "2026-03-14T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /api/dead-letters/{id}/acknowledge
|
||||
|
||||
Mark a dead letter as acknowledged.
|
||||
|
||||
**Auth**: Session cookie (Web UI)
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{ "acknowledged": true }
|
||||
```
|
||||
|
||||
**Error** (404): Dead letter not found or not owned by user.
|
||||
|
||||
### OAuth Discovery
|
||||
|
||||
#### GET /.well-known/oauth-authorization-server
|
||||
|
||||
OAuth 2.0 Authorization Server Metadata (RFC 8414).
|
||||
|
||||
**Auth**: None
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{
|
||||
"issuer": "http://localhost:8080",
|
||||
"authorization_endpoint": "http://localhost:8080/oauth/authorize",
|
||||
"token_endpoint": "http://localhost:8080/oauth/token",
|
||||
"token_endpoint_auth_methods_supported": ["none"],
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"scopes_supported": ["mcp"]
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth Authorization Page
|
||||
|
||||
#### GET /oauth/authorize
|
||||
|
||||
Server-rendered HTML page for OAuth authorization.
|
||||
|
||||
**Query Parameters** (standard OAuth):
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| response_type | string | yes | Must be "code" |
|
||||
| client_id | string | yes | OAuth client ID |
|
||||
| redirect_uri | string | yes | Callback URL |
|
||||
| state | string | yes | CSRF state parameter |
|
||||
| code_challenge | string | yes | PKCE challenge |
|
||||
| code_challenge_method | string | yes | Must be "S256" |
|
||||
| scope | string | no | Requested scopes |
|
||||
|
||||
**Behavior**:
|
||||
1. If user not logged in → shows login form
|
||||
2. If user logged in → shows agent selector dropdown + authorize button
|
||||
3. On approve → redirects to `redirect_uri?code=...&state=...`
|
||||
|
||||
## MCP Endpoint Authentication
|
||||
|
||||
### POST /mcp (MCP Protocol)
|
||||
|
||||
**Change**: MCP now **requires** authentication. The middleware was renamed from `OptionalAuthMiddlewareWithOAuth` to `RequiredAuthMiddlewareWithOAuth`. Unauthenticated requests receive:
|
||||
|
||||
- **Status**: `401 Unauthorized`
|
||||
- **Header**: `WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-authorization-server"`
|
||||
|
||||
This directs MCP clients to the OAuth discovery endpoint for automatic authentication flow initiation.
|
||||
|
||||
**Supported auth methods** (in order of precedence):
|
||||
1. API key via `Authorization: Bearer <api_key>`
|
||||
2. OAuth 2.1 bearer token via `Authorization: Bearer <oauth_token>`
|
||||
|
||||
### MCP Tool Scope
|
||||
|
||||
Agent management tools (`register_agent`, `update_agent`, `deregister_agent`) have been **removed** from MCP. Agents are managed exclusively through the Web UI REST API.
|
||||
|
||||
MCP now exposes only 6 tools:
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `send_message` | Send a message to an agent or channel |
|
||||
| `read_inbox` | Read messages in the agent's inbox |
|
||||
| `claim_messages` | Claim pending messages for processing |
|
||||
| `mark_done` | Mark claimed messages as done |
|
||||
| `search_messages` | Semantic search across messages |
|
||||
| `discover_agents` | List available agents |
|
||||
|
||||
## Modified Endpoints
|
||||
|
||||
### POST /api/messages
|
||||
|
||||
**Change**: When request is session-authenticated (Web UI), the `from` field in the request body is ignored. The server always sets `from_agent` to the user's human agent name.
|
||||
|
||||
### DELETE /api/agents/{name}
|
||||
|
||||
**Change**: Before soft-deleting the agent, the server captures all messages with `to_agent = {name}` and `status IN ('pending', 'processing')` into the `dead_letters` table.
|
||||
|
||||
### POST /api/channels
|
||||
|
||||
**Change**: Channels with `is_system = 1` cannot be created via API (system channels are created internally only).
|
||||
|
||||
### POST /api/channels/{name}/leave
|
||||
|
||||
**Change**: Returns 403 if the channel has `is_system = 1` and the agent is the owner.
|
||||
|
||||
### DELETE /api/channels/{name} (if exists)
|
||||
|
||||
**Change**: Returns 403 if the channel has `is_system = 1`.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Data Model: MCP Auth, UX Polish & Agent Lifecycle
|
||||
|
||||
**Date**: 2026-03-14
|
||||
**Feature**: 002-mcp-auth-ux-polish
|
||||
|
||||
## Entity Changes
|
||||
|
||||
### New: Dead Letter
|
||||
|
||||
Captures unread messages for deleted agents, owned by the agent's human owner.
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|-------|------|-------------|-------------|
|
||||
| id | integer | PK, auto-increment | Unique identifier |
|
||||
| owner_id | integer | FK → users.id, NOT NULL | Owner who deleted the agent |
|
||||
| original_message_id | integer | NOT NULL | Reference to original message ID |
|
||||
| to_agent | text | NOT NULL | Name of the deleted agent (preserved as text, not FK) |
|
||||
| from_agent | text | NOT NULL | Original sender agent name |
|
||||
| body | text | NOT NULL | Message body |
|
||||
| subject | text | | Original subject |
|
||||
| priority | integer | DEFAULT 5 | Original priority (1-10) |
|
||||
| metadata | text | | Original metadata JSON |
|
||||
| acknowledged | integer | DEFAULT 0 | 0=active, 1=acknowledged |
|
||||
| created_at | datetime | DEFAULT CURRENT_TIMESTAMP | When dead letter was created |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_dead_letters_owner` on (owner_id, acknowledged)
|
||||
- `idx_dead_letters_agent` on (to_agent)
|
||||
|
||||
### Modified: Channels
|
||||
|
||||
Add `is_system` flag to prevent deletion of auto-created channels.
|
||||
|
||||
| New Field | Type | Constraints | Description |
|
||||
|-----------|------|-------------|-------------|
|
||||
| is_system | integer | DEFAULT 0 | 1=system channel (cannot be deleted/left by owner) |
|
||||
|
||||
### Modified: OAuth Session Data
|
||||
|
||||
The `session_data` JSON field in `oauth_tokens` already exists. The structure is extended to include agent identity:
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": 1,
|
||||
"username": "alice",
|
||||
"subject": "1",
|
||||
"agent_name": "alice-bot"
|
||||
}
|
||||
```
|
||||
|
||||
No schema change needed — `session_data` is already a JSON text field.
|
||||
|
||||
## State Transitions
|
||||
|
||||
### Dead Letter Lifecycle
|
||||
|
||||
```
|
||||
Message (pending/processing) → [Agent Deleted] → Dead Letter (active)
|
||||
Dead Letter (active) → [Owner Acknowledges] → Dead Letter (acknowledged)
|
||||
```
|
||||
|
||||
### OAuth Agent Selection Flow
|
||||
|
||||
```
|
||||
MCP Client (no auth) → 401 + metadata URL
|
||||
→ Browser opens authorize URL
|
||||
→ User logs in (if needed)
|
||||
→ User selects agent from dropdown
|
||||
→ Authorization code issued (with agent in session)
|
||||
→ Code exchanged for token (agent bound to token)
|
||||
→ MCP Client uses token → Authenticated as selected agent
|
||||
```
|
||||
|
||||
### My Agents Channel Lifecycle
|
||||
|
||||
```
|
||||
User registers → [Login] → my-agents-{username} channel created (if not exists)
|
||||
→ Human agent set as owner
|
||||
User creates agent → Agent auto-joins my-agents channel
|
||||
User deletes agent → Agent removed from my-agents channel (+ dead letter capture)
|
||||
```
|
||||
|
||||
## Relationships
|
||||
|
||||
```
|
||||
users (1) ──── (N) agents
|
||||
│ │
|
||||
│ ├── (N) dead_letters.to_agent (preserved name)
|
||||
│ └── (N) channel_members
|
||||
│
|
||||
├── (1) dead_letters.owner_id
|
||||
└── (1) channels (my-agents-{username}, is_system=1)
|
||||
```
|
||||
|
||||
## Migration: 008_dead_letters.sql
|
||||
|
||||
```sql
|
||||
-- Dead letter queue for unread messages of deleted agents
|
||||
CREATE TABLE IF NOT EXISTS dead_letters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||
original_message_id INTEGER NOT NULL,
|
||||
to_agent TEXT NOT NULL,
|
||||
from_agent TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
subject TEXT DEFAULT '',
|
||||
priority INTEGER DEFAULT 5,
|
||||
metadata TEXT DEFAULT '',
|
||||
acknowledged INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dead_letters_owner ON dead_letters(owner_id, acknowledged);
|
||||
CREATE INDEX IF NOT EXISTS idx_dead_letters_agent ON dead_letters(to_agent);
|
||||
|
||||
-- Add is_system flag to channels
|
||||
ALTER TABLE channels ADD COLUMN is_system INTEGER DEFAULT 0;
|
||||
```
|
||||
@@ -0,0 +1,101 @@
|
||||
# Implementation Plan: MCP Auth, UX Polish & Agent Lifecycle
|
||||
|
||||
**Branch**: `002-mcp-auth-ux-polish` | **Date**: 2026-03-14 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/002-mcp-auth-ux-polish/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Implement dual MCP authentication (API key + OAuth 2.1 fallback with agent selection), remove the "Send as" dropdown from the Web UI so humans always send as their human account, simplify agent management by removing the type selector, auto-create a "my-agents" private channel per user with auto-join on agent registration, and add a dead letter queue for unread messages when agents are deleted.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Go 1.23+
|
||||
**Primary Dependencies**: ory/fosite (OAuth 2.1), mark3labs/mcp-go (MCP server), go-chi/chi (HTTP), Svelte 5 + Tailwind (Web UI)
|
||||
**Storage**: modernc.org/sqlite (pure Go), TFMV/hnsw (vectors)
|
||||
**Testing**: `go test ./...` (Go), manual browser testing (Svelte)
|
||||
**Target Platform**: linux/amd64, darwin/arm64
|
||||
**Project Type**: Web service (single binary with embedded SPA)
|
||||
**Performance Goals**: MCP auth < 1s, OAuth flow < 30s
|
||||
**Constraints**: Zero CGO, single binary, all storage in `--data` directory
|
||||
**Scale/Scope**: Single-instance deployment, multi-tenant with ownership
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Local-First, Single Binary | PASS | All features embedded, no external dependencies added |
|
||||
| II. MCP-Native | PASS | OAuth flow serves MCP clients; REST API changes are Web UI only |
|
||||
| III. Pure Go, Zero CGO | PASS | No new C dependencies; fosite already in use |
|
||||
| IV. Multi-Tenant with Ownership | PASS | Dead letters scoped to owner; my-agents channel scoped to owner |
|
||||
| V. Embedded OAuth 2.1 | PASS | Extending existing fosite integration with agent selection |
|
||||
| VI. Semantic-Ready Storage | PASS | New tables follow SQLite-only pattern |
|
||||
| VII. Swarm Intelligence Patterns | N/A | No swarm changes |
|
||||
| VIII. Observable by Default | PASS | Agent deletion and dead letter actions will be traced |
|
||||
| IX. Progressive Complexity | PASS | Dead letters and my-agents are additive, don't break basic messaging |
|
||||
| X. Web UI as First-Class Citizen | PASS | UI improvements: remove send-as, add DLQ view, simplify agent form |
|
||||
|
||||
**Gate result**: PASS — all applicable principles satisfied.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/002-mcp-auth-ux-polish/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (via /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
# Backend (Go)
|
||||
internal/
|
||||
├── auth/
|
||||
│ ├── handlers.go # MODIFY: Add OAuth authorize page with agent selector
|
||||
│ ├── fosite_store.go # MODIFY: Store agent_name in session data
|
||||
│ └── middleware.go # MODIFY: Extract agent from OAuth token
|
||||
├── agents/
|
||||
│ ├── service.go # MODIFY: Auto-join my-agents channel, dead letter on delete
|
||||
│ └── middleware.go # MODIFY: Renamed OptionalAuthMiddlewareWithOAuth → RequiredAuthMiddlewareWithOAuth (MCP requires auth, returns 401 with WWW-Authenticate header)
|
||||
├── channels/
|
||||
│ └── service.go # MODIFY: Prevent deletion of system channels
|
||||
├── messaging/
|
||||
│ └── service.go # EXISTING: No changes needed (DLQ is a DB query)
|
||||
├── api/
|
||||
│ ├── agents_handler.go # MODIFY: Dead letter capture on DELETE
|
||||
│ ├── messages_handler.go # MODIFY: Force human agent as sender
|
||||
│ ├── channels_handler.go # MODIFY: Block system channel deletion
|
||||
│ └── deadletters_handler.go # NEW: Dead letter queue API endpoints
|
||||
├── mcp/
|
||||
│ └── server.go # MODIFY: OAuth metadata endpoint, removed agent management tools (register_agent, update_agent, deregister_agent)
|
||||
└── storage/
|
||||
└── schema/
|
||||
└── 008_dead_letters.sql # NEW: Dead letters table migration
|
||||
|
||||
# Frontend (Svelte)
|
||||
web/src/
|
||||
├── routes/
|
||||
│ ├── channels/[name]/+page.svelte # MODIFY: Remove send-as dropdown
|
||||
│ ├── dm/[name]/+page.svelte # MODIFY: Remove send-as dropdown
|
||||
│ ├── agents/+page.svelte # MODIFY: Remove type selector
|
||||
│ ├── dead-letters/+page.svelte # NEW: Dead letter queue view
|
||||
│ └── oauth/
|
||||
│ └── authorize/+page.svelte # NEW: OAuth agent selection page
|
||||
├── lib/
|
||||
│ ├── api/client.ts # MODIFY: Add dead letter API methods
|
||||
│ └── components/
|
||||
│ └── Sidebar.svelte # MODIFY: Add dead letters nav item
|
||||
```
|
||||
|
||||
**Structure Decision**: Existing Go `internal/` + Svelte `web/src/` structure. No new packages — changes distributed across existing modules with one new handler file and one new Svelte route.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations. No complexity justification needed.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Quickstart: MCP Auth, UX Polish & Agent Lifecycle
|
||||
|
||||
**Date**: 2026-03-14
|
||||
**Feature**: 002-mcp-auth-ux-polish
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.23+
|
||||
- Node.js 18+ (for Svelte build)
|
||||
- SynapBus repo checked out on branch `002-mcp-auth-ux-polish`
|
||||
|
||||
## Build & Run
|
||||
|
||||
```bash
|
||||
# Build everything
|
||||
make build
|
||||
|
||||
# Run with dev data directory
|
||||
./synapbus serve --port 8080 --data ./data-dev
|
||||
```
|
||||
|
||||
## Test the Features
|
||||
|
||||
### 1. API Key Authentication (existing, verify still works)
|
||||
|
||||
```bash
|
||||
# Register a user
|
||||
curl -X POST http://localhost:8080/auth/register \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username": "alice", "password": "password123"}'
|
||||
|
||||
# Login to get session
|
||||
curl -c cookies.txt -X POST http://localhost:8080/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username": "alice", "password": "password123"}'
|
||||
|
||||
# Register an agent (returns API key)
|
||||
curl -b cookies.txt -X POST http://localhost:8080/api/agents \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name": "my-bot", "display_name": "My Bot"}'
|
||||
# Save the api_key from response
|
||||
|
||||
# Connect MCP with API key
|
||||
# Use any MCP client with Authorization: Bearer <api_key>
|
||||
```
|
||||
|
||||
### 2. OAuth 2.1 Fallback
|
||||
|
||||
```bash
|
||||
# Check OAuth metadata
|
||||
curl http://localhost:8080/.well-known/oauth-authorization-server
|
||||
|
||||
# MCP clients without API key will be directed to:
|
||||
# http://localhost:8080/oauth/authorize?response_type=code&client_id=...&...
|
||||
# User logs in, selects agent, gets redirected with auth code
|
||||
```
|
||||
|
||||
### 3. "My Agents" Channel
|
||||
|
||||
```bash
|
||||
# After login, verify channel exists
|
||||
curl -b cookies.txt http://localhost:8080/api/channels | jq '.[] | select(.name | startswith("my-agents"))'
|
||||
|
||||
# Register a new agent and verify it auto-joined
|
||||
curl -b cookies.txt -X POST http://localhost:8080/api/agents \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name": "second-bot"}'
|
||||
|
||||
curl -b cookies.txt http://localhost:8080/api/channels/my-agents-alice | jq '.members'
|
||||
```
|
||||
|
||||
### 4. Dead Letter Queue
|
||||
|
||||
```bash
|
||||
# Send a message to an agent
|
||||
curl -b cookies.txt -X POST http://localhost:8080/api/messages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"to": "my-bot", "body": "Hello bot!"}'
|
||||
|
||||
# Delete the agent (should capture dead letters)
|
||||
curl -b cookies.txt -X DELETE http://localhost:8080/api/agents/my-bot
|
||||
|
||||
# View dead letters
|
||||
curl -b cookies.txt http://localhost:8080/api/dead-letters
|
||||
```
|
||||
|
||||
### 5. Web UI
|
||||
|
||||
Open `http://localhost:8080` in a browser:
|
||||
- Login → verify "My Agents" channel in sidebar
|
||||
- Navigate to a channel → verify no "Send as" dropdown
|
||||
- Navigate to Agents → verify no type selector in registration form
|
||||
- Navigate to Dead Letters → verify DLQ view
|
||||
- Register/delete agents → verify my-agents channel updates
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# All Go tests
|
||||
make test
|
||||
|
||||
# Specific package tests
|
||||
go test ./internal/auth/... -v
|
||||
go test ./internal/agents/... -v
|
||||
go test ./internal/channels/... -v
|
||||
go test ./internal/api/... -v
|
||||
|
||||
# Build Svelte UI
|
||||
make web
|
||||
```
|
||||
@@ -0,0 +1,81 @@
|
||||
# Research: MCP Auth, UX Polish & Agent Lifecycle
|
||||
|
||||
**Date**: 2026-03-14
|
||||
**Feature**: 002-mcp-auth-ux-polish
|
||||
|
||||
## R1: MCP OAuth 2.1 Discovery Pattern
|
||||
|
||||
**Decision**: Use RFC 8414 OAuth 2.0 Authorization Server Metadata at `/.well-known/oauth-authorization-server` endpoint. When an unauthenticated MCP client connects, the server returns 401 with a `WWW-Authenticate` header pointing to the metadata URL.
|
||||
|
||||
**Rationale**: This is the standard MCP specification approach for auth discovery. MCP clients (like Claude Code) that support OAuth will automatically detect the authorization server metadata and initiate the OAuth flow. The metadata document provides `authorization_endpoint`, `token_endpoint`, and `registration_endpoint` URLs.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Custom auth negotiation protocol — rejected, non-standard
|
||||
- HTTP 302 redirect to login — rejected, doesn't work for programmatic MCP clients
|
||||
- Server-side auth prompt — rejected, MCP transport doesn't support interactive prompts
|
||||
|
||||
## R2: OAuth Token-to-Agent Binding
|
||||
|
||||
**Decision**: Store the selected agent name in the OAuth session data (`session_data` JSON field in `oauth_tokens` table). When a bearer token is introspected, extract the agent name from session data and set it in the request context.
|
||||
|
||||
**Rationale**: Fosite's session system already supports storing arbitrary data. The `fositeSession` struct can carry the agent name alongside user_id. This avoids adding new tables and reuses the existing token introspection flow.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Separate agent-token mapping table — rejected, over-engineering
|
||||
- Token scope with agent name (e.g., `agent:mybot`) — rejected, scopes are for permissions not identity
|
||||
- Custom JWT claim — rejected, fosite handles token format internally
|
||||
|
||||
## R3: OAuth Authorization Page Implementation
|
||||
|
||||
**Decision**: Implement the authorize page as a server-rendered Go template (not a Svelte SPA route) at `/oauth/authorize`. The page includes a login form (if not logged in) and an agent selector dropdown (if logged in). This is a standard OAuth consent page pattern.
|
||||
|
||||
**Rationale**: The OAuth authorize endpoint must work independently of the SPA. It's accessed directly by the user's browser during the OAuth redirect flow. Server-rendered HTML is simpler and avoids CORS/session issues that would arise from a SPA-based consent page.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Svelte SPA route — rejected, OAuth authorize must work without the SPA loaded
|
||||
- Redirect to SPA login then back — rejected, adds unnecessary complexity and redirect chains
|
||||
- Headless/API-only consent — rejected, user must visually see and approve agent selection
|
||||
|
||||
## R4: "My Agents" Channel Auto-Creation Strategy
|
||||
|
||||
**Decision**: Lazy initialization — create the "my-agents-{username}" channel on first login (in the `withHumanAgent` handler wrapper) if it doesn't exist. For existing users, the channel is created on their next login. The channel name uses `my-agents-{username}` format internally but displays as "My Agents" in the UI via a display name.
|
||||
|
||||
**Rationale**: Lazy init avoids a migration that creates channels for all existing users (some of whom may never log in again). It's idempotent — checking "does this channel exist?" on each login is cheap.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Eager migration (create for all users) — rejected, creates channels for inactive users
|
||||
- Separate "system channels" table — rejected, over-engineering; standard channels with a `is_system` flag suffice
|
||||
- On-demand at agent registration — rejected, channel should exist before first agent is created
|
||||
|
||||
## R5: Dead Letter Queue Storage Strategy
|
||||
|
||||
**Decision**: Add a `dead_letters` table with: id, owner_id, original_message_id, to_agent (the deleted agent name), from_agent, body, subject, priority, metadata, acknowledged (boolean), created_at. When an agent is deleted, INSERT INTO dead_letters SELECT from messages WHERE to_agent = ? AND status IN ('pending', 'processing').
|
||||
|
||||
**Rationale**: A separate table is cleaner than adding status flags to the messages table. Dead letters are an owner-level concept (not agent-level), and they need to survive agent deletion. The original message remains in the messages table but can be garbage-collected later.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Status flag on messages table (status = 'dead_letter') — rejected, messages.to_agent references the agent which is being deleted; keeping messages in the same table creates referential integrity issues
|
||||
- Soft-copy into a JSON blob — rejected, loses queryability
|
||||
- View-based approach (query messages for deleted agents) — rejected, requires knowing which agents were deleted and when
|
||||
|
||||
## R6: System Channel Protection
|
||||
|
||||
**Decision**: Add an `is_system` boolean column to the channels table. System channels cannot be deleted or left by the owner. The channel service checks this flag before allowing delete/leave operations.
|
||||
|
||||
**Rationale**: Simple boolean flag is more flexible than hardcoding channel name patterns. Can be used for future system channels beyond "my-agents".
|
||||
|
||||
**Alternatives considered**:
|
||||
- Hardcode "my-agents-*" pattern check — rejected, not extensible
|
||||
- Separate system_channels table — rejected, over-engineering
|
||||
- Permission-based (remove delete permission) — rejected, permissions don't exist yet as a first-class concept for channels
|
||||
|
||||
## R7: Web UI Human-Only Messaging
|
||||
|
||||
**Decision**: Modify the message send API handler to always override the `from` field with the logged-in user's human agent name when the request comes from a session-authenticated context (Web UI). Remove the "Send as" dropdown from channel and DM page components.
|
||||
|
||||
**Rationale**: Server-side enforcement ensures security — even if the UI is bypassed, the API won't allow impersonation. Client-side removal of the dropdown is a UX simplification.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Client-side only enforcement — rejected, insecure
|
||||
- Remove the `from` field entirely from API — rejected, MCP tools still need it
|
||||
- Per-user setting to enable/disable send-as — rejected, user explicitly said to remove it
|
||||
@@ -0,0 +1,195 @@
|
||||
# Feature Specification: MCP Auth, UX Polish & Agent Lifecycle
|
||||
|
||||
**Feature Branch**: `002-mcp-auth-ux-polish`
|
||||
**Created**: 2026-03-14
|
||||
**Status**: Draft
|
||||
**Input**: MCP OAuth auto-switch, remove send-as, my-agents channel, dead letter queue
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - MCP Agent Connection with API Key (Priority: P1)
|
||||
|
||||
An AI agent (e.g., Claude Code) connects to SynapBus via the MCP protocol by providing an API key in the Authorization header. MCP **requires** authentication — unauthenticated requests receive a `401 Unauthorized` response with a `WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-authorization-server"` header, directing clients to the OAuth discovery endpoint. With a valid API key, the connection is authenticated immediately and the agent can send/receive messages without any browser interaction.
|
||||
|
||||
**Why this priority**: This is the primary agent connection method. Without reliable API key auth, no agent can interact with SynapBus.
|
||||
|
||||
**Independent Test**: Can be tested by connecting an MCP client with a valid API key header and verifying the agent identity is correctly resolved and tools are accessible. Also test that connecting without any credentials returns 401 with the WWW-Authenticate header.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an agent with a valid API key, **When** it connects to the MCP endpoint with `Authorization: Bearer <api_key>`, **Then** it is authenticated as that agent and can invoke MCP tools.
|
||||
2. **Given** an agent with an invalid or revoked API key, **When** it connects to the MCP endpoint, **Then** it receives a 401 Unauthorized response and cannot invoke tools.
|
||||
3. **Given** an MCP client with no credentials, **When** it connects to the MCP endpoint, **Then** it receives a 401 Unauthorized response with `WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-authorization-server"` header.
|
||||
4. **Given** an agent with a valid API key, **When** it sends a message, **Then** the message `from_agent` is set to the authenticated agent's name (not choosable).
|
||||
|
||||
**Note**: Agent management (register, update, deregister) is handled exclusively through the Web UI. MCP exposes only 6 messaging/discovery tools: `send_message`, `read_inbox`, `claim_messages`, `mark_done`, `search_messages`, `discover_agents`.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - MCP Connection with OAuth 2.1 Fallback (Priority: P1)
|
||||
|
||||
When an MCP client connects without an API key, the server responds with `401 Unauthorized` and a `WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-authorization-server"` header. The MCP client uses this to discover the OAuth 2.1 authorization endpoint and initiates an Authorization Code flow with PKCE. The user sees a browser login form, authenticates with username/password, then selects which of their registered agents the MCP client should act as. After successful authorization, the MCP client receives a bearer token scoped to the selected agent.
|
||||
|
||||
**Why this priority**: This is the second supported connection method and enables MCP clients that don't support static API keys to authenticate interactively.
|
||||
|
||||
**Independent Test**: Can be tested by connecting an MCP client without an API key, verifying the 401 response with WWW-Authenticate header, following the OAuth discovery and authorization flow, completing the browser-based login, selecting an agent, and confirming the MCP client receives a working token.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an MCP client connecting without any credentials, **When** the connection is initiated, **Then** the system returns 401 with a `WWW-Authenticate` header pointing to the OAuth authorization server metadata endpoint.
|
||||
2. **Given** the user opens the authorization URL, **When** they are not logged in, **Then** they see a login form requesting username and password.
|
||||
3. **Given** the user has logged in successfully, **When** they reach the authorization page, **Then** they see a dropdown listing all their registered agents and can select which agent the MCP client should act as.
|
||||
4. **Given** the user selects an agent and approves authorization, **When** the OAuth flow completes, **Then** the MCP client receives an access token scoped to the selected agent.
|
||||
5. **Given** a valid OAuth token, **When** the MCP client uses it for subsequent requests, **Then** requests are authenticated as the selected agent.
|
||||
6. **Given** a user with no registered agents, **When** they reach the authorization page, **Then** they see a message indicating they need to register an agent first (with a link to the agents page).
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Human Users Always Send as Their Human Account (Priority: P2)
|
||||
|
||||
When a human user is logged into the Web UI, all messages they send (in channels or DMs) are sent from their human account. There is no "Send as" agent selector dropdown. The identity is fixed to the logged-in user's human agent.
|
||||
|
||||
**Why this priority**: Simplifies the UI and prevents confusion about message authorship. Humans should always be identifiable as humans in conversations.
|
||||
|
||||
**Independent Test**: Can be tested by logging into the Web UI, navigating to any channel or DM, and verifying there is no agent selection dropdown and messages are attributed to the human account.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a logged-in user viewing a channel, **When** they compose and send a message, **Then** it is sent from their human agent account (no agent selection dropdown is shown).
|
||||
2. **Given** a logged-in user viewing a DM conversation, **When** they compose and send a message, **Then** it is sent from their human agent account.
|
||||
3. **Given** a message sent by a human in a channel, **When** other users view it, **Then** it shows the human's display name with a "Human" badge.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Simplified Agent Management (Priority: P2)
|
||||
|
||||
In the "Manage Agents" section of the Web UI, users create agent accounts without specifying a type. All agents created through the UI are AI agents by default. The agent type selector is removed from the registration form. In message displays and channel member lists, AI and Human badges remain visible to distinguish account types.
|
||||
|
||||
**Why this priority**: Reduces unnecessary complexity in the agent creation flow. Human accounts are auto-created on login; users only need to register AI agents.
|
||||
|
||||
**Independent Test**: Can be tested by navigating to the agents management page, registering a new agent, and verifying no type selector exists. Then checking that messages and channel lists still show AI/Human badges.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user on the agent registration page, **When** they fill out the form, **Then** there is no "type" field and the created agent is automatically of type "ai".
|
||||
2. **Given** existing messages from AI and human agents, **When** viewed in the channel or DM message list, **Then** AI agents show a purple "AI" badge and human agents show a blue "Human" badge.
|
||||
3. **Given** a channel member list, **When** a user views it, **Then** each member shows the appropriate AI or Human badge.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - "My Agents" Channel (Priority: P2)
|
||||
|
||||
Every user has a pre-existing private channel called "my-agents" that is automatically created when the user registers. When a user creates a new AI agent, that agent is automatically added to this channel. The purpose is to allow the user to broadcast commands to all their AI agents at once.
|
||||
|
||||
**Why this priority**: Enables efficient multi-agent coordination. Without this, users would need to message each agent individually.
|
||||
|
||||
**Independent Test**: Can be tested by registering a new user, verifying the "my-agents" channel exists, creating a new agent, and confirming the agent appears in the channel member list. Then send a message and verify all agents receive it.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a new user registers, **When** their account is created, **Then** a private channel named "my-agents" is automatically created with the user's human agent as owner.
|
||||
2. **Given** a user creates a new AI agent, **When** the agent is registered, **Then** the agent is automatically added as a member of the user's "my-agents" channel.
|
||||
3. **Given** a user sends a message in their "my-agents" channel, **When** the message is delivered, **Then** all their registered agents receive the message.
|
||||
4. **Given** a user with multiple agents, **When** they view the "my-agents" channel, **Then** they see all their agents listed as members.
|
||||
5. **Given** a user's "my-agents" channel, **When** any user (including other users) views the channel list, **Then** only the owner can see their own "my-agents" channel (it is private).
|
||||
|
||||
---
|
||||
|
||||
### User Story 6 - Dead Letter Queue for Deleted Agents (Priority: P3)
|
||||
|
||||
When a user deletes an AI agent, any unread messages addressed to that agent are moved to a "dead letter queue" visible to the agent's owner. This prevents message loss when agents are deregistered.
|
||||
|
||||
**Why this priority**: Data safety feature. Without it, deleting an agent silently discards unread messages, which could contain important information.
|
||||
|
||||
**Independent Test**: Can be tested by sending messages to an agent, deleting the agent without reading those messages, and verifying the unread messages appear in the owner's dead letter queue view.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an agent with unread messages, **When** the owner deletes that agent, **Then** all unread (pending/processing) messages for that agent are marked as dead letters.
|
||||
2. **Given** dead letter messages exist, **When** the owner views the dead letter queue in the Web UI, **Then** they see a list of unread messages that were addressed to their deleted agents, including the original sender, body, timestamp, and which agent they were addressed to.
|
||||
3. **Given** a dead letter message, **When** the owner reviews it, **Then** they can mark it as acknowledged (removing it from the active queue).
|
||||
4. **Given** an agent with no unread messages, **When** the owner deletes that agent, **Then** no dead letters are created.
|
||||
5. **Given** an agent that has already read/processed all messages, **When** the owner deletes that agent, **Then** no dead letters are created (only truly unread messages become dead letters).
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when a user tries to delete the "my-agents" channel? The system prevents deletion of this system-created channel.
|
||||
- What happens when an OAuth token expires during an active MCP session? The client must re-authenticate using the refresh token or restart the OAuth flow.
|
||||
- What happens if the same MCP client authenticates via OAuth but the selected agent is subsequently deleted? The MCP session becomes invalid and the client receives an authentication error on the next tool call.
|
||||
- What happens if a user has no agents when connecting via OAuth? They see a message instructing them to register an agent first.
|
||||
- What happens when viewing dead letters for a long-deleted agent? The dead letter record preserves the agent name for display even after the agent record is deactivated.
|
||||
- What happens when a message is mid-processing (claimed) when the agent is deleted? Messages in "processing" status are also captured as dead letters since they were never completed.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
#### MCP Authentication
|
||||
|
||||
- **FR-001**: System MUST authenticate MCP connections that include a valid agent API key in the Authorization Bearer header.
|
||||
- **FR-001a**: System MUST reject unauthenticated MCP requests with 401 Unauthorized and a `WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-authorization-server"` header.
|
||||
- **FR-001b**: Agent management tools (register, update, deregister) MUST NOT be exposed via MCP. Agent management is exclusively through the Web UI. MCP exposes only messaging and discovery tools: `send_message`, `read_inbox`, `claim_messages`, `mark_done`, `search_messages`, `discover_agents`.
|
||||
- **FR-002**: System MUST support OAuth 2.1 Authorization Code flow with PKCE for MCP clients that receive the 401/WWW-Authenticate challenge.
|
||||
- **FR-003**: The OAuth authorization page MUST present a login form if the user is not already authenticated.
|
||||
- **FR-004**: The OAuth authorization page MUST display a dropdown of the user's registered agents for selection after successful login.
|
||||
- **FR-005**: OAuth tokens issued MUST be scoped to the selected agent so that MCP tool calls operate as that agent.
|
||||
- **FR-006**: The system MUST support token refresh so long-lived MCP sessions can maintain authentication without re-prompting the user.
|
||||
|
||||
#### Web UI - Messaging Identity
|
||||
|
||||
- **FR-007**: The Web UI MUST NOT display an agent selection dropdown ("Send as") in channel or DM views.
|
||||
- **FR-008**: All messages sent from the Web UI MUST use the logged-in user's human agent as the sender.
|
||||
|
||||
#### Web UI - Agent Management
|
||||
|
||||
- **FR-009**: The agent registration form MUST NOT include an agent type selector field.
|
||||
- **FR-010**: All agents created via the Web UI MUST be assigned type "ai" automatically.
|
||||
- **FR-011**: AI and Human badges MUST remain visible in message lists, channel member lists, DM lists, and the sidebar.
|
||||
|
||||
#### My Agents Channel
|
||||
|
||||
- **FR-012**: System MUST automatically create a private "my-agents" channel for each user upon registration.
|
||||
- **FR-013**: The user's human agent MUST be the owner of their "my-agents" channel.
|
||||
- **FR-014**: When a new agent is registered by a user, the system MUST automatically add that agent to the user's "my-agents" channel.
|
||||
- **FR-015**: The "my-agents" channel MUST NOT be deletable or leavable by the owner.
|
||||
- **FR-016**: The "my-agents" channel MUST only be visible to its owner in channel listings.
|
||||
|
||||
#### Dead Letter Queue
|
||||
|
||||
- **FR-017**: When an agent is deleted, all messages with status "pending" or "processing" addressed to that agent MUST be captured as dead letters.
|
||||
- **FR-018**: Dead letters MUST be accessible to the deleted agent's owner via the Web UI.
|
||||
- **FR-019**: Each dead letter MUST preserve: original sender, message body, timestamp, subject, priority, and the name of the deleted agent it was addressed to.
|
||||
- **FR-020**: Users MUST be able to acknowledge individual dead letters, removing them from the active queue.
|
||||
- **FR-021**: Dead letters MUST be accessible via a dedicated section in the Web UI navigation.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Dead Letter**: A preserved copy of an undelivered message, linked to the owner who deleted the receiving agent. Contains original message data plus the deleted agent name and acknowledgment status.
|
||||
- **My Agents Channel**: A system-created private channel per user that aggregates all the user's agents. Cannot be deleted. Auto-populated on agent creation.
|
||||
- **OAuth Agent Selection**: During OAuth authorization, the binding between an OAuth token and a specific agent identity.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Agents connecting via API key are authenticated and operational within 1 second of connection.
|
||||
- **SC-002**: The OAuth 2.1 fallback flow completes (from redirect to token issuance) in under 30 seconds for a user who is already logged in.
|
||||
- **SC-003**: 100% of messages sent from the Web UI are attributed to the logged-in user's human account with no option to impersonate agents.
|
||||
- **SC-004**: Agent registration in the Web UI requires only name and optional display name — no type selection.
|
||||
- **SC-005**: When a user registers, their "my-agents" channel exists immediately and is visible in the sidebar.
|
||||
- **SC-006**: New agents appear in the "my-agents" channel within 1 second of registration.
|
||||
- **SC-007**: 100% of unread messages for a deleted agent are captured in the dead letter queue with no data loss.
|
||||
- **SC-008**: Dead letter queue is accessible within 2 clicks from the main navigation.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **OAuth flow trigger**: When no valid credentials are present, the MCP endpoint returns 401 with a `WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-authorization-server"` header so MCP clients can discover the authorization endpoint. This follows the MCP specification's auth discovery pattern.
|
||||
- **Agent selection scope**: During OAuth, only active (non-deactivated) agents owned by the authenticated user are shown in the dropdown.
|
||||
- **"my-agents" channel naming**: The channel uses a per-user unique name format `my-agents-{username}` internally but displays as "My Agents" in the UI.
|
||||
- **Dead letter retention**: Dead letters are retained indefinitely until acknowledged by the owner. No automatic expiry.
|
||||
- **Existing users**: When this feature is deployed, existing users will have their "my-agents" channels created on their next login (lazy initialization).
|
||||
- **Human agents**: Continue to be auto-created on first login as they are today. The "my-agents" channel includes the human agent as owner.
|
||||
- **OAuth client registration**: MCP clients that use OAuth are treated as public clients (no client secret) using PKCE S256. A default OAuth client is auto-registered for MCP connections.
|
||||
- **Thread panel**: The thread/reply panel continues to send replies as the human agent, consistent with FR-008.
|
||||
@@ -0,0 +1,235 @@
|
||||
# Tasks: MCP Auth, UX Polish & Agent Lifecycle
|
||||
|
||||
**Input**: Design documents from `/specs/002-mcp-auth-ux-polish/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Database migration and shared schema changes needed by multiple stories
|
||||
|
||||
- [x] T001 Create migration file `schema/008_dead_letters.sql` with dead_letters table and channels.is_system column per data-model.md
|
||||
- [x] T002 Register migration 008 in storage initialization at `internal/storage/sqlite.go`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Backend service changes that multiple user stories depend on
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
- [x] T003 Add `is_system` field to Channel struct in `internal/channels/types.go`
|
||||
- [x] T004 Modify channel store to persist and load `is_system` flag in `internal/channels/store.go`
|
||||
- [x] T005 [P] Add dead letter store with Create, List, Acknowledge methods in `internal/messaging/deadletter_store.go`
|
||||
- [x] T006 [P] Add dead letter types (DeadLetter struct, ListOptions) in `internal/messaging/types.go`
|
||||
|
||||
**Checkpoint**: Foundation ready — schema migrated, dead letter store available, is_system flag available
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — MCP Agent Connection with API Key (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Verify API key auth works correctly for MCP connections, agent identity is enforced
|
||||
|
||||
**Independent Test**: Connect MCP client with valid/invalid API key, verify auth and tool access
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T007 [US1] Add test in `internal/mcp/server_test.go` verifying MCP tool calls with valid API key resolve correct agent identity
|
||||
- [x] T008 [US1] Add test in `internal/mcp/server_test.go` verifying MCP tool calls without auth return 401
|
||||
- [x] T009 [US1] Verify `send_message` MCP tool enforces `from_agent` from authenticated agent (not user-supplied) in `internal/mcp/server.go`
|
||||
|
||||
**Checkpoint**: API key MCP auth verified and enforced
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — MCP Connection with OAuth 2.1 Fallback (Priority: P1)
|
||||
|
||||
**Goal**: Unauthenticated MCP clients get redirected to OAuth flow with agent selection
|
||||
|
||||
**Independent Test**: Connect without API key, complete OAuth in browser with agent selection, use resulting token
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T010 [US2] Add `/.well-known/oauth-authorization-server` metadata endpoint in `internal/auth/handlers.go`
|
||||
- [x] T011 [US2] Create OAuth authorize HTML template with login form + agent selector dropdown in `internal/auth/handlers.go` (inlined template)
|
||||
- [x] T012 [US2] Implement GET `/oauth/authorize` handler serving the HTML page with agent list in `internal/auth/handlers.go`
|
||||
- [x] T013 [US2] Implement POST `/oauth/authorize` handler processing login + agent selection + code issuance in `internal/auth/handlers.go`
|
||||
- [x] T014 [US2] Modify fosite session to store `agent_name` in session_data in `internal/auth/fosite_store.go`
|
||||
- [x] T015 [US2] Add `RequireBearer` middleware to extract agent_name from OAuth token session and set agent context in `internal/auth/middleware.go`
|
||||
- [x] T016 [US2] Register a default MCP OAuth client on server startup (public client, PKCE S256) in `cmd/synapbus/main.go`
|
||||
- [x] T017 [US2] Wire OAuth metadata + authorize endpoints and bearer middleware on `/mcp` route in `cmd/synapbus/main.go`
|
||||
- [x] T018 [US2] Add test for OAuth metadata endpoint in `internal/auth/handlers_test.go`
|
||||
- [x] T019 [US2] Add test for authorize page rendering with agent list in `internal/auth/handlers_test.go`
|
||||
|
||||
**Checkpoint**: OAuth 2.1 fallback flow functional — MCP clients without API key can authenticate via browser
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Human Users Always Send as Human Account (Priority: P2)
|
||||
|
||||
**Goal**: Remove "Send as" dropdown, force human agent identity for all Web UI messages
|
||||
|
||||
**Independent Test**: Log into Web UI, send message in channel/DM, verify it's from human account
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T020 [US3] Modify POST `/api/messages` handler to override `from` with user's human agent when session-authenticated in `internal/api/messages_handler.go`
|
||||
- [x] T021 [US3] Add helper `GetHumanAgentForUser` to agent service in `internal/agents/service.go`
|
||||
- [x] T022 [P] [US3] Remove "Send as" dropdown from channel page in `web/src/routes/channels/[name]/+page.svelte`
|
||||
- [x] T023 [P] [US3] Remove "Send as" dropdown from DM page in `web/src/routes/dm/[name]/+page.svelte`
|
||||
- [x] T024 [US3] Update ThreadPanel reply to use human agent (remove agent selection logic) in `web/src/lib/components/ThreadPanel.svelte`
|
||||
- [x] T025 [US3] Add test verifying session-auth messages always use human agent in `internal/api/messages_handler_test.go`
|
||||
|
||||
**Checkpoint**: Web UI enforces human-only sending — no agent impersonation possible
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — Simplified Agent Management (Priority: P2)
|
||||
|
||||
**Goal**: Remove type selector from agent registration, keep AI/Human badges in displays
|
||||
|
||||
**Independent Test**: Register agent via UI, verify no type field and agent is type "ai"
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [x] T026 [P] [US4] Remove agent type selector from registration form in `web/src/routes/agents/+page.svelte`
|
||||
- [x] T027 [P] [US4] Hardcode type="ai" in agent registration API call in `web/src/routes/agents/+page.svelte`
|
||||
- [x] T028 [US4] Verify AI/Human badges still display correctly in `web/src/lib/components/AgentCard.svelte` (no changes needed, just verify)
|
||||
- [x] T029 [US4] Verify AI/Human badges display in `web/src/lib/components/Sidebar.svelte` DM list (no changes needed, just verify)
|
||||
- [x] T030 [US4] Verify AI/Human badges display in `web/src/lib/components/MessageList.svelte` (add badge if missing)
|
||||
|
||||
**Checkpoint**: Agent registration simplified — badges preserved throughout UI
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 — "My Agents" Channel (Priority: P2)
|
||||
|
||||
**Goal**: Auto-create private "my-agents" channel per user, auto-join on agent registration
|
||||
|
||||
**Independent Test**: Register user, verify channel exists, create agent, verify auto-joined
|
||||
|
||||
### Implementation for User Story 5
|
||||
|
||||
- [x] T031 [US5] Add `EnsureMyAgentsChannel` method to channel service in `internal/channels/service.go` — creates private `my-agents-{username}` channel with `is_system=1` if not exists
|
||||
- [x] T032 [US5] Call `EnsureMyAgentsChannel` during login flow (after EnsureHumanAgent) in `cmd/synapbus/main.go`
|
||||
- [x] T033 [US5] Auto-join newly registered agent to owner's my-agents channel in `internal/api/agents_handler.go`
|
||||
- [x] T034 [US5] Prevent deletion/leave of system channels in `internal/channels/service.go` LeaveChannel and any delete logic
|
||||
- [x] T035 [US5] Filter `my-agents-*` channels to only show owner's own in channel listing API (already handled by private channel filtering)
|
||||
- [x] T036 [US5] Display "My Agents" as channel display name for `my-agents-*` channels in `web/src/lib/components/Sidebar.svelte`
|
||||
- [x] T037 [US5] Add test for EnsureMyAgentsChannel creation and idempotency in `internal/channels/service_test.go`
|
||||
- [x] T038 [US5] Add test for auto-join on agent registration in `internal/agents/service_test.go`
|
||||
|
||||
**Checkpoint**: My-agents channel auto-creates and auto-populates — broadcast to all agents works
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: User Story 6 — Dead Letter Queue (Priority: P3)
|
||||
|
||||
**Goal**: Capture unread messages on agent deletion, display in owner's DLQ view
|
||||
|
||||
**Independent Test**: Send message to agent, delete agent, verify dead letters visible
|
||||
|
||||
### Implementation for User Story 6
|
||||
|
||||
- [x] T039 [US6] Add dead letter capture logic to agent deregistration in `internal/agents/service.go` Deregister method — query pending/processing messages, insert into dead_letters
|
||||
- [x] T040 [US6] Create dead letters REST handler with List and Acknowledge endpoints in `internal/api/deadletters_handler.go`
|
||||
- [x] T041 [US6] Register dead letter API routes in `internal/api/router.go`
|
||||
- [x] T042 [US6] Add dead letter API client methods (list, acknowledge) in `web/src/lib/api/client.ts`
|
||||
- [x] T043 [US6] Create dead letters page in `web/src/routes/dead-letters/+page.svelte` — list with acknowledge buttons
|
||||
- [x] T044 [US6] Add "Dead Letters" navigation item in `web/src/lib/components/Sidebar.svelte` with unacknowledged count badge
|
||||
- [x] T045 [US6] Add test for dead letter capture on agent deletion in `internal/agents/service_test.go`
|
||||
- [x] T046 [US6] Add test for dead letter list/acknowledge API in `internal/api/deadletters_handler_test.go`
|
||||
|
||||
**Checkpoint**: Dead letter queue captures unread messages, UI shows them, owner can acknowledge
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Build verification and final integration
|
||||
|
||||
- [x] T047 [P] Build Svelte SPA with `make web`
|
||||
- [x] T048 [P] Run `make test` to verify no regressions
|
||||
- [x] T049 Run `make build` to verify single binary compiles
|
||||
- [x] T050 Run quickstart.md validation — test all features end-to-end
|
||||
- [x] T051 Write `autonomous_summary.md` with completion status
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Phase 1 (migration must exist)
|
||||
- **US1 (Phase 3)**: Depends on Phase 2 — independent of other stories
|
||||
- **US2 (Phase 4)**: Depends on Phase 2 — independent of other stories
|
||||
- **US3 (Phase 5)**: Depends on Phase 2 — independent of other stories
|
||||
- **US4 (Phase 6)**: Depends on Phase 2 — independent, frontend-only
|
||||
- **US5 (Phase 7)**: Depends on Phase 2 (is_system flag) — independent
|
||||
- **US6 (Phase 8)**: Depends on Phase 2 (dead letter store) — independent
|
||||
- **Polish (Phase 9)**: Depends on all user stories
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: No cross-story dependencies
|
||||
- **US2 (P1)**: No cross-story dependencies (uses existing OAuth infrastructure)
|
||||
- **US3 (P2)**: No cross-story dependencies
|
||||
- **US4 (P2)**: No cross-story dependencies (frontend-only)
|
||||
- **US5 (P2)**: No cross-story dependencies
|
||||
- **US6 (P3)**: No cross-story dependencies
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Models/types before services
|
||||
- Services before API handlers
|
||||
- Backend before frontend
|
||||
- Core implementation before tests (tests validate the implementation)
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T005 + T006 (dead letter store + types)
|
||||
- T022 + T023 (remove send-as from channel + DM pages)
|
||||
- T026 + T027 (agent form changes)
|
||||
- T047 + T048 (web build + go test)
|
||||
- All user stories (Phases 3-8) can run in parallel after Phase 2
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Stories 1 + 2 Only)
|
||||
|
||||
1. Complete Phase 1: Setup (migration)
|
||||
2. Complete Phase 2: Foundational (types, stores)
|
||||
3. Complete Phase 3: US1 — API key auth verified
|
||||
4. Complete Phase 4: US2 — OAuth fallback working
|
||||
5. **STOP and VALIDATE**: Both MCP auth paths work
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Foundational → Foundation ready
|
||||
2. US1 + US2 → MCP auth complete (MVP!)
|
||||
3. US3 + US4 → UI polish (send-as removed, agent form simplified)
|
||||
4. US5 → My-agents channel operational
|
||||
5. US6 → Dead letter queue complete
|
||||
6. Polish → Full verification
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- Each user story is independently completable and testable
|
||||
- Total tasks: 51 — ALL COMPLETED ✅
|
||||
- Tasks per story: US1=3, US2=10, US3=6, US4=5, US5=8, US6=8, Setup=2, Foundation=4, Polish=5
|
||||
@@ -111,6 +111,20 @@ export const channels = {
|
||||
}
|
||||
};
|
||||
|
||||
// Dead Letters
|
||||
export const deadLetters = {
|
||||
list: (opts?: { acknowledged?: boolean; limit?: number }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts?.acknowledged) qs.set('acknowledged', 'true');
|
||||
if (opts?.limit) qs.set('limit', String(opts.limit));
|
||||
const q = qs.toString();
|
||||
return request<{ dead_letters: any[]; total: number }>('GET', `/api/dead-letters${q ? '?' + q : ''}`);
|
||||
},
|
||||
acknowledge: (id: number) =>
|
||||
request<{ acknowledged: boolean }>('POST', `/api/dead-letters/${id}/acknowledge`),
|
||||
count: () => request<{ count: number }>('GET', '/api/dead-letters/count')
|
||||
};
|
||||
|
||||
// API Keys
|
||||
export const apiKeys = {
|
||||
list: () => request<{ keys: any[] }>('GET', '/api/keys'),
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
reply_count?: number;
|
||||
};
|
||||
|
||||
let { messages = [], showConversationLink = false }: { messages: Message[]; showConversationLink?: boolean } = $props();
|
||||
let { messages = [], showConversationLink = false, agentTypes = {} }: { messages: Message[]; showConversationLink?: boolean; agentTypes?: Record<string, string> } = $props();
|
||||
|
||||
let hoveredId = $state<number | null>(null);
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
function handleClick(msg: Message) {
|
||||
if (msg.conversation_id) {
|
||||
openThread(msg.id, msg.conversation_id);
|
||||
openThread(msg.id, msg.conversation_id, msg.from_agent);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -84,6 +84,11 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="font-semibold text-sm text-text-primary hover:underline">{msg.from_agent}</span>
|
||||
{#if agentTypes[msg.from_agent] === 'ai'}
|
||||
<span class="text-[9px] font-mono text-accent-purple bg-accent-purple/10 px-1 rounded">AI</span>
|
||||
{:else if agentTypes[msg.from_agent] === 'human'}
|
||||
<span class="text-[9px] font-mono text-accent-blue bg-accent-blue/10 px-1 rounded">Human</span>
|
||||
{/if}
|
||||
{#if msg.to_agent}
|
||||
<svg class="w-3 h-3 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
@@ -106,7 +111,7 @@
|
||||
{#if showConversationLink && msg.conversation_id}
|
||||
<button
|
||||
class="mt-1 flex items-center gap-1 text-xs text-text-link hover:underline"
|
||||
onclick={(e) => { e.stopPropagation(); openThread(msg.id, msg.conversation_id); }}
|
||||
onclick={(e) => { e.stopPropagation(); openThread(msg.id, msg.conversation_id, msg.from_agent); }}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
@@ -123,7 +128,7 @@
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-bg-tertiary text-text-secondary hover:text-text-primary transition-colors"
|
||||
title="Reply in thread"
|
||||
onclick={(e) => { e.stopPropagation(); openThread(msg.id, msg.conversation_id); }}
|
||||
onclick={(e) => { e.stopPropagation(); openThread(msg.id, msg.conversation_id, msg.from_agent); }}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { user, logout } from '$lib/stores/auth';
|
||||
import { channels as channelsApi, agents as agentsApi } from '$lib/api/client';
|
||||
import { channels as channelsApi, agents as agentsApi, deadLetters as deadLettersApi } from '$lib/api/client';
|
||||
|
||||
let channelList = $state<any[]>([]);
|
||||
let agentList = $state<any[]>([]);
|
||||
let deadLetterCount = $state(0);
|
||||
|
||||
let channelsExpanded = $state(true);
|
||||
let dmsExpanded = $state(true);
|
||||
@@ -21,12 +22,14 @@
|
||||
|
||||
async function loadSidebarData() {
|
||||
try {
|
||||
const [chRes, agRes] = await Promise.all([
|
||||
const [chRes, agRes, dlRes] = await Promise.all([
|
||||
channelsApi.list(),
|
||||
agentsApi.list()
|
||||
agentsApi.list(),
|
||||
deadLettersApi.count().catch(() => ({ count: 0 }))
|
||||
]);
|
||||
channelList = chRes.channels ?? [];
|
||||
agentList = agRes.agents ?? [];
|
||||
deadLetterCount = dlRes.count ?? 0;
|
||||
} catch {
|
||||
// handled
|
||||
}
|
||||
@@ -153,9 +156,16 @@
|
||||
href="/channels/{ch.name}"
|
||||
class="sidebar-item {isActive('/channels/' + ch.name) ? 'sidebar-item-active' : ''}"
|
||||
>
|
||||
<span class="text-text-secondary font-mono text-xs">#</span>
|
||||
<span class="truncate">{ch.name}</span>
|
||||
{#if ch.is_private}
|
||||
{#if ch.name.startsWith('my-agents-')}
|
||||
<svg class="w-4 h-4 flex-shrink-0 text-accent-purple" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="truncate">My Agents</span>
|
||||
{:else}
|
||||
<span class="text-text-secondary font-mono text-xs">#</span>
|
||||
<span class="truncate">{ch.name}</span>
|
||||
{/if}
|
||||
{#if ch.is_private && !ch.name.startsWith('my-agents-')}
|
||||
<svg class="w-3 h-3 text-text-secondary ml-auto flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
@@ -243,6 +253,18 @@
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
<a
|
||||
href="/dead-letters"
|
||||
class="sidebar-item {isActive('/dead-letters') ? 'sidebar-item-active' : ''}"
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z" />
|
||||
</svg>
|
||||
Dead Letters
|
||||
{#if deadLetterCount > 0}
|
||||
<span class="ml-auto text-[10px] font-bold text-white bg-accent-red px-1.5 py-0.5 rounded-full min-w-[18px] text-center flex-shrink-0">{deadLetterCount}</span>
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,15 @@
|
||||
let replyBody = $state('');
|
||||
let sending = $state(false);
|
||||
let error = $state('');
|
||||
let loadError = $state('');
|
||||
|
||||
let currentThread = $state<{ messageId: number; conversationId: number } | null>(null);
|
||||
let currentThread = $state<{ messageId: number; conversationId: number; fromAgent?: string } | null>(null);
|
||||
|
||||
activeThread.subscribe((val) => {
|
||||
currentThread = val;
|
||||
loadError = '';
|
||||
error = '';
|
||||
replyBody = '';
|
||||
if (val) {
|
||||
loadThread(val.conversationId);
|
||||
} else {
|
||||
@@ -23,12 +27,13 @@
|
||||
|
||||
async function loadThread(conversationId: number) {
|
||||
loadingThread = true;
|
||||
loadError = '';
|
||||
try {
|
||||
const res = await convsApi.get(conversationId);
|
||||
conversation = res.conversation;
|
||||
threadMessages = res.messages;
|
||||
} catch {
|
||||
// handled
|
||||
} catch (err: any) {
|
||||
loadError = err.message || 'Could not load thread';
|
||||
} finally {
|
||||
loadingThread = false;
|
||||
}
|
||||
@@ -36,15 +41,25 @@
|
||||
|
||||
async function sendReply(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!replyBody.trim() || threadMessages.length === 0 || !currentThread) return;
|
||||
if (!replyBody.trim() || !currentThread) return;
|
||||
|
||||
// Determine recipient: use last message's sender, or fallback to stored fromAgent
|
||||
const recipient = threadMessages.length > 0
|
||||
? threadMessages[threadMessages.length - 1].from_agent
|
||||
: currentThread.fromAgent;
|
||||
|
||||
if (!recipient) {
|
||||
error = 'Cannot determine recipient';
|
||||
return;
|
||||
}
|
||||
|
||||
sending = true;
|
||||
error = '';
|
||||
try {
|
||||
const lastMsg = threadMessages[threadMessages.length - 1];
|
||||
await messagesApi.send({
|
||||
to: lastMsg.from_agent,
|
||||
to: recipient,
|
||||
body: replyBody.trim(),
|
||||
reply_to: currentThread.messageId,
|
||||
subject: conversation?.subject
|
||||
});
|
||||
replyBody = '';
|
||||
@@ -93,6 +108,8 @@
|
||||
<h3 class="font-display font-bold text-sm text-text-primary truncate">Thread</h3>
|
||||
{#if conversation}
|
||||
<p class="text-[10px] text-text-secondary truncate">{conversation.subject || 'Conversation #' + conversation.id}</p>
|
||||
{:else if currentThread.fromAgent}
|
||||
<p class="text-[10px] text-text-secondary truncate">Reply to {currentThread.fromAgent}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
@@ -120,6 +137,15 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<div class="p-4 text-center text-text-secondary text-xs">
|
||||
<p>Thread history unavailable</p>
|
||||
<p class="mt-1 text-[10px]">You can still send a reply below</p>
|
||||
</div>
|
||||
{:else if threadMessages.length === 0}
|
||||
<div class="p-4 text-center text-text-secondary text-xs">
|
||||
No messages in this thread yet
|
||||
</div>
|
||||
{:else}
|
||||
{#each threadMessages as msg, i (msg.id)}
|
||||
<div class="px-4 py-3 hover:bg-bg-tertiary/50 transition-colors {i === 0 ? 'border-b border-border bg-bg-primary/30' : ''}">
|
||||
@@ -131,7 +157,9 @@
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="font-semibold text-xs text-text-primary">{msg.from_agent}</span>
|
||||
<span class="text-[10px] text-text-secondary">{formatTime(msg.created_at)}</span>
|
||||
<span class="{statusClass(msg.status)} text-[10px]">{msg.status}</span>
|
||||
{#if msg.status !== 'done'}
|
||||
<span class="{statusClass(msg.status)} text-[10px]">{msg.status}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-text-primary/90 whitespace-pre-wrap leading-relaxed">{msg.body}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const activeThread = writable<{ messageId: number; conversationId: number } | null>(null);
|
||||
export const activeThread = writable<{ messageId: number; conversationId: number; fromAgent?: string } | null>(null);
|
||||
|
||||
export function openThread(messageId: number, conversationId: number) {
|
||||
activeThread.set({ messageId, conversationId });
|
||||
export function openThread(messageId: number, conversationId: number, fromAgent?: string) {
|
||||
activeThread.set({ messageId, conversationId, fromAgent });
|
||||
}
|
||||
|
||||
export function closeThread() {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
let recentMessages = $state<any[]>([]);
|
||||
let recentConversations = $state<any[]>([]);
|
||||
let agentCount = $state(0);
|
||||
let agentTypeMap = $state<Record<string, string>>({});
|
||||
let loadingData = $state(true);
|
||||
let showCompose = $state(false);
|
||||
|
||||
@@ -20,6 +21,11 @@
|
||||
recentMessages = msgRes.messages;
|
||||
recentConversations = convRes.conversations;
|
||||
agentCount = agentRes.agents.length;
|
||||
const typeMap: Record<string, string> = {};
|
||||
for (const agent of agentRes.agents) {
|
||||
typeMap[agent.name] = agent.type;
|
||||
}
|
||||
agentTypeMap = typeMap;
|
||||
} catch {
|
||||
// Errors handled by API client
|
||||
} finally {
|
||||
@@ -131,7 +137,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<MessageList messages={recentMessages} showConversationLink={true} />
|
||||
<MessageList messages={recentMessages} showConversationLink={true} agentTypes={agentTypeMap} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
let newName = $state('');
|
||||
let newDisplayName = $state('');
|
||||
let newType = 'ai'; // agents are always AI; human accounts created via CLI
|
||||
let registering = $state(false);
|
||||
let registerError = $state('');
|
||||
let newApiKey = $state('');
|
||||
@@ -19,7 +18,7 @@
|
||||
loadingData = true;
|
||||
try {
|
||||
const res = await agentsApi.list();
|
||||
agentList = res.agents;
|
||||
agentList = (res.agents || []).filter((a: any) => a.type !== 'human');
|
||||
} catch {
|
||||
// handled
|
||||
} finally {
|
||||
@@ -48,7 +47,7 @@
|
||||
const res = await agentsApi.register({
|
||||
name: newName.trim(),
|
||||
display_name: newDisplayName.trim() || undefined,
|
||||
type: newType
|
||||
type: 'ai'
|
||||
});
|
||||
newApiKey = res.api_key;
|
||||
await loadAgents();
|
||||
@@ -64,7 +63,6 @@
|
||||
newApiKey = '';
|
||||
newName = '';
|
||||
newDisplayName = '';
|
||||
newType = 'ai';
|
||||
registerError = '';
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
try {
|
||||
const res = await agentsApi.get(agentName);
|
||||
agent = res.agent;
|
||||
// Human agents cannot be managed via this page
|
||||
if (agent?.type === 'human') {
|
||||
goto('/agents');
|
||||
return;
|
||||
}
|
||||
traces = res.traces;
|
||||
// Populate access rights from capabilities
|
||||
const caps = agent.capabilities || {};
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
let body = $state('');
|
||||
let sending = $state(false);
|
||||
let sendError = $state('');
|
||||
let selectedAgent = $state<any>(null);
|
||||
|
||||
let channelName = $derived($page.params.name);
|
||||
|
||||
@@ -33,9 +32,6 @@
|
||||
channel = chRes.channel;
|
||||
members = chRes.members;
|
||||
agentList = agRes.agents ?? [];
|
||||
if (agentList.length > 0 && !selectedAgent) {
|
||||
selectedAgent = agentList[0];
|
||||
}
|
||||
await loadMessages();
|
||||
} catch {
|
||||
// handled
|
||||
@@ -102,12 +98,11 @@
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!body.trim() || !selectedAgent) return;
|
||||
if (!body.trim()) return;
|
||||
sending = true;
|
||||
sendError = '';
|
||||
try {
|
||||
await messagesApi.send({
|
||||
from: selectedAgent.name,
|
||||
body: body.trim(),
|
||||
channel_id: channel.id
|
||||
});
|
||||
@@ -127,6 +122,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
function agentType(name: string): string | null {
|
||||
const agent = agentList.find(a => a.name === name);
|
||||
return agent?.type ?? null;
|
||||
}
|
||||
|
||||
function agentColor(name: string): string {
|
||||
const colors = ['bg-accent-blue', 'bg-accent-green', 'bg-accent-purple', 'bg-accent-yellow', 'bg-accent-red'];
|
||||
let hash = 0;
|
||||
@@ -221,13 +221,18 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="font-semibold text-sm text-text-primary">{msg.from_agent}</span>
|
||||
{#if agentType(msg.from_agent) === 'ai'}
|
||||
<span class="text-[9px] font-mono text-accent-purple bg-accent-purple/10 px-1 rounded">AI</span>
|
||||
{:else if agentType(msg.from_agent) === 'human'}
|
||||
<span class="text-[9px] font-mono text-accent-blue bg-accent-blue/10 px-1 rounded">Human</span>
|
||||
{/if}
|
||||
<span class="text-xs text-text-secondary">{formatTime(msg.created_at)}</span>
|
||||
</div>
|
||||
<p class="text-sm text-text-primary/90 leading-relaxed whitespace-pre-wrap">{msg.body}</p>
|
||||
{#if msg.reply_count > 0}
|
||||
<button
|
||||
class="mt-1 flex items-center gap-1 text-xs text-accent-blue hover:underline"
|
||||
onclick={() => openThread(msg.id, msg.conversation_id)}
|
||||
onclick={() => openThread(msg.id, msg.conversation_id, msg.from_agent)}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
|
||||
@@ -240,7 +245,7 @@
|
||||
<button
|
||||
class="absolute top-1.5 right-3 p-1 rounded hover:bg-bg-tertiary text-text-secondary hover:text-text-primary opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Reply in thread"
|
||||
onclick={() => openThread(msg.id, msg.conversation_id)}
|
||||
onclick={() => openThread(msg.id, msg.conversation_id, msg.from_agent)}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
|
||||
@@ -282,24 +287,6 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1 px-1">
|
||||
<p class="text-[10px] text-text-secondary">Sending as <span class="font-mono">{selectedAgent?.display_name || selectedAgent?.name}</span></p>
|
||||
{#if agentList.length > 1}
|
||||
<select
|
||||
class="text-[10px] bg-bg-tertiary border border-border rounded px-1 py-0.5 text-text-secondary outline-none"
|
||||
onchange={(e) => {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
selectedAgent = agentList.find(a => a.name === target.value) ?? agentList[0];
|
||||
}}
|
||||
>
|
||||
{#each agentList as agent}
|
||||
<option value={agent.name} selected={agent.name === selectedAgent?.name}>
|
||||
{agent.display_name || agent.name}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import { deadLetters as deadLettersApi } from '$lib/api/client';
|
||||
|
||||
let letterList = $state<any[]>([]);
|
||||
let loadingData = $state(true);
|
||||
let showAcknowledged = $state(false);
|
||||
let totalUnacknowledged = $state(0);
|
||||
|
||||
let _initialized = $state(false);
|
||||
$effect(() => {
|
||||
if (!_initialized) {
|
||||
_initialized = true;
|
||||
loadDeadLetters();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadDeadLetters() {
|
||||
loadingData = true;
|
||||
try {
|
||||
const res = await deadLettersApi.list({ acknowledged: showAcknowledged, limit: 100 });
|
||||
letterList = res.dead_letters;
|
||||
totalUnacknowledged = res.total;
|
||||
} catch {
|
||||
// handled
|
||||
} finally {
|
||||
loadingData = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledge(id: number) {
|
||||
try {
|
||||
await deadLettersApi.acknowledge(id);
|
||||
// Remove from list or mark as acknowledged
|
||||
letterList = letterList.map((dl) =>
|
||||
dl.id === id ? { ...dl, acknowledged: true } : dl
|
||||
);
|
||||
if (!showAcknowledged) {
|
||||
letterList = letterList.filter((dl) => !dl.acknowledged);
|
||||
}
|
||||
totalUnacknowledged = Math.max(0, totalUnacknowledged - 1);
|
||||
} catch {
|
||||
// handled
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShowAcknowledged() {
|
||||
showAcknowledged = !showAcknowledged;
|
||||
loadDeadLetters();
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
||||
function truncateBody(body: string, maxLen = 120): string {
|
||||
if (body.length <= maxLen) return body;
|
||||
return body.slice(0, maxLen) + '...';
|
||||
}
|
||||
|
||||
function priorityLabel(p: number): string {
|
||||
if (p >= 8) return 'Urgent';
|
||||
if (p >= 5) return 'High';
|
||||
if (p >= 3) return 'Normal';
|
||||
return 'Low';
|
||||
}
|
||||
|
||||
function priorityColor(p: number): string {
|
||||
if (p >= 8) return 'text-accent-red';
|
||||
if (p >= 5) return 'text-accent-orange';
|
||||
return 'text-text-secondary';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-5 max-w-5xl">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-xl font-bold text-text-primary font-display">Dead Letters</h1>
|
||||
{#if totalUnacknowledged > 0}
|
||||
<span class="badge bg-accent-red/20 text-accent-red">{totalUnacknowledged} unacknowledged</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="text-xs px-3 py-1.5 rounded border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
onclick={toggleShowAcknowledged}
|
||||
>
|
||||
{showAcknowledged ? 'Hide acknowledged' : 'Show acknowledged'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
<h2 class="font-semibold text-sm text-text-primary font-display">
|
||||
{showAcknowledged ? 'All Dead Letters' : 'Unacknowledged Dead Letters'}
|
||||
</h2>
|
||||
<p class="text-xs text-text-secondary mt-0.5">
|
||||
Messages that could not be delivered because the recipient agent was deleted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if loadingData}
|
||||
<div class="p-5 space-y-4">
|
||||
{#each Array(3) as _}
|
||||
<div class="space-y-2">
|
||||
<div class="skeleton h-3 w-1/3"></div>
|
||||
<div class="skeleton h-3 w-2/3"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if letterList.length === 0}
|
||||
<div class="p-8 text-center">
|
||||
<svg class="w-12 h-12 mx-auto mb-3 text-text-secondary/40" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z" />
|
||||
</svg>
|
||||
<p class="text-text-secondary text-sm">No dead letters.</p>
|
||||
<p class="text-text-secondary/60 text-xs mt-1">Messages appear here when an agent is deleted with pending messages.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-border">
|
||||
{#each letterList as dl (dl.id)}
|
||||
<div class="px-5 py-3 {dl.acknowledged ? 'opacity-50' : ''}">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span class="font-semibold text-sm text-text-primary">{dl.from_agent}</span>
|
||||
<svg class="w-3 h-3 text-text-secondary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
<span class="font-semibold text-sm text-accent-red/80 line-through">{dl.to_agent}</span>
|
||||
<span class="text-[9px] text-accent-red bg-accent-red/10 px-1 rounded">deleted</span>
|
||||
{#if dl.priority > 0}
|
||||
<span class="text-[10px] {priorityColor(dl.priority)}">{priorityLabel(dl.priority)}</span>
|
||||
{/if}
|
||||
<span class="text-xs text-text-secondary ml-auto flex-shrink-0">{formatTime(dl.created_at)}</span>
|
||||
</div>
|
||||
{#if dl.subject}
|
||||
<p class="text-xs font-medium text-text-secondary mb-0.5">{dl.subject}</p>
|
||||
{/if}
|
||||
<p class="text-sm text-text-primary/80">{truncateBody(dl.body)}</p>
|
||||
</div>
|
||||
{#if !dl.acknowledged}
|
||||
<button
|
||||
class="btn-primary text-xs flex-shrink-0 px-3 py-1.5"
|
||||
onclick={() => acknowledge(dl.id)}
|
||||
>
|
||||
Acknowledge
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-[10px] text-text-secondary bg-bg-tertiary px-2 py-1 rounded flex-shrink-0">Acknowledged</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,7 +13,6 @@
|
||||
let body = $state('');
|
||||
let sending = $state(false);
|
||||
let sendError = $state('');
|
||||
let selectedAgent = $state<any>(null);
|
||||
|
||||
let messagesContainer: HTMLDivElement;
|
||||
|
||||
@@ -26,9 +25,6 @@
|
||||
]);
|
||||
ownAgents = agRes.agents ?? [];
|
||||
peer = peerRes?.agent ?? { name: peerAgent };
|
||||
if (ownAgents.length > 0 && !selectedAgent) {
|
||||
selectedAgent = ownAgents[0];
|
||||
}
|
||||
await loadMessages();
|
||||
} catch {
|
||||
// handled
|
||||
@@ -65,12 +61,11 @@
|
||||
});
|
||||
|
||||
async function handleSend() {
|
||||
if (!body.trim() || !selectedAgent) return;
|
||||
if (!body.trim()) return;
|
||||
sending = true;
|
||||
sendError = '';
|
||||
try {
|
||||
await messagesApi.send({
|
||||
from: selectedAgent.name,
|
||||
to: peerAgent,
|
||||
body: body.trim()
|
||||
});
|
||||
@@ -90,6 +85,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
function agentType(name: string): string | null {
|
||||
// Check own agents first
|
||||
const ownAgent = ownAgents.find(a => a.name === name);
|
||||
if (ownAgent) return ownAgent.type;
|
||||
// Check peer agent
|
||||
if (peer && peer.name === name && peer.type) return peer.type;
|
||||
return null;
|
||||
}
|
||||
|
||||
function agentColor(name: string): string {
|
||||
const colors = ['bg-accent-blue', 'bg-accent-green', 'bg-accent-purple', 'bg-accent-yellow', 'bg-accent-red'];
|
||||
let hash = 0;
|
||||
@@ -185,6 +189,11 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="font-semibold text-sm text-text-primary">{msg.from_agent}</span>
|
||||
{#if agentType(msg.from_agent) === 'ai'}
|
||||
<span class="text-[9px] font-mono text-accent-purple bg-accent-purple/10 px-1 rounded">AI</span>
|
||||
{:else if agentType(msg.from_agent) === 'human'}
|
||||
<span class="text-[9px] font-mono text-accent-blue bg-accent-blue/10 px-1 rounded">Human</span>
|
||||
{/if}
|
||||
{#if msg.to_agent}
|
||||
<svg class="w-3 h-3 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
@@ -200,7 +209,7 @@
|
||||
{#if msg.reply_count > 0}
|
||||
<button
|
||||
class="mt-1 flex items-center gap-1 text-xs text-accent-blue hover:underline"
|
||||
onclick={() => openThread(msg.id, msg.conversation_id)}
|
||||
onclick={() => openThread(msg.id, msg.conversation_id, msg.from_agent)}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
|
||||
@@ -213,7 +222,7 @@
|
||||
<button
|
||||
class="absolute top-1.5 right-3 p-1 rounded hover:bg-bg-tertiary text-text-secondary hover:text-text-primary opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Reply in thread"
|
||||
onclick={() => openThread(msg.id, msg.conversation_id)}
|
||||
onclick={() => openThread(msg.id, msg.conversation_id, msg.from_agent)}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
|
||||
@@ -254,24 +263,6 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1 px-1">
|
||||
<p class="text-[10px] text-text-secondary">Sending as <span class="font-mono">{selectedAgent?.display_name || selectedAgent?.name}</span></p>
|
||||
{#if ownAgents.length > 1}
|
||||
<select
|
||||
class="text-[10px] bg-bg-tertiary border border-border rounded px-1 py-0.5 text-text-secondary outline-none"
|
||||
onchange={(e) => {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
selectedAgent = ownAgents.find(a => a.name === target.value) ?? ownAgents[0];
|
||||
}}
|
||||
>
|
||||
{#each ownAgents as agent}
|
||||
<option value={agent.name} selected={agent.name === selectedAgent?.name}>
|
||||
{agent.display_name || agent.name}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user