summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-28 22:19:28 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-28 22:19:28 -1000
commit05b1930e04ac222d73ffb2f45c1b1febb69f893d (patch)
treebc451d72b5265ff044c4655ed90685c601688b6d
parent058ff7d699f088edb851336928dd3eea2934cc07 (diff)
Add Agent Context API for external agent integration
Phase 1: Authentication and read-only context - POST /agent/auth/request - request access with name + agent_id - GET /agent/auth/poll - poll for approval status - POST /agent/auth/approve|deny - user approval (browser auth required) - GET /agent/context - 7-day timeline context (agent session required) Phase 1.5: Browser-only agent endpoints (HTML pages) - GET /agent/web/request - request page with token - GET /agent/web/status - status page with polling - GET /agent/web/context - context page with timeline data WebSocket notifications: - GET /ws/notifications - push agent requests to browsers - Approval modal with trust indicators and countdown timer Database: - agents table for registered agent tracking - agent_sessions table for pending/active sessions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--cmd/dashboard/main.go33
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--internal/auth/middleware.go9
-rw-r--r--internal/handlers/handlers_test.go9
-rw-r--r--internal/middleware/security.go2
-rw-r--r--internal/models/types.go51
-rw-r--r--internal/store/sqlite.go345
-rw-r--r--migrations/010_agent_tables.sql30
-rw-r--r--web/static/js/app.js175
-rw-r--r--web/templates/agent-context.html121
-rw-r--r--web/templates/agent-error.html28
-rw-r--r--web/templates/agent-request.html61
-rw-r--r--web/templates/agent-status.html80
14 files changed, 941 insertions, 6 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index ce91e6e..8f87e30 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -136,6 +136,9 @@ func main() {
// Rate limiter for auth endpoints
authRateLimiter := appmiddleware.NewRateLimiter(config.AuthRateLimitRequests, config.AuthRateLimitWindow)
+ // Rate limiter for agent auth (stricter - 10 requests/minute per IP)
+ agentAuthRateLimiter := appmiddleware.NewRateLimiter(10, time.Minute)
+
// Public routes (no auth required)
r.Get("/login", authHandlers.HandleLoginPage)
r.With(authRateLimiter.Limit).Post("/login", authHandlers.HandleLogin)
@@ -148,6 +151,33 @@ func main() {
// Conditions page (public - no auth required)
r.Get("/conditions", h.HandleConditionsPage)
+ // Agent API
+ r.Route("/agent", func(r chi.Router) {
+ // Public endpoints (no browser auth, but rate limited)
+ r.With(agentAuthRateLimiter.Limit).Post("/auth/request", h.HandleAgentAuthRequest)
+ r.Get("/auth/poll", h.HandleAgentAuthPoll)
+
+ // Browser auth required for approve/deny
+ r.Group(func(r chi.Router) {
+ r.Use(authHandlers.Middleware().RequireAuth)
+ r.Post("/auth/approve", h.HandleAgentAuthApprove)
+ r.Post("/auth/deny", h.HandleAgentAuthDeny)
+ })
+
+ // Agent session required for context
+ r.Group(func(r chi.Router) {
+ r.Use(h.AgentAuthMiddleware)
+ r.Get("/context", h.HandleAgentContext)
+ })
+
+ // HTML endpoints for browser-only agents (GET requests only)
+ r.Route("/web", func(r chi.Router) {
+ r.With(agentAuthRateLimiter.Limit).Get("/request", h.HandleAgentWebRequest)
+ r.Get("/status", h.HandleAgentWebStatus)
+ r.Get("/context", h.HandleAgentWebContext)
+ })
+ })
+
// Protected routes (auth required)
r.Group(func(r chi.Router) {
r.Use(authHandlers.Middleware().RequireAuth)
@@ -201,6 +231,9 @@ func main() {
// Shopping mode (focused single-store view)
r.Get("/shopping/mode/{store}", h.HandleShoppingMode)
r.Post("/shopping/mode/{store}/toggle", h.HandleShoppingModeToggle)
+
+ // WebSocket for notifications
+ r.Get("/ws/notifications", h.HandleWebSocket)
})
// Start server
diff --git a/go.mod b/go.mod
index d9bba6f..df85101 100644
--- a/go.mod
+++ b/go.mod
@@ -30,6 +30,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
+ github.com/gorilla/websocket v1.5.3 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
diff --git a/go.sum b/go.sum
index e05da29..b34a475 100644
--- a/go.sum
+++ b/go.sum
@@ -40,6 +40,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dq
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go
index ecdde82..78f3b53 100644
--- a/internal/auth/middleware.go
+++ b/internal/auth/middleware.go
@@ -6,6 +6,7 @@ import (
"crypto/subtle"
"encoding/base64"
"net/http"
+ "strings"
"github.com/alexedwards/scs/v2"
)
@@ -63,6 +64,12 @@ func (m *Middleware) ClearSession(r *http.Request) error {
// CSRFProtect checks for a valid CSRF token on state-changing requests
func (m *Middleware) CSRFProtect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Skip CSRF for agent API endpoints (they use token-based auth, not cookies)
+ if strings.HasPrefix(r.URL.Path, "/agent/") {
+ next.ServeHTTP(w, r)
+ return
+ }
+
// Ensure a token exists in the session
if !m.sessions.Exists(r.Context(), SessionKeyCSRF) {
token, err := generateToken()
@@ -78,7 +85,7 @@ func (m *Middleware) CSRFProtect(next http.Handler) http.Handler {
// Check token for state-changing methods
if r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE" || r.Method == "PATCH" {
requestToken := r.Header.Get("X-CSRF-Token")
-
+
if requestToken == "" {
requestToken = r.FormValue("csrf_token")
}
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
index d863546..3367ef6 100644
--- a/internal/handlers/handlers_test.go
+++ b/internal/handlers/handlers_test.go
@@ -75,10 +75,11 @@ func loadTestTemplates(t *testing.T) *template.Template {
}
}
- // Parse partials
- tmpl, err = tmpl.ParseGlob(filepath.Join("web", "templates", "partials", "*.html"))
- if err != nil {
- tmpl, _ = tmpl.ParseGlob(filepath.Join("..", "..", "web", "templates", "partials", "*.html"))
+ // Parse partials - don't reassign tmpl if parsing fails
+ if parsed, err := tmpl.ParseGlob(filepath.Join("web", "templates", "partials", "*.html")); err == nil {
+ tmpl = parsed
+ } else if parsed, err := tmpl.ParseGlob(filepath.Join("..", "..", "web", "templates", "partials", "*.html")); err == nil {
+ tmpl = parsed
}
return tmpl
diff --git a/internal/middleware/security.go b/internal/middleware/security.go
index e048645..8d1e619 100644
--- a/internal/middleware/security.go
+++ b/internal/middleware/security.go
@@ -29,7 +29,7 @@ func SecurityHeaders(debug bool) func(http.Handler) http.Handler {
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "+
"font-src 'self' https://fonts.gstatic.com; "+
"frame-src https://www.youtube.com https://embed.windy.com; "+
- "connect-src 'self'")
+ "connect-src 'self' wss: ws:")
next.ServeHTTP(w, r)
})
diff --git a/internal/models/types.go b/internal/models/types.go
index 4bf8462..5214bf8 100644
--- a/internal/models/types.go
+++ b/internal/models/types.go
@@ -146,3 +146,54 @@ type DashboardData struct {
LastUpdated time.Time `json:"last_updated"`
Errors []string `json:"errors,omitempty"`
}
+
+// Agent represents a registered external agent
+type Agent struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ AgentID string `json:"agent_id"` // UUID from agent
+ CreatedAt time.Time `json:"created_at"`
+ LastSeen *time.Time `json:"last_seen,omitempty"`
+ Trusted bool `json:"trusted"`
+}
+
+// AgentSession represents a pending request or active session
+type AgentSession struct {
+ ID int64 `json:"id"`
+ RequestToken string `json:"request_token"`
+ AgentName string `json:"agent_name"`
+ AgentID string `json:"agent_id"`
+ Status string `json:"status"` // pending, approved, denied, expired
+ CreatedAt time.Time `json:"created_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+ SessionToken string `json:"session_token,omitempty"`
+ SessionExpiresAt *time.Time `json:"session_expires_at,omitempty"`
+}
+
+// AgentAuthRequest is the request body for agent auth
+type AgentAuthRequest struct {
+ Name string `json:"name"`
+ AgentID string `json:"agent_id"`
+}
+
+// AgentAuthResponse is the response for auth request
+type AgentAuthResponse struct {
+ RequestToken string `json:"request_token"`
+ Status string `json:"status"`
+}
+
+// AgentPollResponse is the response for poll endpoint
+type AgentPollResponse struct {
+ Status string `json:"status"`
+ SessionToken string `json:"session_token,omitempty"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
+}
+
+// AgentTrustLevel indicates the trust state of an agent
+type AgentTrustLevel string
+
+const (
+ AgentTrustNew AgentTrustLevel = "new"
+ AgentTrustRecognized AgentTrustLevel = "recognized"
+ AgentTrustSuspicious AgentTrustLevel = "suspicious"
+)
diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
index 396ac54..b324e9f 100644
--- a/internal/store/sqlite.go
+++ b/internal/store/sqlite.go
@@ -796,3 +796,348 @@ func (s *Store) GetCardsByDateRange(start, end time.Time) ([]models.Card, error)
return cards, rows.Err()
}
+
+// Agent operations
+
+// CreateAgentSession creates a new pending agent session
+func (s *Store) CreateAgentSession(session *models.AgentSession) error {
+ result, err := s.db.Exec(`
+ INSERT INTO agent_sessions (request_token, agent_name, agent_id, status, expires_at)
+ VALUES (?, ?, ?, 'pending', ?)
+ `, session.RequestToken, session.AgentName, session.AgentID, session.ExpiresAt)
+ if err != nil {
+ return err
+ }
+ id, err := result.LastInsertId()
+ if err != nil {
+ return err
+ }
+ session.ID = id
+ return nil
+}
+
+// GetAgentSessionByRequestToken retrieves a session by request token
+func (s *Store) GetAgentSessionByRequestToken(token string) (*models.AgentSession, error) {
+ var session models.AgentSession
+ var sessionToken sql.NullString
+ var sessionExpiresAt sql.NullTime
+
+ err := s.db.QueryRow(`
+ SELECT id, request_token, agent_name, agent_id, status, created_at, expires_at, session_token, session_expires_at
+ FROM agent_sessions
+ WHERE request_token = ?
+ `, token).Scan(
+ &session.ID,
+ &session.RequestToken,
+ &session.AgentName,
+ &session.AgentID,
+ &session.Status,
+ &session.CreatedAt,
+ &session.ExpiresAt,
+ &sessionToken,
+ &sessionExpiresAt,
+ )
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ if sessionToken.Valid {
+ session.SessionToken = sessionToken.String
+ }
+ if sessionExpiresAt.Valid {
+ session.SessionExpiresAt = &sessionExpiresAt.Time
+ }
+ return &session, nil
+}
+
+// GetPendingAgentSessionByAgentID retrieves an existing pending session for an agent
+func (s *Store) GetPendingAgentSessionByAgentID(agentID string) (*models.AgentSession, error) {
+ var session models.AgentSession
+
+ err := s.db.QueryRow(`
+ SELECT id, request_token, agent_name, agent_id, status, created_at, expires_at
+ FROM agent_sessions
+ WHERE agent_id = ? AND status = 'pending' AND expires_at > datetime('now', 'localtime')
+ ORDER BY created_at DESC
+ LIMIT 1
+ `, agentID).Scan(
+ &session.ID,
+ &session.RequestToken,
+ &session.AgentName,
+ &session.AgentID,
+ &session.Status,
+ &session.CreatedAt,
+ &session.ExpiresAt,
+ )
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ return &session, nil
+}
+
+// GetAgentSessionBySessionToken retrieves a session by session token
+func (s *Store) GetAgentSessionBySessionToken(token string) (*models.AgentSession, error) {
+ var session models.AgentSession
+ var sessionToken sql.NullString
+ var sessionExpiresAt sql.NullTime
+
+ err := s.db.QueryRow(`
+ SELECT id, request_token, agent_name, agent_id, status, created_at, expires_at, session_token, session_expires_at
+ FROM agent_sessions
+ WHERE session_token = ? AND status = 'approved'
+ `, token).Scan(
+ &session.ID,
+ &session.RequestToken,
+ &session.AgentName,
+ &session.AgentID,
+ &session.Status,
+ &session.CreatedAt,
+ &session.ExpiresAt,
+ &sessionToken,
+ &sessionExpiresAt,
+ )
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ if sessionToken.Valid {
+ session.SessionToken = sessionToken.String
+ }
+ if sessionExpiresAt.Valid {
+ session.SessionExpiresAt = &sessionExpiresAt.Time
+ }
+ return &session, nil
+}
+
+// ApproveAgentSession approves a pending session
+func (s *Store) ApproveAgentSession(requestToken, sessionToken string, sessionExpiresAt time.Time) error {
+ result, err := s.db.Exec(`
+ UPDATE agent_sessions
+ SET status = 'approved', session_token = ?, session_expires_at = ?
+ WHERE request_token = ? AND status = 'pending'
+ `, sessionToken, sessionExpiresAt, requestToken)
+ if err != nil {
+ return err
+ }
+ affected, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if affected == 0 {
+ return errors.New("session not found or already processed")
+ }
+ return nil
+}
+
+// DenyAgentSession denies a pending session
+func (s *Store) DenyAgentSession(requestToken string) error {
+ result, err := s.db.Exec(`
+ UPDATE agent_sessions
+ SET status = 'denied'
+ WHERE request_token = ? AND status = 'pending'
+ `, requestToken)
+ if err != nil {
+ return err
+ }
+ affected, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if affected == 0 {
+ return errors.New("session not found or already processed")
+ }
+ return nil
+}
+
+// GetPendingAgentSessions retrieves all unexpired pending sessions
+func (s *Store) GetPendingAgentSessions() ([]models.AgentSession, error) {
+ rows, err := s.db.Query(`
+ SELECT id, request_token, agent_name, agent_id, status, created_at, expires_at
+ FROM agent_sessions
+ WHERE status = 'pending' AND expires_at > datetime('now', 'localtime')
+ ORDER BY created_at DESC
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var sessions []models.AgentSession
+ for rows.Next() {
+ var session models.AgentSession
+ if err := rows.Scan(
+ &session.ID,
+ &session.RequestToken,
+ &session.AgentName,
+ &session.AgentID,
+ &session.Status,
+ &session.CreatedAt,
+ &session.ExpiresAt,
+ ); err != nil {
+ return nil, err
+ }
+ sessions = append(sessions, session)
+ }
+ return sessions, rows.Err()
+}
+
+// InvalidatePreviousAgentSessions marks previous sessions for an agent as expired
+func (s *Store) InvalidatePreviousAgentSessions(agentID string) error {
+ _, err := s.db.Exec(`
+ UPDATE agent_sessions
+ SET status = 'expired'
+ WHERE agent_id = ? AND status IN ('pending', 'approved')
+ `, agentID)
+ return err
+}
+
+// GetAgentByAgentID retrieves an agent by their agent_id (UUID)
+func (s *Store) GetAgentByAgentID(agentID string) (*models.Agent, error) {
+ var agent models.Agent
+ var lastSeen sql.NullTime
+
+ err := s.db.QueryRow(`
+ SELECT id, name, agent_id, created_at, last_seen, trusted
+ FROM agents
+ WHERE agent_id = ?
+ `, agentID).Scan(
+ &agent.ID,
+ &agent.Name,
+ &agent.AgentID,
+ &agent.CreatedAt,
+ &lastSeen,
+ &agent.Trusted,
+ )
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ if lastSeen.Valid {
+ agent.LastSeen = &lastSeen.Time
+ }
+ return &agent, nil
+}
+
+// GetAgentByName retrieves an agent by name
+func (s *Store) GetAgentByName(name string) (*models.Agent, error) {
+ var agent models.Agent
+ var lastSeen sql.NullTime
+
+ err := s.db.QueryRow(`
+ SELECT id, name, agent_id, created_at, last_seen, trusted
+ FROM agents
+ WHERE name = ?
+ `, name).Scan(
+ &agent.ID,
+ &agent.Name,
+ &agent.AgentID,
+ &agent.CreatedAt,
+ &lastSeen,
+ &agent.Trusted,
+ )
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ if lastSeen.Valid {
+ agent.LastSeen = &lastSeen.Time
+ }
+ return &agent, nil
+}
+
+// CreateOrUpdateAgent creates or updates an agent record
+func (s *Store) CreateOrUpdateAgent(name, agentID string) error {
+ _, err := s.db.Exec(`
+ INSERT INTO agents (name, agent_id, last_seen, trusted)
+ VALUES (?, ?, datetime('now'), 1)
+ ON CONFLICT(agent_id) DO UPDATE SET
+ name = excluded.name,
+ last_seen = datetime('now')
+ `, name, agentID)
+ return err
+}
+
+// UpdateAgentLastSeen updates the last_seen timestamp for an agent
+func (s *Store) UpdateAgentLastSeen(agentID string) error {
+ _, err := s.db.Exec(`
+ UPDATE agents SET last_seen = datetime('now')
+ WHERE agent_id = ?
+ `, agentID)
+ return err
+}
+
+// GetAllAgents retrieves all agents
+func (s *Store) GetAllAgents() ([]models.Agent, error) {
+ rows, err := s.db.Query(`
+ SELECT id, name, agent_id, created_at, last_seen, trusted
+ FROM agents
+ ORDER BY last_seen DESC NULLS LAST
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var agents []models.Agent
+ for rows.Next() {
+ var agent models.Agent
+ var lastSeen sql.NullTime
+ if err := rows.Scan(
+ &agent.ID,
+ &agent.Name,
+ &agent.AgentID,
+ &agent.CreatedAt,
+ &lastSeen,
+ &agent.Trusted,
+ ); err != nil {
+ return nil, err
+ }
+ if lastSeen.Valid {
+ agent.LastSeen = &lastSeen.Time
+ }
+ agents = append(agents, agent)
+ }
+ return agents, rows.Err()
+}
+
+// RevokeAgent sets trusted=false for an agent
+func (s *Store) RevokeAgent(agentID string) error {
+ _, err := s.db.Exec(`UPDATE agents SET trusted = 0 WHERE agent_id = ?`, agentID)
+ return err
+}
+
+// CheckAgentTrust determines trust level for an agent request
+func (s *Store) CheckAgentTrust(name, agentID string) (models.AgentTrustLevel, error) {
+ // Check if this exact agent_id is known
+ existingByID, err := s.GetAgentByAgentID(agentID)
+ if err != nil {
+ return "", err
+ }
+
+ // Check if this name is known with a different ID
+ existingByName, err := s.GetAgentByName(name)
+ if err != nil {
+ return "", err
+ }
+
+ if existingByID != nil && existingByID.Name == name && existingByID.Trusted {
+ return models.AgentTrustRecognized, nil
+ }
+
+ if existingByName != nil && existingByName.AgentID != agentID {
+ return models.AgentTrustSuspicious, nil
+ }
+
+ return models.AgentTrustNew, nil
+}
diff --git a/migrations/010_agent_tables.sql b/migrations/010_agent_tables.sql
new file mode 100644
index 0000000..23e1c2c
--- /dev/null
+++ b/migrations/010_agent_tables.sql
@@ -0,0 +1,30 @@
+-- Agent Context API tables
+-- Migration: 010_agent_tables.sql
+
+-- Registered/approved agents (identity binding)
+CREATE TABLE IF NOT EXISTS agents (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ agent_id TEXT NOT NULL UNIQUE, -- UUID from agent
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_seen DATETIME,
+ trusted BOOLEAN DEFAULT 1 -- can be revoked
+);
+
+-- Pending access requests and active sessions
+CREATE TABLE IF NOT EXISTS agent_sessions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ request_token TEXT NOT NULL UNIQUE,
+ agent_name TEXT NOT NULL,
+ agent_id TEXT NOT NULL,
+ status TEXT DEFAULT 'pending', -- pending, approved, denied, expired
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ expires_at DATETIME NOT NULL, -- request expires after 5 min
+ session_token TEXT, -- populated on approval
+ session_expires_at DATETIME -- session TTL (1 hour)
+);
+
+CREATE INDEX IF NOT EXISTS idx_agent_sessions_request ON agent_sessions(request_token);
+CREATE INDEX IF NOT EXISTS idx_agent_sessions_session ON agent_sessions(session_token);
+CREATE INDEX IF NOT EXISTS idx_agent_sessions_status ON agent_sessions(status);
+CREATE INDEX IF NOT EXISTS idx_agents_agent_id ON agents(agent_id);
diff --git a/web/static/js/app.js b/web/static/js/app.js
index f103ae8..380bb70 100644
--- a/web/static/js/app.js
+++ b/web/static/js/app.js
@@ -265,3 +265,178 @@ function toggleTask(taskId) {
}, { passive: true });
});
})();
+
+// Agent Access Request Notifications
+(function() {
+ let wsConnection = null;
+ let reconnectAttempts = 0;
+ const MAX_RECONNECT_ATTEMPTS = 5;
+ const RECONNECT_DELAY_BASE = 1000;
+
+ function connectWebSocket() {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const wsUrl = `${protocol}//${window.location.host}/ws/notifications`;
+
+ try {
+ wsConnection = new WebSocket(wsUrl);
+
+ wsConnection.onopen = function() {
+ console.log('WebSocket connected');
+ reconnectAttempts = 0;
+ };
+
+ wsConnection.onmessage = function(event) {
+ try {
+ const msg = JSON.parse(event.data);
+ if (msg.type === 'agent_request') {
+ showAgentApprovalModal(msg.payload);
+ }
+ } catch (e) {
+ console.error('Failed to parse WebSocket message:', e);
+ }
+ };
+
+ wsConnection.onclose = function(event) {
+ console.log('WebSocket disconnected');
+ if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
+ const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttempts);
+ reconnectAttempts++;
+ console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`);
+ setTimeout(connectWebSocket, delay);
+ }
+ };
+
+ wsConnection.onerror = function(error) {
+ console.error('WebSocket error:', error);
+ };
+ } catch (e) {
+ console.error('Failed to create WebSocket:', e);
+ }
+ }
+
+ function showAgentApprovalModal(payload) {
+ // Remove any existing modal
+ const existingModal = document.getElementById('agent-approval-modal');
+ if (existingModal) {
+ existingModal.remove();
+ }
+
+ // Truncate agent ID for display
+ const shortAgentId = payload.agent_id.substring(0, 8);
+
+ // Determine trust indicator
+ let trustBadge = '';
+ let trustClass = '';
+ switch (payload.trust_level) {
+ case 'recognized':
+ trustBadge = 'Recognized';
+ trustClass = 'bg-green-500';
+ break;
+ case 'suspicious':
+ trustBadge = 'Warning: Different ID';
+ trustClass = 'bg-yellow-500';
+ break;
+ default:
+ trustBadge = 'New Agent';
+ trustClass = 'bg-blue-500';
+ }
+
+ // Calculate time remaining
+ const expiresAt = new Date(payload.expires_at);
+ const timeRemaining = Math.max(0, Math.floor((expiresAt - new Date()) / 1000));
+
+ const modal = document.createElement('div');
+ modal.id = 'agent-approval-modal';
+ modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
+ modal.innerHTML = `
+ <div class="bg-gray-900 rounded-lg p-6 max-w-md mx-4 shadow-xl border border-gray-700">
+ <div class="flex items-center justify-between mb-4">
+ <h2 class="text-xl font-semibold text-white">Agent Access Request</h2>
+ <span class="px-2 py-1 text-xs rounded ${trustClass} text-white">${trustBadge}</span>
+ </div>
+ <div class="mb-6 text-gray-300">
+ <p class="mb-2"><strong class="text-white">Agent Name:</strong> ${escapeHtml(payload.agent_name)}</p>
+ <p class="mb-2"><strong class="text-white">Agent ID:</strong> <code class="bg-gray-800 px-1 rounded">${shortAgentId}...</code></p>
+ <p class="text-sm text-gray-400">Expires in <span id="agent-countdown">${timeRemaining}</span>s</p>
+ </div>
+ <div class="flex gap-3">
+ <button id="agent-approve-btn" class="flex-1 bg-green-600 hover:bg-green-700 text-white py-2 px-4 rounded transition">
+ Approve
+ </button>
+ <button id="agent-deny-btn" class="flex-1 bg-red-600 hover:bg-red-700 text-white py-2 px-4 rounded transition">
+ Deny
+ </button>
+ </div>
+ </div>
+ `;
+
+ document.body.appendChild(modal);
+
+ // Countdown timer
+ const countdownEl = document.getElementById('agent-countdown');
+ const countdownInterval = setInterval(() => {
+ const remaining = Math.max(0, Math.floor((expiresAt - new Date()) / 1000));
+ countdownEl.textContent = remaining;
+ if (remaining <= 0) {
+ clearInterval(countdownInterval);
+ modal.remove();
+ }
+ }, 1000);
+
+ // Button handlers
+ document.getElementById('agent-approve-btn').addEventListener('click', async () => {
+ await handleAgentDecision(payload.request_token, 'approve');
+ clearInterval(countdownInterval);
+ modal.remove();
+ });
+
+ document.getElementById('agent-deny-btn').addEventListener('click', async () => {
+ await handleAgentDecision(payload.request_token, 'deny');
+ clearInterval(countdownInterval);
+ modal.remove();
+ });
+
+ // Click outside to dismiss (treat as no action)
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) {
+ clearInterval(countdownInterval);
+ modal.remove();
+ }
+ });
+ }
+
+ async function handleAgentDecision(requestToken, decision) {
+ try {
+ const response = await fetch(`/agent/auth/${decision}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': getCSRFToken()
+ },
+ body: JSON.stringify({ request_token: requestToken })
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ console.error(`Failed to ${decision} agent:`, error);
+ alert(`Failed to ${decision} agent request. Please try again.`);
+ } else {
+ console.log(`Agent request ${decision}d successfully`);
+ }
+ } catch (e) {
+ console.error(`Error during agent ${decision}:`, e);
+ alert(`Error processing request. Please try again.`);
+ }
+ }
+
+ function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ // Initialize WebSocket on page load
+ document.addEventListener('DOMContentLoaded', function() {
+ connectWebSocket();
+ });
+})();
diff --git a/web/templates/agent-context.html b/web/templates/agent-context.html
new file mode 100644
index 0000000..3a4778a
--- /dev/null
+++ b/web/templates/agent-context.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Agent Context - {{.AgentName}}</title>
+ <style>
+ body { font-family: system-ui, sans-serif; max-width: 800px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }
+ .card { background: #16213e; border-radius: 8px; padding: 24px; margin-bottom: 20px; }
+ h1 { color: #e94560; margin-top: 0; }
+ h2 { color: #0f3460; font-size: 1.2em; margin-top: 24px; }
+ .label { color: #888; font-size: 0.9em; margin-bottom: 4px; }
+ .value { font-family: monospace; background: #0f0f23; padding: 8px 12px; border-radius: 4px; word-break: break-all; margin-bottom: 16px; }
+ .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; margin-bottom: 20px; }
+ .summary-item { background: #0f3460; padding: 12px; border-radius: 6px; text-align: center; }
+ .summary-value { font-size: 1.5em; font-weight: bold; color: #e94560; }
+ .summary-label { font-size: 0.8em; color: #888; }
+ table { width: 100%; border-collapse: collapse; margin-top: 16px; }
+ th, td { padding: 10px; text-align: left; border-bottom: 1px solid #333; }
+ th { background: #0f3460; color: #fff; }
+ tr:hover { background: #1a1a2e; }
+ .source { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 0.8em; }
+ .source-todoist { background: #e44332; color: #fff; }
+ .source-trello { background: #0079bf; color: #fff; }
+ .source-plantoeat { background: #5cb85c; color: #fff; }
+ .due-overdue { color: #dc3545; }
+ .due-today { color: #ffc107; }
+ .due-future { color: #28a745; }
+ a { color: #4da6ff; }
+ </style>
+</head>
+<body>
+ <script type="application/json" id="agent-data">
+{
+ "generated_at": "{{.GeneratedAt}}",
+ "range": {
+ "start": "{{.RangeStart}}",
+ "end": "{{.RangeEnd}}"
+ },
+ "summary": {
+ "total_items": {{with .Summary}}{{.total_items}}{{else}}0{{end}},
+ "overdue": {{with .Summary}}{{.overdue}}{{else}}0{{end}},
+ "today": {{with .Summary}}{{.today}}{{else}}0{{end}}
+ },
+ "timeline": [{{range $i, $item := .Timeline}}{{if $i}},{{end}}
+ {
+ "id": "{{$item.ID}}",
+ "source": "{{$item.Source}}",
+ "type": "{{$item.Type}}",
+ "title": "{{$item.Title}}",
+ "description": "{{$item.Description}}",
+ "due": {{if $item.Due}}"{{$item.Due.Format "2006-01-02T15:04:05Z07:00"}}"{{else}}null{{end}},
+ "priority": {{$item.Priority}},
+ "completable": {{$item.Completable}},
+ "url": "{{$item.URL}}"
+ }{{end}}
+ ]
+}
+ </script>
+
+ <div class="card">
+ <h1>Agent Context</h1>
+ <p>Timeline data for <strong>{{.AgentName}}</strong></p>
+
+ <div class="label">Generated At</div>
+ <div class="value">{{.GeneratedAt}}</div>
+
+ <div class="label">Date Range</div>
+ <div class="value">{{.RangeStart}} to {{.RangeEnd}}</div>
+ </div>
+
+ <div class="card">
+ <h2>Summary</h2>
+ <div class="summary">
+ <div class="summary-item">
+ <div class="summary-value">{{with .Summary}}{{.total_items}}{{else}}0{{end}}</div>
+ <div class="summary-label">Total Items</div>
+ </div>
+ <div class="summary-item">
+ <div class="summary-value">{{with .Summary}}{{.overdue}}{{else}}0{{end}}</div>
+ <div class="summary-label">Overdue</div>
+ </div>
+ <div class="summary-item">
+ <div class="summary-value">{{with .Summary}}{{.today}}{{else}}0{{end}}</div>
+ <div class="summary-label">Due Today</div>
+ </div>
+ </div>
+ </div>
+
+ <div class="card">
+ <h2>Timeline</h2>
+ {{if .Timeline}}
+ <table>
+ <thead>
+ <tr>
+ <th>Source</th>
+ <th>Title</th>
+ <th>Due</th>
+ <th>Type</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{range .Timeline}}
+ <tr>
+ <td><span class="source source-{{.Source}}">{{.Source}}</span></td>
+ <td>
+ {{if .URL}}<a href="{{.URL}}" target="_blank">{{.Title}}</a>{{else}}{{.Title}}{{end}}
+ {{if .Description}}<br><small style="color: #888;">{{.Description}}</small>{{end}}
+ </td>
+ <td>{{if .Due}}{{.Due.Format "Jan 2, 3:04 PM"}}{{else}}-{{end}}</td>
+ <td>{{.Type}}</td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+ {{else}}
+ <p>No items in the timeline for this date range.</p>
+ {{end}}
+ </div>
+</body>
+</html>
diff --git a/web/templates/agent-error.html b/web/templates/agent-error.html
new file mode 100644
index 0000000..afb3603
--- /dev/null
+++ b/web/templates/agent-error.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Error - Agent API</title>
+ <style>
+ body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }
+ .card { background: #16213e; border-radius: 8px; padding: 24px; margin-bottom: 20px; border-left: 4px solid #dc3545; }
+ h1 { color: #dc3545; margin-top: 0; }
+ .status-code { font-size: 3em; color: #dc3545; font-weight: bold; }
+ </style>
+</head>
+<body>
+ <script type="application/json" id="agent-data">
+{
+ "error": "{{.Error}}",
+ "status": {{.Status}}
+}
+ </script>
+
+ <div class="card">
+ <div class="status-code">{{.Status}}</div>
+ <h1>Error</h1>
+ <p>{{.Error}}</p>
+ </div>
+</body>
+</html>
diff --git a/web/templates/agent-request.html b/web/templates/agent-request.html
new file mode 100644
index 0000000..fee5ca4
--- /dev/null
+++ b/web/templates/agent-request.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Agent Auth Request - {{.AgentName}}</title>
+ <style>
+ body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }
+ .card { background: #16213e; border-radius: 8px; padding: 24px; margin-bottom: 20px; }
+ h1 { color: #e94560; margin-top: 0; }
+ .label { color: #888; font-size: 0.9em; margin-bottom: 4px; }
+ .value { font-family: monospace; background: #0f0f23; padding: 8px 12px; border-radius: 4px; word-break: break-all; margin-bottom: 16px; }
+ .status { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 0.9em; }
+ .status-pending { background: #ffc107; color: #000; }
+ .instructions { background: #1a1a2e; border: 1px solid #333; border-radius: 4px; padding: 16px; font-size: 0.9em; }
+ .instructions ol { margin: 0; padding-left: 20px; }
+ .instructions li { margin-bottom: 8px; }
+ code { background: #0f0f23; padding: 2px 6px; border-radius: 3px; }
+ </style>
+</head>
+<body>
+ <script type="application/json" id="agent-data">
+{
+ "request_token": "{{.RequestToken}}",
+ "status": "{{.Status}}",
+ "poll_url": "{{.PollURL}}",
+ "expires_at": "{{.ExpiresAt}}",
+ "agent_name": "{{.AgentName}}",
+ "agent_id": "{{.AgentID}}"
+}
+ </script>
+
+ <div class="card">
+ <h1>Agent Authentication Request</h1>
+ <p>Agent <strong>{{.AgentName}}</strong> is requesting access to your dashboard.</p>
+
+ <div class="label">Status</div>
+ <div><span class="status status-pending">{{.Status}}</span></div>
+ <br>
+
+ <div class="label">Request Token</div>
+ <div class="value">{{.RequestToken}}</div>
+
+ <div class="label">Poll URL</div>
+ <div class="value">{{.PollURL}}</div>
+
+ <div class="label">Expires At</div>
+ <div class="value">{{.ExpiresAt}}</div>
+ </div>
+
+ <div class="instructions">
+ <strong>Next Steps:</strong>
+ <ol>
+ <li>Wait for human approval on the dashboard</li>
+ <li>Poll the status URL: <code>GET {{.PollURL}}</code></li>
+ <li>When status is "approved", extract the session token from the response</li>
+ <li>Use the context URL to fetch your timeline data</li>
+ </ol>
+ </div>
+</body>
+</html>
diff --git a/web/templates/agent-status.html b/web/templates/agent-status.html
new file mode 100644
index 0000000..a77bb97
--- /dev/null
+++ b/web/templates/agent-status.html
@@ -0,0 +1,80 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Agent Status - {{.AgentName}}</title>
+ <style>
+ body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }
+ .card { background: #16213e; border-radius: 8px; padding: 24px; margin-bottom: 20px; }
+ h1 { color: #e94560; margin-top: 0; }
+ .label { color: #888; font-size: 0.9em; margin-bottom: 4px; }
+ .value { font-family: monospace; background: #0f0f23; padding: 8px 12px; border-radius: 4px; word-break: break-all; margin-bottom: 16px; }
+ .status { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 0.9em; }
+ .status-pending { background: #ffc107; color: #000; }
+ .status-approved { background: #28a745; color: #fff; }
+ .status-denied { background: #dc3545; color: #fff; }
+ .status-expired { background: #6c757d; color: #fff; }
+ .success-box { background: #155724; border: 1px solid #28a745; border-radius: 4px; padding: 16px; margin-top: 20px; }
+ .instructions { background: #1a1a2e; border: 1px solid #333; border-radius: 4px; padding: 16px; font-size: 0.9em; }
+ code { background: #0f0f23; padding: 2px 6px; border-radius: 3px; }
+ </style>
+</head>
+<body>
+ <script type="application/json" id="agent-data">
+{
+ "status": "{{.Status}}"{{if .SessionToken}},
+ "session_token": "{{.SessionToken}}",
+ "context_url": "{{.ContextURL}}",
+ "expires_at": "{{.SessionExpiresAt}}"{{end}}
+}
+ </script>
+
+ <div class="card">
+ <h1>Agent Status</h1>
+ <p>Status for agent <strong>{{.AgentName}}</strong></p>
+
+ <div class="label">Status</div>
+ <div><span class="status status-{{.Status}}">{{.Status}}</span></div>
+
+ {{if eq .Status "approved"}}
+ <div class="success-box">
+ <strong>Access Granted!</strong>
+ <p>Your session has been approved. Use the credentials below to access the context.</p>
+ </div>
+
+ <br>
+ <div class="label">Session Token</div>
+ <div class="value">{{.SessionToken}}</div>
+
+ <div class="label">Context URL</div>
+ <div class="value">{{.ContextURL}}</div>
+
+ {{if .SessionExpiresAt}}
+ <div class="label">Session Expires At</div>
+ <div class="value">{{.SessionExpiresAt}}</div>
+ {{end}}
+
+ <div class="instructions">
+ <strong>Next Step:</strong>
+ <p>Fetch your timeline data by navigating to: <code>GET {{.ContextURL}}</code></p>
+ </div>
+ {{else if eq .Status "pending"}}
+ <div class="instructions">
+ <strong>Waiting for approval...</strong>
+ <p>Refresh this page to check the current status.</p>
+ </div>
+ {{else if eq .Status "denied"}}
+ <div class="instructions">
+ <strong>Access Denied</strong>
+ <p>Your request was denied by the dashboard owner.</p>
+ </div>
+ {{else if eq .Status "expired"}}
+ <div class="instructions">
+ <strong>Request Expired</strong>
+ <p>Your authentication request has expired. Please initiate a new request.</p>
+ </div>
+ {{end}}
+ </div>
+</body>
+</html>