diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-06-15 09:14:14 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-06-15 09:14:14 +0000 |
| commit | c86abd29845fa6feb61b1fddb8e8bb41312d55a8 (patch) | |
| tree | 697fa3542035672a73ba9ddee8d47ca7044b0d5b | |
| parent | eb089ef4c9416bd3d5642bb5a4437355ffa2fdd0 (diff) | |
feat: service gateway framework + playground demo
Formalizes doot as an authenticated reverse proxy for arbitrary upstream
services. Any local HTTP service can now be registered behind doot's
existing auth + SSL layer with 3 lines of config.
- Rename NewClaudomatorProxy → NewServiceProxy (generic)
- Replace hard-coded claudomator proxy block with serviceMount slice loop
- Add PlaygroundURL config (PLAYGROUND_URL env var)
- Add playground/web/server.py: stdlib Python status page on port 9090
- Document pattern in .agent/design.md; update .env.example
To add a new service: set its URL env var, add a config field, append
one mount entry in main.go. Claudomator's GitHub webhook bypass is
expressed as webhookPaths on its mount.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | .agent/design.md | 1703 | ||||
| -rw-r--r-- | .env.example | 6 | ||||
| -rw-r--r-- | cmd/dashboard/main.go | 59 | ||||
| -rw-r--r-- | internal/config/config.go | 6 | ||||
| -rw-r--r-- | internal/handlers/proxy.go (renamed from internal/handlers/claudomator_proxy.go) | 49 | ||||
| -rw-r--r-- | internal/handlers/proxy_test.go (renamed from internal/handlers/claudomator_proxy_test.go) | 47 |
6 files changed, 977 insertions, 893 deletions
diff --git a/.agent/design.md b/.agent/design.md index f7b55c8..467b44a 100644 --- a/.agent/design.md +++ b/.agent/design.md @@ -1,831 +1,872 @@ -# Task Dashboard - Design Document - -## Overview - -Task Dashboard is a personal productivity web application that aggregates tasks, meals, calendar events, and shopping lists from multiple external services into a unified, mobile-friendly interface. Built with Go backend, HTMX frontend, Tailwind CSS styling, and SQLite storage. - -**Stack:** Go 1.24 | chi router | HTMX 1.x | Tailwind CSS | SQLite (WAL mode) - ---- - -## Project Intent - -The dashboard serves as a **personal consolidation hub** designed to: - -1. **Aggregate multiple data sources** into one view (Trello, Todoist, PlanToEat, Google Calendar, Google Tasks, user-reported bugs) -2. **Prioritize reliability** over speed - graceful degradation when APIs fail -3. **Support mobile-first usage** - optimized for phone/tablet in-store or on-the-go -4. **Minimize context switching** - complete tasks from any source without opening multiple apps -5. **Provide live monitoring** - volcano webcams and weather for Hawaii location - ---- - -## Architecture - -### Directory Structure - -``` -task-dashboard/ -├── cmd/dashboard/ -│ └── main.go # Entry point, route registration, ldflags -├── internal/ -│ ├── api/ # External API clients -│ │ ├── interfaces.go # API client contracts (interfaces) -│ │ ├── http.go # BaseClient HTTP utilities -│ │ ├── trello.go # Trello API client -│ │ ├── todoist.go # Todoist API (REST v2 + Sync v9) -│ │ ├── plantoeat.go # PlanToEat web scraper -│ │ ├── google_calendar.go # Google Calendar integration -│ │ └── google_tasks.go # Google Tasks integration -│ ├── auth/ -│ │ ├── auth.go # Authentication service (bcrypt + WebAuthn) -│ │ ├── handlers.go # Login/logout/passkey handlers -│ │ └── middleware.go # Session + CSRF protection -│ ├── config/ -│ │ ├── config.go # Environment configuration -│ │ ├── constants.go # App constants (timeouts, limits) -│ │ └── timezone.go # Display timezone helpers -│ ├── handlers/ -│ │ ├── handlers.go # Main HTTP handlers + aggregation -│ │ ├── settings.go # Settings page handlers -│ │ ├── shopping.go # Shopping tab + mode handlers -│ │ ├── agent.go # Agent auth + context API -│ │ ├── websocket.go # WebSocket notification hub -│ │ ├── timeline.go # Timeline view handler -│ │ ├── timeline_logic.go # Timeline data processing -│ │ ├── atoms.go # Unified task aggregation -│ │ ├── cache.go # Generic cache fetcher pattern -│ │ ├── helpers.go # Form parsing helpers -│ │ ├── renderer.go # Template renderer interface -│ │ └── response.go # JSON/HTML response utilities -│ ├── middleware/ -│ │ └── security.go # Security headers + rate limiting -│ ├── models/ -│ │ ├── types.go # Data models (Task, Card, Meal, Agent, etc.) -│ │ ├── atom.go # Unified Atom model + converters -│ │ └── timeline.go # TimelineItem + DaySection models -│ └── store/ -│ └── sqlite.go # SQLite database layer -├── migrations/ # SQL migration files (001-014) -├── scripts/ -│ ├── bugs # List bugs from production DB (local) -│ ├── deploy # Build + local deploy to /site -│ ├── logs # Fetch production journalctl (local) -│ └── resolve-bug # Resolve a production bug (local) -├── web/ -``` - -### Request Flow - -``` -Browser (HTMX request) - ↓ -chi Router (middleware stack) - ├── Logger, Recoverer, Timeout - ├── SecurityHeaders - ├── Session (LoadAndSave) - └── CSRFProtect - ↓ -Handler Function - ├── API Client (fetch from external service) - │ └── BaseClient.Get/Post (HTTP with context) - └── Store (SQLite cache) - └── Get*/Save* methods - ↓ -Template Rendering - ├── Full page: ExecuteTemplate(w, "filename.html", data) - └── Partial: ExecuteTemplate(w, "partial-name", data) - ↓ -HTML Response → HTMX swap -``` - -### Data Flow Patterns - -**Cache-First with Fallback:** -```go -// internal/handlers/cache.go pattern -1. Check IsCacheValid(key) - TTL-based -2. If valid, return cached data from SQLite -3. If expired, fetch from API -4. If API fails, return stale cache (graceful degradation) -5. On success, save to cache + update metadata -``` - -**Unified Atom Model:** -```go -// internal/models/atom.go -// Normalizes Todoist tasks, Trello cards, bugs → single Atom struct -TaskToAtom(Task) → Atom{Source: "todoist", SourceIcon: "🔴", ...} -CardToAtom(Card) → Atom{Source: "trello", SourceIcon: "📋", ...} -BugToAtom(Bug) → Atom{Source: "bug", SourceIcon: "🐛", ...} -``` - -**Timeline Aggregation:** -```go -// internal/handlers/timeline_logic.go -BuildTimeline(ctx, store, calendarClient, tasksClient, start, end) -├── Fetch tasks with due dates (Todoist) -├── Fetch cards with due dates (Trello) -├── Fetch meals (PlanToEat) → apply default times -├── Fetch events (Google Calendar) -├── Fetch tasks (Google Tasks) -├── Convert all to TimelineItem -├── Compute DaySection (today/tomorrow/later) -└── Sort by Time -``` - ---- - -## Visual Design - -### Color Palette - -| Usage | Value | Tailwind | -|-------|-------|----------| -| Panel background | `rgba(0,0,0,0.4)` | `bg-black/40` | -| Card background | `rgba(0,0,0,0.5)` | `bg-black/50` | -| Card hover | `rgba(0,0,0,0.6)` | `bg-black/60` | -| Primary text | `#ffffff` | `text-white` | -| Secondary text | `rgba(255,255,255,0.7)` | `text-white/70` | -| Muted text | `rgba(255,255,255,0.5)` | `text-white/50` | -| Border subtle | `rgba(255,255,255,0.1)` | `border-white/10` | -| Border accent | `rgba(255,255,255,0.2)` | `border-white/20` | -| Todoist accent | `#e44332` | `border-red-500` | -| Trello accent | `#0079bf` | `border-blue-500` | -| PlanToEat accent | `#10b981` | `border-green-500` | - -### Component Patterns - -**Card Container:** -```html -<div class="bg-card bg-card-hover transition-colors rounded-lg border border-white/5"> - <div class="flex items-start gap-3 p-3"> - <!-- checkbox → icon → content → link --> - </div> -</div> -``` - -**Tab Button:** -```html -<button class="tab-button {{if .IsActive}}tab-button-active{{end}}"> - <span>🗓️</span> Timeline -</button> -``` - -**Badge:** -```html -<span class="text-xs px-2 py-0.5 rounded bg-blue-900/50 text-blue-300"> - trello -</span> -``` - -**Glass Morphism:** -- `bg-black/40 backdrop-blur-sm` for panels -- `bg-black/60 backdrop-blur-lg` for modals -- `box-shadow: 0 0 12px black` for depth - -**Z-Index Hierarchy:** -| Layer | z-index | Element | -|-------|---------|---------| -| Content | default | Page content | -| Sticky headers | z-10 | Timeline section headers | -| FAB button | z-50 | Floating action button | -| Modals | z-50 | Action modal, edit modal | -| Dropdowns/Popups | z-100 | Navigation dropdown, context menus | - -### Typography - -- **Font:** Inter (300, 400, 500, 600 weights) -- **Headings:** `font-light tracking-wide text-white` -- **Labels:** `text-xs font-medium tracking-wider uppercase` -- **Body:** `text-sm text-white/70` - -### Quick Add UX Pattern - -All quick-add forms (shopping, tasks, etc.) must follow these behaviors for rapid data entry: - -**On Successful Submission:** -1. **Clear input** - Reset the form/input field immediately -2. **Refocus input** - Return focus to the text input for the next entry -3. **Visual feedback** - Brief flash (green background, 300ms) to confirm success -4. **Update list** - New item appears in the list immediately - -**On Error:** -1. **Preserve input** - Do not clear the input so user can retry -2. **Show error** - Display error message near the input -3. **Maintain focus** - Keep focus on the input - -**Checked/Completed Items:** -- Completed items are immediately removed from view (not just crossed out) -- User items: Deleted from database -- External items (Trello, PlanToEat): Marked as checked, filtered from display - ---- - -## API Integrations - -### Summary Table - -| Service | Auth Method | Config Var | Read | Write | Notes | -|---------|-------------|------------|------|-------|-------| -| Todoist | Bearer token | `TODOIST_API_KEY` | ✓ Tasks, Projects | ✓ Create, Complete | Incremental sync available | -| Trello | Key + Token | `TRELLO_API_KEY`, `TRELLO_TOKEN` | ✓ Boards, Cards, Lists | ✓ Create, Archive | Concurrent board fetching | -| PlanToEat | Session cookie | `PLANTOEAT_SESSION` | ✓ Meals, Shopping | ✗ | Web scraping (brittle) | -| Google Calendar | OAuth file | `GOOGLE_CREDENTIALS_FILE` | ✓ Events | ✗ | Multi-calendar support | -| Google Tasks | OAuth file | `GOOGLE_CREDENTIALS_FILE` | ✓ Tasks | ✓ Complete | Shared credentials with Calendar | - -### Interface Pattern - -All API clients implement interfaces (in `internal/api/interfaces.go`): - -```go -type TodoistAPI interface { - GetTasks(ctx context.Context) ([]models.Task, error) - GetProjects(ctx context.Context) ([]models.Project, error) - CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error) - CompleteTask(ctx context.Context, taskID string) error - ReopenTask(ctx context.Context, taskID string) error - // ... -} -``` - -Compile-time enforcement: -```go -var _ TodoistAPI = (*TodoistClient)(nil) -``` - -### Timezone Handling - -All times normalized to display timezone: -```go -config.SetDisplayTimezone("Pacific/Honolulu") // Set at startup -config.Now() // Current time in display TZ -config.Today() // Current date in display TZ -config.ToDisplayTZ(t) // Convert any time -config.ParseDateInDisplayTZ("2026-01-27") // Parse in display TZ -``` - ---- - -## Features - -### Timeline View (`/tabs/timeline`) - -Chronological view of all upcoming items grouped by day section. - -**Data Sources:** Todoist, Trello, PlanToEat, Google Calendar, Google Tasks - -**Organization:** -- **Today** (expanded, collapsible) - Items due today -- **Tomorrow** (expanded, collapsible) - Items due tomorrow -- **Later** (collapsed) - Items 2+ days out - -**Item Display:** -- Color-coded dot (blue=event, orange=meal, green=task, yellow=gtask) -- Time display (hidden for all-day items at midnight) -- Checkbox for completable items -- Title + description (2-line clamp) -- External link icon - -### Tasks View (`/tabs/tasks`) - -Unified task grid showing Atoms (normalized tasks from all sources). - -**Layout:** 3-column responsive grid - -**Features:** -- Priority sorting (overdue → today → future → no date) -- Source icons (🔴 Todoist, 📋 Trello, 🐛 Bug) -- Expandable descriptions -- Collapsible "Future" section - -### Shopping View (`/tabs/shopping`) - -Aggregated shopping lists from Trello + PlanToEat + user items. - -**Tab Mode:** Store sections with items, quick-add form - -**Shopping Mode (`/shopping/mode/{store}`):** Full-screen mobile-optimized view -- Store switcher (horizontal tabs) -- Large touch targets -- Fixed bottom quick-add - -### Meals View (`/tabs/meals`) - -PlanToEat meal schedule for the next 7 days. - -**Features:** -- Meals grouped by date + meal type (breakfast/lunch/dinner) -- Multiple recipes for same slot combined with " + " separator -- Sorted by date, then meal type order - -### Conditions Page (`/conditions`) - -**Public route** - no authentication required. - -Standalone live feeds page with: -- 3 Kilauea volcano webcams (USGS) -- 2 Hawaii weather maps (Windy.com) -- 1 National weather map - -Auto-refresh webcam images every 60 seconds. - -### Agent Context API (`/agent/*`) - -External agent authentication and data access system. Allows CLI tools, automation scripts, and AI assistants to access dashboard context securely. - -**Authentication Flow:** -1. Agent sends `POST /agent/auth/request` with name + agent_id (UUID) -2. Dashboard checks trust level: - - **TRUSTED**: Auto-approve if agent_id was previously approved - - **UNKNOWN**: Send WebSocket notification to connected browsers - - **BLOCKED**: Reject immediately -3. User approves/denies via modal in browser (pushed via WebSocket) -4. Agent polls `GET /agent/auth/poll?token=REQUEST_TOKEN` for result -5. On approval, agent receives session_token (1-hour TTL) -6. Agent uses session_token in Authorization header for context access - -**Endpoints:** - -| Method | Path | Auth | Purpose | -|--------|------|------|---------| -| POST | `/agent/auth/request` | None | Request access (returns request_token) | -| GET | `/agent/auth/poll` | None | Poll for approval status | -| POST | `/agent/auth/approve` | Browser session | User approves agent request | -| POST | `/agent/auth/deny` | Browser session | User denies agent request | -| GET | `/agent/context` | Agent session | Full timeline context (7 days back, 14 forward) | -| GET | `/agent/web/request` | None | HTML auth flow for browser-only agents | -| GET | `/agent/web/status` | None | HTML status page | -| GET | `/agent/web/context` | None | HTML context view | - -**WebSocket Notifications (`/ws/notifications`):** -- Pushes agent auth requests to connected browsers in real-time -- JSON protocol with auto-reconnect on disconnect -- Frontend handler in `web/static/js/app.js` - -**Security:** -- Rate limit: 10 requests/minute per IP on auth endpoints -- Session TTL: 1 hour, one active session per agent_id -- Impersonation detection: Rejects if name/agent_id mismatch previous binding - -**Implementation:** `internal/handlers/agent.go`, `internal/handlers/websocket.go` - -### Completed Tasks Log - -Tracks task completion history for audit trail and agent context. - -**Purpose:** -- Provides completion history in agent context API -- Enables future analytics/reporting -- Audit trail of completed work - -**Storage:** `completed_tasks` table (source, source_id, title, due_date, completed_at) - -**Feature Toggle:** `completed_log` (enabled by default) - -**Populated by:** `HandleCompleteAtom()` on task/card completion - ---- - -## Database Schema - -**Engine:** SQLite with WAL mode (concurrent reads) - -**Key Tables:** - -| Table | Purpose | Key Columns | -|-------|---------|-------------| -| `tasks` | Todoist cache | id, content, due_date, priority, completed | -| `boards` | Trello boards | id, name | -| `cards` | Trello cards | id, name, board_id, list_id, due_date | -| `meals` | PlanToEat meals | id, recipe_name, date, meal_type | -| `users` | Authentication | id, username, password_hash | -| `sessions` | SCS sessions | token, data, expiry | -| `bugs` | Bug reports | id, description, resolved_at | -| `user_shopping_items` | User-added items | id, name, store, checked | -| `shopping_item_checks` | External item state | source, item_id, checked | -| `cache_metadata` | Cache timestamps | key, last_fetch, ttl_minutes | -| `sync_tokens` | Incremental sync | service, token | -| `agents` | Registered agents | name, agent_id, trust_level, last_seen | -| `agent_sessions` | Agent auth sessions | agent_id, request_token, session_token, status, expires_at | -| `completed_tasks` | Completion log | source, source_id, title, due_date, completed_at | -| `feature_toggles` | Feature flags | name, description, enabled | -| `source_config` | Source filtering | source, item_type, item_id, item_name, enabled | - ---- - -## Development Guide for Claude Code - -### Getting Started - -1. **Read this file first** - Understand architecture and patterns -2. **Check `CLAUDE.md`** - Project-specific instructions -3. **Run tests before changes:** `go test ./...` -4. **Run after changes:** `go test ./...` then `go build` - -### Key Principles - -1. **Cache-First, Fail-Soft:** Never hard-fail if an API is down. Return cached data. -2. **Minimal Changes:** Surgical edits only. Don't refactor unrelated code. -3. **Interface-Driven:** API clients implement interfaces for testability. -4. **HTMX for Updates:** Use partials and `hx-swap` for reactive UI. - -### Common Patterns - -**Adding a new handler:** -```go -// internal/handlers/handlers.go -func (h *Handler) HandleNewFeature(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // 1. Parse input - if err := r.ParseForm(); err != nil { - JSONError(w, http.StatusBadRequest, "Parse failed", err) - return - } - - // 2. Validate - value := r.FormValue("key") - if value == "" { - JSONError(w, http.StatusBadRequest, "Missing key", nil) - return - } - - // 3. Business logic - result, err := h.apiClient.DoSomething(ctx, value) - if err != nil { - JSONError(w, http.StatusInternalServerError, "Failed", err) - return - } - - // 4. Save to cache (best-effort) - _ = h.store.SaveResult(result) - - // 5. Respond - HTMLResponse(w, h.renderer, "partial-name", result) -} -``` - -**Adding a new API method:** -```go -// 1. Add to interface (internal/api/interfaces.go) -type MyAPI interface { - NewMethod(ctx context.Context, param string) (Result, error) -} - -// 2. Implement in client (internal/api/myclient.go) -func (c *MyClient) NewMethod(ctx context.Context, param string) (Result, error) { - var result Result - if err := c.Get(ctx, "/endpoint/"+param, c.authHeaders(), &result); err != nil { - return Result{}, fmt.Errorf("failed to fetch: %w", err) - } - return result, nil -} -``` - -**Adding a new template:** -```html -<!-- web/templates/partials/new-feature.html --> -{{define "new-feature"}} -<div class="space-y-4"> - {{range .Items}} - <div class="bg-card rounded-lg p-4"> - <h3 class="text-white font-medium">{{.Title}}</h3> - </div> - {{end}} -</div> -{{end}} -``` - -**Adding a new route:** -```go -// cmd/dashboard/main.go inside protected routes group -r.Get("/tabs/newfeature", h.HandleNewFeature) -r.Post("/newfeature/action", h.HandleNewAction) -``` - -### Template Naming Convention - -| Type | Location | Reference | -|------|----------|-----------| -| Standalone page | `web/templates/name.html` | `"name.html"` | -| Partial | `web/templates/partials/name.html` | `"name"` (define name) | - -**Example:** -```go -// Standalone page - use filename -h.renderer.Render(w, "shopping-mode.html", data) - -// Partial - use define name -HTMLResponse(w, h.renderer, "shopping-tab", data) -``` - -### HTMX Patterns - -**Tab content loading:** -```html -<div id="tab-content" - hx-get="/tabs/{{.ActiveTab}}" - hx-trigger="load" - hx-swap="innerHTML"> -</div> -``` - -**Form submission with swap:** -```html -<form hx-post="/complete-atom" - hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"}' - hx-target="closest div.rounded-lg" - hx-swap="outerHTML"> -``` - -**Refresh trigger:** -```javascript -// After action completes -htmx.trigger(document.body, 'refresh-tasks') -``` - -### Test Patterns - -**Unit test for store:** -```go -func TestStore_SaveTasks(t *testing.T) { - db := setupTestStore(t) - defer db.Close() - - tasks := []models.Task{{ID: "1", Content: "Test"}} - err := db.SaveTasks(tasks) - require.NoError(t, err) - - result, err := db.GetTasks() - require.NoError(t, err) - assert.Len(t, result, 1) -} -``` - -**Handler test:** -```go -func TestHandler_HandleDashboard(t *testing.T) { - h := setupTestHandler(t) - - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - - h.HandleDashboard(w, req) - - assert.Equal(t, http.StatusOK, w.Code) -} -``` - -### Debugging Tips - -1. **Production logs:** `bash scripts/logs -n 200 2>&1 | grep -i error` -2. **Production bugs:** `bash scripts/bugs` to list, `bash scripts/resolve-bug <id>` to close -3. **Cache issues:** Check `cache_metadata` table for stale timestamps -4. **API failures:** Look for `"Warning:"` or `"ERROR:"` prefixes in logs -5. **Template errors:** Check for `html/template: "name" is undefined` -6. **HTMX issues:** Browser DevTools Network tab shows all requests -7. **WebAuthn issues:** Check for "WebAuthn initialized" in startup logs; requires `WEBAUTHN_RP_ID` + `WEBAUTHN_ORIGIN` env vars - -### Files You'll Modify Most - -| Task | Primary Files | -|------|--------------| -| New feature | `handlers.go` or new handler file, `main.go`, new template | -| Bug fix | Relevant handler file or API client | -| UI change | Template in `partials/`, maybe `input.css` | -| API integration | New file in `internal/api/`, update `interfaces.go` | -| Database change | New migration in `migrations/`, update `sqlite.go` | -| Settings | `settings.go`, `settings.html` | -| Shopping | `shopping.go`, `shopping-tab.html` or `shopping-mode.html` | - -### Common Mistakes to Avoid - -1. **Don't use `"name"` for standalone pages** - Use `"name.html"` -2. **Don't forget context propagation** - All handlers should use `r.Context()` -3. **Don't panic on API errors** - Return cached data or empty slice -4. **Don't modify tests without running them** - `go test ./...` -5. **Don't add unnecessary comments** - Code should be self-explanatory -6. **Don't create new files unless necessary** - Prefer editing existing -7. **Don't refactor working code** - Only touch what's needed - -### Feature Toggles - -Feature toggles allow gradual rollout of new features and easy rollback if issues arise. - -**When to use feature toggles:** -- New features that may need adjustment after deployment -- Experimental features being tested -- Features that depend on external services that may be unreliable -- Any change that could break existing functionality - -**Creating a feature toggle:** - -1. Add a migration or use the Settings UI at `/settings` -2. Check the toggle in your handler: -```go -if h.store.IsFeatureEnabled("my_feature") { - // New behavior -} else { - // Old behavior or disabled state -} -``` - -3. Document the toggle in this file under "Active Feature Toggles" - -**Managing feature toggles:** -- Access the Settings page at `/settings` to view/toggle/create features -- Store methods: `IsFeatureEnabled()`, `SetFeatureEnabled()`, `CreateFeatureToggle()` -- Toggles are stored in the `feature_toggles` table - -**Lifecycle:** -1. **Create** toggle (disabled by default) before deploying feature -2. **Enable** after deployment if tests pass -3. **Monitor** for issues -4. **Remove** toggle and conditional code once feature is stable (usually after 1-2 weeks) - -**Active Feature Toggles:** -| Name | Description | Status | -|------|-------------|--------| -| `source_config` | Configure which boards/lists/calendars to fetch | Disabled | -| `calendar_timeline` | Show timeline as a calendar view with time slots | Disabled | -| `completed_log` | Track and display completed tasks log | Enabled | - -### Bug Management Workflow - -Use the bug tracking system to report and resolve issues: - -**Viewing bugs (production):** -```bash -./scripts/bugs -# Lists all bugs from production database with ID, description, and created_at -``` - -**Resolving a bug:** -```bash -./scripts/resolve-bug <bug_id> -# Shows bug description, then deletes it from the database -``` - -**Workflow:** -1. Bugs are reported via the dashboard UI (🐛 tab in quick-add modal) -2. Run `./scripts/bugs` to see current issues -3. Fix the bug with tests -4. Deploy the fix -5. Run `./scripts/resolve-bug <id>` to close it - -Bugs appear as atoms in the Tasks view (🐛 icon) until resolved. - -### Test Locations & Commands - -**Test locations:** -| Component | Test File | -|-----------|-----------| -| Store methods | `internal/store/sqlite_test.go` | -| Handlers | `internal/handlers/handlers_test.go` | -| Timeline logic | `internal/handlers/timeline_logic_test.go` | -| Agent handlers | `internal/handlers/agent_test.go` | -| API clients | `internal/api/*_test.go` | -| Auth | `internal/auth/*_test.go` | -| Integration | `test/acceptance_test.go` | - -**Running tests:** -```bash -# All tests -go test ./... - -# Specific package -go test ./internal/handlers/... - -# Verbose with coverage -go test -v -cover ./... - -# Single test -go test -run TestBuildTimeline ./internal/handlers/... -``` - -**Test patterns:** -```go -func TestFeature_Behavior(t *testing.T) { - // Arrange - db := setupTestStore(t) - defer db.Close() - - // Act - result, err := db.SomeMethod(input) - - // Assert - require.NoError(t, err) - assert.Equal(t, expected, result) -} -``` - -### Architecture Decision Records - -**Current ADRs:** -| ADR | Decision | -|-----|----------| -| 001 | Session-based authentication with SQLite storage | -| 002 | Polymorphic TimelineItem model for unified aggregation | -| 003 | HTMX partials for all write operations | -| 004 | Concurrent fetching with semaphore + cache fallback | -| 005 | Agent API notification-based authentication | - -**ADR template:** -```markdown -# ADR NNN: Title - -## Status -Proposed | Accepted | Deprecated | Superseded - -## Context -What is the issue or decision we need to make? - -## Decision -What did we decide and why? - -### Technical Details: -- Implementation specifics -- Key code locations - -## Consequences -- **Pros:** Benefits of this approach -- **Cons:** Tradeoffs and limitations - -## Alternatives Considered -- Option A: Why rejected -- Option B: Why rejected -``` - -**Numbering:** Use sequential 3-digit numbers (001, 002, etc.) - ---- - -## Quick Reference - -### Commands - -```bash -# Run application -go run cmd/dashboard/main.go - -# Run tests -go test ./... - -# Build binary -go build -o dashboard cmd/dashboard/main.go - -# Rebuild Tailwind CSS -npx tailwindcss -i web/static/css/input.css -o web/static/css/output.css --watch -``` - -### Key Endpoints - -| Method | Path | Purpose | -|--------|------|---------| -| GET | `/` | Main dashboard | -| GET | `/tabs/timeline` | Timeline partial | -| GET | `/tabs/tasks` | Tasks partial | -| GET | `/tabs/planning` | Planning partial | -| GET | `/tabs/meals` | Meals partial | -| GET | `/tabs/shopping` | Shopping partial | -| GET | `/tabs/conditions` | Conditions partial | -| GET | `/conditions` | Live feeds page (public) | -| POST | `/complete-atom` | Complete task/card/bug | -| POST | `/uncomplete-atom` | Reopen task/card | -| POST | `/unified-add` | Quick add task | -| GET | `/bugs` | List bug reports | -| POST | `/bugs` | Submit bug report | -| POST | `/shopping/add` | Add shopping item | -| GET | `/shopping/mode/{store}` | Shopping mode view | -| POST | `/shopping/mode/{store}/toggle` | Toggle shopping item | -| POST | `/shopping/mode/{store}/complete` | Complete shopping item | -| GET | `/settings` | Settings page | -| POST | `/settings/features` | Create feature toggle | -| POST | `/settings/features/toggle` | Toggle feature on/off | -| GET | `/settings/passkeys` | List passkeys (requires WebAuthn) | -| POST | `/passkeys/register/begin` | Start passkey registration | -| POST | `/passkeys/register/finish` | Complete passkey registration | -| GET | `/ws/notifications` | WebSocket for agent notifications | -| POST | `/agent/auth/request` | Agent requests access | -| GET | `/agent/auth/poll` | Agent polls for approval | -| POST | `/agent/auth/approve` | User approves agent | -| GET | `/agent/context` | Agent gets timeline context | - -### Handler Signature - -```go -func (h *Handler) HandleName(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - // ... implementation -} -``` - -### Response Helpers - -```go -JSONResponse(w, data) // 200 + JSON + no-cache -JSONError(w, status, "message", err) // Error + log -HTMLResponse(w, h.renderer, "name", data) // Partial render + no-cache -HTMLString(w, "<html>...</html>") // Raw HTML -``` +# Task Dashboard - Design Document
+
+## Overview
+
+Task Dashboard is a personal productivity web application that aggregates tasks, meals, calendar events, and shopping lists from multiple external services into a unified, mobile-friendly interface. Built with Go backend, HTMX frontend, Tailwind CSS styling, and SQLite storage.
+
+**Stack:** Go 1.24 | chi router | HTMX 1.x | Tailwind CSS | SQLite (WAL mode)
+
+---
+
+## Project Intent
+
+The dashboard serves as a **personal consolidation hub** designed to:
+
+1. **Aggregate multiple data sources** into one view (Trello, Todoist, PlanToEat, Google Calendar, Google Tasks, user-reported bugs)
+2. **Prioritize reliability** over speed - graceful degradation when APIs fail
+3. **Support mobile-first usage** - optimized for phone/tablet in-store or on-the-go
+4. **Minimize context switching** - complete tasks from any source without opening multiple apps
+5. **Provide live monitoring** - volcano webcams and weather for Hawaii location
+
+---
+
+## Architecture
+
+### Directory Structure
+
+```
+task-dashboard/
+├── cmd/dashboard/
+│ └── main.go # Entry point, route registration, ldflags
+├── internal/
+│ ├── api/ # External API clients
+│ │ ├── interfaces.go # API client contracts (interfaces)
+│ │ ├── http.go # BaseClient HTTP utilities
+│ │ ├── trello.go # Trello API client
+│ │ ├── todoist.go # Todoist API (REST v2 + Sync v9)
+│ │ ├── plantoeat.go # PlanToEat web scraper
+│ │ ├── google_calendar.go # Google Calendar integration
+│ │ └── google_tasks.go # Google Tasks integration
+│ ├── auth/
+│ │ ├── auth.go # Authentication service (bcrypt + WebAuthn)
+│ │ ├── handlers.go # Login/logout/passkey handlers
+│ │ └── middleware.go # Session + CSRF protection
+│ ├── config/
+│ │ ├── config.go # Environment configuration
+│ │ ├── constants.go # App constants (timeouts, limits)
+│ │ └── timezone.go # Display timezone helpers
+│ ├── handlers/
+│ │ ├── handlers.go # Main HTTP handlers + aggregation
+│ │ ├── settings.go # Settings page handlers
+│ │ ├── shopping.go # Shopping tab + mode handlers
+│ │ ├── agent.go # Agent auth + context API
+│ │ ├── websocket.go # WebSocket notification hub
+│ │ ├── timeline.go # Timeline view handler
+│ │ ├── timeline_logic.go # Timeline data processing
+│ │ ├── atoms.go # Unified task aggregation
+│ │ ├── cache.go # Generic cache fetcher pattern
+│ │ ├── helpers.go # Form parsing helpers
+│ │ ├── renderer.go # Template renderer interface
+│ │ └── response.go # JSON/HTML response utilities
+│ ├── middleware/
+│ │ └── security.go # Security headers + rate limiting
+│ ├── models/
+│ │ ├── types.go # Data models (Task, Card, Meal, Agent, etc.)
+│ │ ├── atom.go # Unified Atom model + converters
+│ │ └── timeline.go # TimelineItem + DaySection models
+│ └── store/
+│ └── sqlite.go # SQLite database layer
+├── migrations/ # SQL migration files (001-014)
+├── scripts/
+│ ├── bugs # List bugs from production DB (local)
+│ ├── deploy # Build + local deploy to /site
+│ ├── logs # Fetch production journalctl (local)
+│ └── resolve-bug # Resolve a production bug (local)
+├── web/
+```
+
+### Service Gateway
+
+Doot acts as an authenticated reverse proxy for upstream services. Any HTTP service running on localhost can be registered as a **service mount** and will be served through doot's existing auth + SSL layer.
+
+```
+Browser (HTTPS via Apache)
+ ↓
+Doot :38080 ← session auth + CSRF + origin check
+ ├── / dashboard (native doot handler)
+ ├── /conditions public live feeds (native doot handler)
+ ├── /claudomator/* ── proxy → Claudomator :8484
+ └── /playground/* ── proxy → Playground demo :9090
+```
+
+**To add a new service:**
+
+1. Set its URL in `.env`: `MYSERVICE_URL=http://127.0.0.1:PORT`
+2. Add the URL field to `internal/config/config.go`:
+ ```go
+ MyServiceURL string
+ // in Load(): MyServiceURL: os.Getenv("MYSERVICE_URL"),
+ ```
+3. Register a mount in `cmd/dashboard/main.go`:
+ ```go
+ if cfg.MyServiceURL != "" {
+ mounts = append(mounts, serviceMount{
+ name: "myservice",
+ upstream: cfg.MyServiceURL,
+ prefix: "/myservice",
+ })
+ }
+ ```
+
+**Implementation:** `internal/handlers/proxy.go` — `NewServiceProxy(targetURL, allowedOrigin, pathPrefix)`.
+
+Supports both HTTP and WebSocket (via TCP hijack). Webhook paths can be listed in `webhookPaths` to bypass session auth (they should rely on service-level HMAC/token auth instead, like Claudomator's GitHub webhook).
+
+**Proof-of-concept service:** `playground/web/server.py` — a stdlib Python server (port 9090) serving a system status page. Start it, set `PLAYGROUND_URL=http://127.0.0.1:9090`, and restart doot to see it at `/playground/`.
+
+---
+
+### Request Flow
+
+```
+Browser (HTMX request)
+ ↓
+chi Router (middleware stack)
+ ├── Logger, Recoverer, Timeout
+ ├── SecurityHeaders
+ ├── Session (LoadAndSave)
+ └── CSRFProtect
+ ↓
+Handler Function
+ ├── API Client (fetch from external service)
+ │ └── BaseClient.Get/Post (HTTP with context)
+ └── Store (SQLite cache)
+ └── Get*/Save* methods
+ ↓
+Template Rendering
+ ├── Full page: ExecuteTemplate(w, "filename.html", data)
+ └── Partial: ExecuteTemplate(w, "partial-name", data)
+ ↓
+HTML Response → HTMX swap
+```
+
+### Data Flow Patterns
+
+**Cache-First with Fallback:**
+```go
+// internal/handlers/cache.go pattern
+1. Check IsCacheValid(key) - TTL-based
+2. If valid, return cached data from SQLite
+3. If expired, fetch from API
+4. If API fails, return stale cache (graceful degradation)
+5. On success, save to cache + update metadata
+```
+
+**Unified Atom Model:**
+```go
+// internal/models/atom.go
+// Normalizes Todoist tasks, Trello cards, bugs → single Atom struct
+TaskToAtom(Task) → Atom{Source: "todoist", SourceIcon: "🔴", ...}
+CardToAtom(Card) → Atom{Source: "trello", SourceIcon: "📋", ...}
+BugToAtom(Bug) → Atom{Source: "bug", SourceIcon: "🐛", ...}
+```
+
+**Timeline Aggregation:**
+```go
+// internal/handlers/timeline_logic.go
+BuildTimeline(ctx, store, calendarClient, tasksClient, start, end)
+├── Fetch tasks with due dates (Todoist)
+├── Fetch cards with due dates (Trello)
+├── Fetch meals (PlanToEat) → apply default times
+├── Fetch events (Google Calendar)
+├── Fetch tasks (Google Tasks)
+├── Convert all to TimelineItem
+├── Compute DaySection (today/tomorrow/later)
+└── Sort by Time
+```
+
+---
+
+## Visual Design
+
+### Color Palette
+
+| Usage | Value | Tailwind |
+|-------|-------|----------|
+| Panel background | `rgba(0,0,0,0.4)` | `bg-black/40` |
+| Card background | `rgba(0,0,0,0.5)` | `bg-black/50` |
+| Card hover | `rgba(0,0,0,0.6)` | `bg-black/60` |
+| Primary text | `#ffffff` | `text-white` |
+| Secondary text | `rgba(255,255,255,0.7)` | `text-white/70` |
+| Muted text | `rgba(255,255,255,0.5)` | `text-white/50` |
+| Border subtle | `rgba(255,255,255,0.1)` | `border-white/10` |
+| Border accent | `rgba(255,255,255,0.2)` | `border-white/20` |
+| Todoist accent | `#e44332` | `border-red-500` |
+| Trello accent | `#0079bf` | `border-blue-500` |
+| PlanToEat accent | `#10b981` | `border-green-500` |
+
+### Component Patterns
+
+**Card Container:**
+```html
+<div class="bg-card bg-card-hover transition-colors rounded-lg border border-white/5">
+ <div class="flex items-start gap-3 p-3">
+ <!-- checkbox → icon → content → link -->
+ </div>
+</div>
+```
+
+**Tab Button:**
+```html
+<button class="tab-button {{if .IsActive}}tab-button-active{{end}}">
+ <span>🗓️</span> Timeline
+</button>
+```
+
+**Badge:**
+```html
+<span class="text-xs px-2 py-0.5 rounded bg-blue-900/50 text-blue-300">
+ trello
+</span>
+```
+
+**Glass Morphism:**
+- `bg-black/40 backdrop-blur-sm` for panels
+- `bg-black/60 backdrop-blur-lg` for modals
+- `box-shadow: 0 0 12px black` for depth
+
+**Z-Index Hierarchy:**
+| Layer | z-index | Element |
+|-------|---------|---------|
+| Content | default | Page content |
+| Sticky headers | z-10 | Timeline section headers |
+| FAB button | z-50 | Floating action button |
+| Modals | z-50 | Action modal, edit modal |
+| Dropdowns/Popups | z-100 | Navigation dropdown, context menus |
+
+### Typography
+
+- **Font:** Inter (300, 400, 500, 600 weights)
+- **Headings:** `font-light tracking-wide text-white`
+- **Labels:** `text-xs font-medium tracking-wider uppercase`
+- **Body:** `text-sm text-white/70`
+
+### Quick Add UX Pattern
+
+All quick-add forms (shopping, tasks, etc.) must follow these behaviors for rapid data entry:
+
+**On Successful Submission:**
+1. **Clear input** - Reset the form/input field immediately
+2. **Refocus input** - Return focus to the text input for the next entry
+3. **Visual feedback** - Brief flash (green background, 300ms) to confirm success
+4. **Update list** - New item appears in the list immediately
+
+**On Error:**
+1. **Preserve input** - Do not clear the input so user can retry
+2. **Show error** - Display error message near the input
+3. **Maintain focus** - Keep focus on the input
+
+**Checked/Completed Items:**
+- Completed items are immediately removed from view (not just crossed out)
+- User items: Deleted from database
+- External items (Trello, PlanToEat): Marked as checked, filtered from display
+
+---
+
+## API Integrations
+
+### Summary Table
+
+| Service | Auth Method | Config Var | Read | Write | Notes |
+|---------|-------------|------------|------|-------|-------|
+| Todoist | Bearer token | `TODOIST_API_KEY` | ✓ Tasks, Projects | ✓ Create, Complete | Incremental sync available |
+| Trello | Key + Token | `TRELLO_API_KEY`, `TRELLO_TOKEN` | ✓ Boards, Cards, Lists | ✓ Create, Archive | Concurrent board fetching |
+| PlanToEat | Session cookie | `PLANTOEAT_SESSION` | ✓ Meals, Shopping | ✗ | Web scraping (brittle) |
+| Google Calendar | OAuth file | `GOOGLE_CREDENTIALS_FILE` | ✓ Events | ✗ | Multi-calendar support |
+| Google Tasks | OAuth file | `GOOGLE_CREDENTIALS_FILE` | ✓ Tasks | ✓ Complete | Shared credentials with Calendar |
+
+### Interface Pattern
+
+All API clients implement interfaces (in `internal/api/interfaces.go`):
+
+```go
+type TodoistAPI interface {
+ GetTasks(ctx context.Context) ([]models.Task, error)
+ GetProjects(ctx context.Context) ([]models.Project, error)
+ CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error)
+ CompleteTask(ctx context.Context, taskID string) error
+ ReopenTask(ctx context.Context, taskID string) error
+ // ...
+}
+```
+
+Compile-time enforcement:
+```go
+var _ TodoistAPI = (*TodoistClient)(nil)
+```
+
+### Timezone Handling
+
+All times normalized to display timezone:
+```go
+config.SetDisplayTimezone("Pacific/Honolulu") // Set at startup
+config.Now() // Current time in display TZ
+config.Today() // Current date in display TZ
+config.ToDisplayTZ(t) // Convert any time
+config.ParseDateInDisplayTZ("2026-01-27") // Parse in display TZ
+```
+
+---
+
+## Features
+
+### Timeline View (`/tabs/timeline`)
+
+Chronological view of all upcoming items grouped by day section.
+
+**Data Sources:** Todoist, Trello, PlanToEat, Google Calendar, Google Tasks
+
+**Organization:**
+- **Today** (expanded, collapsible) - Items due today
+- **Tomorrow** (expanded, collapsible) - Items due tomorrow
+- **Later** (collapsed) - Items 2+ days out
+
+**Item Display:**
+- Color-coded dot (blue=event, orange=meal, green=task, yellow=gtask)
+- Time display (hidden for all-day items at midnight)
+- Checkbox for completable items
+- Title + description (2-line clamp)
+- External link icon
+
+### Tasks View (`/tabs/tasks`)
+
+Unified task grid showing Atoms (normalized tasks from all sources).
+
+**Layout:** 3-column responsive grid
+
+**Features:**
+- Priority sorting (overdue → today → future → no date)
+- Source icons (🔴 Todoist, 📋 Trello, 🐛 Bug)
+- Expandable descriptions
+- Collapsible "Future" section
+
+### Shopping View (`/tabs/shopping`)
+
+Aggregated shopping lists from Trello + PlanToEat + user items.
+
+**Tab Mode:** Store sections with items, quick-add form
+
+**Shopping Mode (`/shopping/mode/{store}`):** Full-screen mobile-optimized view
+- Store switcher (horizontal tabs)
+- Large touch targets
+- Fixed bottom quick-add
+
+### Meals View (`/tabs/meals`)
+
+PlanToEat meal schedule for the next 7 days.
+
+**Features:**
+- Meals grouped by date + meal type (breakfast/lunch/dinner)
+- Multiple recipes for same slot combined with " + " separator
+- Sorted by date, then meal type order
+
+### Conditions Page (`/conditions`)
+
+**Public route** - no authentication required.
+
+Standalone live feeds page with:
+- 3 Kilauea volcano webcams (USGS)
+- 2 Hawaii weather maps (Windy.com)
+- 1 National weather map
+
+Auto-refresh webcam images every 60 seconds.
+
+### Agent Context API (`/agent/*`)
+
+External agent authentication and data access system. Allows CLI tools, automation scripts, and AI assistants to access dashboard context securely.
+
+**Authentication Flow:**
+1. Agent sends `POST /agent/auth/request` with name + agent_id (UUID)
+2. Dashboard checks trust level:
+ - **TRUSTED**: Auto-approve if agent_id was previously approved
+ - **UNKNOWN**: Send WebSocket notification to connected browsers
+ - **BLOCKED**: Reject immediately
+3. User approves/denies via modal in browser (pushed via WebSocket)
+4. Agent polls `GET /agent/auth/poll?token=REQUEST_TOKEN` for result
+5. On approval, agent receives session_token (1-hour TTL)
+6. Agent uses session_token in Authorization header for context access
+
+**Endpoints:**
+
+| Method | Path | Auth | Purpose |
+|--------|------|------|---------|
+| POST | `/agent/auth/request` | None | Request access (returns request_token) |
+| GET | `/agent/auth/poll` | None | Poll for approval status |
+| POST | `/agent/auth/approve` | Browser session | User approves agent request |
+| POST | `/agent/auth/deny` | Browser session | User denies agent request |
+| GET | `/agent/context` | Agent session | Full timeline context (7 days back, 14 forward) |
+| GET | `/agent/web/request` | None | HTML auth flow for browser-only agents |
+| GET | `/agent/web/status` | None | HTML status page |
+| GET | `/agent/web/context` | None | HTML context view |
+
+**WebSocket Notifications (`/ws/notifications`):**
+- Pushes agent auth requests to connected browsers in real-time
+- JSON protocol with auto-reconnect on disconnect
+- Frontend handler in `web/static/js/app.js`
+
+**Security:**
+- Rate limit: 10 requests/minute per IP on auth endpoints
+- Session TTL: 1 hour, one active session per agent_id
+- Impersonation detection: Rejects if name/agent_id mismatch previous binding
+
+**Implementation:** `internal/handlers/agent.go`, `internal/handlers/websocket.go`
+
+### Completed Tasks Log
+
+Tracks task completion history for audit trail and agent context.
+
+**Purpose:**
+- Provides completion history in agent context API
+- Enables future analytics/reporting
+- Audit trail of completed work
+
+**Storage:** `completed_tasks` table (source, source_id, title, due_date, completed_at)
+
+**Feature Toggle:** `completed_log` (enabled by default)
+
+**Populated by:** `HandleCompleteAtom()` on task/card completion
+
+---
+
+## Database Schema
+
+**Engine:** SQLite with WAL mode (concurrent reads)
+
+**Key Tables:**
+
+| Table | Purpose | Key Columns |
+|-------|---------|-------------|
+| `tasks` | Todoist cache | id, content, due_date, priority, completed |
+| `boards` | Trello boards | id, name |
+| `cards` | Trello cards | id, name, board_id, list_id, due_date |
+| `meals` | PlanToEat meals | id, recipe_name, date, meal_type |
+| `users` | Authentication | id, username, password_hash |
+| `sessions` | SCS sessions | token, data, expiry |
+| `bugs` | Bug reports | id, description, resolved_at |
+| `user_shopping_items` | User-added items | id, name, store, checked |
+| `shopping_item_checks` | External item state | source, item_id, checked |
+| `cache_metadata` | Cache timestamps | key, last_fetch, ttl_minutes |
+| `sync_tokens` | Incremental sync | service, token |
+| `agents` | Registered agents | name, agent_id, trust_level, last_seen |
+| `agent_sessions` | Agent auth sessions | agent_id, request_token, session_token, status, expires_at |
+| `completed_tasks` | Completion log | source, source_id, title, due_date, completed_at |
+| `feature_toggles` | Feature flags | name, description, enabled |
+| `source_config` | Source filtering | source, item_type, item_id, item_name, enabled |
+
+---
+
+## Development Guide for Claude Code
+
+### Getting Started
+
+1. **Read this file first** - Understand architecture and patterns
+2. **Check `CLAUDE.md`** - Project-specific instructions
+3. **Run tests before changes:** `go test ./...`
+4. **Run after changes:** `go test ./...` then `go build`
+
+### Key Principles
+
+1. **Cache-First, Fail-Soft:** Never hard-fail if an API is down. Return cached data.
+2. **Minimal Changes:** Surgical edits only. Don't refactor unrelated code.
+3. **Interface-Driven:** API clients implement interfaces for testability.
+4. **HTMX for Updates:** Use partials and `hx-swap` for reactive UI.
+
+### Common Patterns
+
+**Adding a new handler:**
+```go
+// internal/handlers/handlers.go
+func (h *Handler) HandleNewFeature(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ // 1. Parse input
+ if err := r.ParseForm(); err != nil {
+ JSONError(w, http.StatusBadRequest, "Parse failed", err)
+ return
+ }
+
+ // 2. Validate
+ value := r.FormValue("key")
+ if value == "" {
+ JSONError(w, http.StatusBadRequest, "Missing key", nil)
+ return
+ }
+
+ // 3. Business logic
+ result, err := h.apiClient.DoSomething(ctx, value)
+ if err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed", err)
+ return
+ }
+
+ // 4. Save to cache (best-effort)
+ _ = h.store.SaveResult(result)
+
+ // 5. Respond
+ HTMLResponse(w, h.renderer, "partial-name", result)
+}
+```
+
+**Adding a new API method:**
+```go
+// 1. Add to interface (internal/api/interfaces.go)
+type MyAPI interface {
+ NewMethod(ctx context.Context, param string) (Result, error)
+}
+
+// 2. Implement in client (internal/api/myclient.go)
+func (c *MyClient) NewMethod(ctx context.Context, param string) (Result, error) {
+ var result Result
+ if err := c.Get(ctx, "/endpoint/"+param, c.authHeaders(), &result); err != nil {
+ return Result{}, fmt.Errorf("failed to fetch: %w", err)
+ }
+ return result, nil
+}
+```
+
+**Adding a new template:**
+```html
+<!-- web/templates/partials/new-feature.html -->
+{{define "new-feature"}}
+<div class="space-y-4">
+ {{range .Items}}
+ <div class="bg-card rounded-lg p-4">
+ <h3 class="text-white font-medium">{{.Title}}</h3>
+ </div>
+ {{end}}
+</div>
+{{end}}
+```
+
+**Adding a new route:**
+```go
+// cmd/dashboard/main.go inside protected routes group
+r.Get("/tabs/newfeature", h.HandleNewFeature)
+r.Post("/newfeature/action", h.HandleNewAction)
+```
+
+### Template Naming Convention
+
+| Type | Location | Reference |
+|------|----------|-----------|
+| Standalone page | `web/templates/name.html` | `"name.html"` |
+| Partial | `web/templates/partials/name.html` | `"name"` (define name) |
+
+**Example:**
+```go
+// Standalone page - use filename
+h.renderer.Render(w, "shopping-mode.html", data)
+
+// Partial - use define name
+HTMLResponse(w, h.renderer, "shopping-tab", data)
+```
+
+### HTMX Patterns
+
+**Tab content loading:**
+```html
+<div id="tab-content"
+ hx-get="/tabs/{{.ActiveTab}}"
+ hx-trigger="load"
+ hx-swap="innerHTML">
+</div>
+```
+
+**Form submission with swap:**
+```html
+<form hx-post="/complete-atom"
+ hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"}'
+ hx-target="closest div.rounded-lg"
+ hx-swap="outerHTML">
+```
+
+**Refresh trigger:**
+```javascript
+// After action completes
+htmx.trigger(document.body, 'refresh-tasks')
+```
+
+### Test Patterns
+
+**Unit test for store:**
+```go
+func TestStore_SaveTasks(t *testing.T) {
+ db := setupTestStore(t)
+ defer db.Close()
+
+ tasks := []models.Task{{ID: "1", Content: "Test"}}
+ err := db.SaveTasks(tasks)
+ require.NoError(t, err)
+
+ result, err := db.GetTasks()
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+}
+```
+
+**Handler test:**
+```go
+func TestHandler_HandleDashboard(t *testing.T) {
+ h := setupTestHandler(t)
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ w := httptest.NewRecorder()
+
+ h.HandleDashboard(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+}
+```
+
+### Debugging Tips
+
+1. **Production logs:** `bash scripts/logs -n 200 2>&1 | grep -i error`
+2. **Production bugs:** `bash scripts/bugs` to list, `bash scripts/resolve-bug <id>` to close
+3. **Cache issues:** Check `cache_metadata` table for stale timestamps
+4. **API failures:** Look for `"Warning:"` or `"ERROR:"` prefixes in logs
+5. **Template errors:** Check for `html/template: "name" is undefined`
+6. **HTMX issues:** Browser DevTools Network tab shows all requests
+7. **WebAuthn issues:** Check for "WebAuthn initialized" in startup logs; requires `WEBAUTHN_RP_ID` + `WEBAUTHN_ORIGIN` env vars
+
+### Files You'll Modify Most
+
+| Task | Primary Files |
+|------|--------------|
+| New feature | `handlers.go` or new handler file, `main.go`, new template |
+| Bug fix | Relevant handler file or API client |
+| UI change | Template in `partials/`, maybe `input.css` |
+| API integration | New file in `internal/api/`, update `interfaces.go` |
+| Database change | New migration in `migrations/`, update `sqlite.go` |
+| Settings | `settings.go`, `settings.html` |
+| Shopping | `shopping.go`, `shopping-tab.html` or `shopping-mode.html` |
+
+### Common Mistakes to Avoid
+
+1. **Don't use `"name"` for standalone pages** - Use `"name.html"`
+2. **Don't forget context propagation** - All handlers should use `r.Context()`
+3. **Don't panic on API errors** - Return cached data or empty slice
+4. **Don't modify tests without running them** - `go test ./...`
+5. **Don't add unnecessary comments** - Code should be self-explanatory
+6. **Don't create new files unless necessary** - Prefer editing existing
+7. **Don't refactor working code** - Only touch what's needed
+
+### Feature Toggles
+
+Feature toggles allow gradual rollout of new features and easy rollback if issues arise.
+
+**When to use feature toggles:**
+- New features that may need adjustment after deployment
+- Experimental features being tested
+- Features that depend on external services that may be unreliable
+- Any change that could break existing functionality
+
+**Creating a feature toggle:**
+
+1. Add a migration or use the Settings UI at `/settings`
+2. Check the toggle in your handler:
+```go
+if h.store.IsFeatureEnabled("my_feature") {
+ // New behavior
+} else {
+ // Old behavior or disabled state
+}
+```
+
+3. Document the toggle in this file under "Active Feature Toggles"
+
+**Managing feature toggles:**
+- Access the Settings page at `/settings` to view/toggle/create features
+- Store methods: `IsFeatureEnabled()`, `SetFeatureEnabled()`, `CreateFeatureToggle()`
+- Toggles are stored in the `feature_toggles` table
+
+**Lifecycle:**
+1. **Create** toggle (disabled by default) before deploying feature
+2. **Enable** after deployment if tests pass
+3. **Monitor** for issues
+4. **Remove** toggle and conditional code once feature is stable (usually after 1-2 weeks)
+
+**Active Feature Toggles:**
+| Name | Description | Status |
+|------|-------------|--------|
+| `source_config` | Configure which boards/lists/calendars to fetch | Disabled |
+| `calendar_timeline` | Show timeline as a calendar view with time slots | Disabled |
+| `completed_log` | Track and display completed tasks log | Enabled |
+
+### Bug Management Workflow
+
+Use the bug tracking system to report and resolve issues:
+
+**Viewing bugs (production):**
+```bash
+./scripts/bugs
+# Lists all bugs from production database with ID, description, and created_at
+```
+
+**Resolving a bug:**
+```bash
+./scripts/resolve-bug <bug_id>
+# Shows bug description, then deletes it from the database
+```
+
+**Workflow:**
+1. Bugs are reported via the dashboard UI (🐛 tab in quick-add modal)
+2. Run `./scripts/bugs` to see current issues
+3. Fix the bug with tests
+4. Deploy the fix
+5. Run `./scripts/resolve-bug <id>` to close it
+
+Bugs appear as atoms in the Tasks view (🐛 icon) until resolved.
+
+### Test Locations & Commands
+
+**Test locations:**
+| Component | Test File |
+|-----------|-----------|
+| Store methods | `internal/store/sqlite_test.go` |
+| Handlers | `internal/handlers/handlers_test.go` |
+| Timeline logic | `internal/handlers/timeline_logic_test.go` |
+| Agent handlers | `internal/handlers/agent_test.go` |
+| API clients | `internal/api/*_test.go` |
+| Auth | `internal/auth/*_test.go` |
+| Integration | `test/acceptance_test.go` |
+
+**Running tests:**
+```bash
+# All tests
+go test ./...
+
+# Specific package
+go test ./internal/handlers/...
+
+# Verbose with coverage
+go test -v -cover ./...
+
+# Single test
+go test -run TestBuildTimeline ./internal/handlers/...
+```
+
+**Test patterns:**
+```go
+func TestFeature_Behavior(t *testing.T) {
+ // Arrange
+ db := setupTestStore(t)
+ defer db.Close()
+
+ // Act
+ result, err := db.SomeMethod(input)
+
+ // Assert
+ require.NoError(t, err)
+ assert.Equal(t, expected, result)
+}
+```
+
+### Architecture Decision Records
+
+**Current ADRs:**
+| ADR | Decision |
+|-----|----------|
+| 001 | Session-based authentication with SQLite storage |
+| 002 | Polymorphic TimelineItem model for unified aggregation |
+| 003 | HTMX partials for all write operations |
+| 004 | Concurrent fetching with semaphore + cache fallback |
+| 005 | Agent API notification-based authentication |
+
+**ADR template:**
+```markdown
+# ADR NNN: Title
+
+## Status
+Proposed | Accepted | Deprecated | Superseded
+
+## Context
+What is the issue or decision we need to make?
+
+## Decision
+What did we decide and why?
+
+### Technical Details:
+- Implementation specifics
+- Key code locations
+
+## Consequences
+- **Pros:** Benefits of this approach
+- **Cons:** Tradeoffs and limitations
+
+## Alternatives Considered
+- Option A: Why rejected
+- Option B: Why rejected
+```
+
+**Numbering:** Use sequential 3-digit numbers (001, 002, etc.)
+
+---
+
+## Quick Reference
+
+### Commands
+
+```bash
+# Run application
+go run cmd/dashboard/main.go
+
+# Run tests
+go test ./...
+
+# Build binary
+go build -o dashboard cmd/dashboard/main.go
+
+# Rebuild Tailwind CSS
+npx tailwindcss -i web/static/css/input.css -o web/static/css/output.css --watch
+```
+
+### Key Endpoints
+
+| Method | Path | Purpose |
+|--------|------|---------|
+| GET | `/` | Main dashboard |
+| GET | `/tabs/timeline` | Timeline partial |
+| GET | `/tabs/tasks` | Tasks partial |
+| GET | `/tabs/planning` | Planning partial |
+| GET | `/tabs/meals` | Meals partial |
+| GET | `/tabs/shopping` | Shopping partial |
+| GET | `/tabs/conditions` | Conditions partial |
+| GET | `/conditions` | Live feeds page (public) |
+| POST | `/complete-atom` | Complete task/card/bug |
+| POST | `/uncomplete-atom` | Reopen task/card |
+| POST | `/unified-add` | Quick add task |
+| GET | `/bugs` | List bug reports |
+| POST | `/bugs` | Submit bug report |
+| POST | `/shopping/add` | Add shopping item |
+| GET | `/shopping/mode/{store}` | Shopping mode view |
+| POST | `/shopping/mode/{store}/toggle` | Toggle shopping item |
+| POST | `/shopping/mode/{store}/complete` | Complete shopping item |
+| GET | `/settings` | Settings page |
+| POST | `/settings/features` | Create feature toggle |
+| POST | `/settings/features/toggle` | Toggle feature on/off |
+| GET | `/settings/passkeys` | List passkeys (requires WebAuthn) |
+| POST | `/passkeys/register/begin` | Start passkey registration |
+| POST | `/passkeys/register/finish` | Complete passkey registration |
+| GET | `/ws/notifications` | WebSocket for agent notifications |
+| POST | `/agent/auth/request` | Agent requests access |
+| GET | `/agent/auth/poll` | Agent polls for approval |
+| POST | `/agent/auth/approve` | User approves agent |
+| GET | `/agent/context` | Agent gets timeline context |
+
+### Handler Signature
+
+```go
+func (h *Handler) HandleName(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ // ... implementation
+}
+```
+
+### Response Helpers
+
+```go
+JSONResponse(w, data) // 200 + JSON + no-cache
+JSONError(w, status, "message", err) // Error + log
+HTMLResponse(w, h.renderer, "name", data) // Partial render + no-cache
+HTMLString(w, "<html>...</html>") // Raw HTML
+```
diff --git a/.env.example b/.env.example index e54a20b..fa70f68 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,9 @@ CACHE_TTL_MINUTES=5 # Development Settings # Set to "true" to enable debug logging DEBUG=false + +# Service Gateway — additional upstream services proxied behind doot's auth + SSL +# Each service is exposed at /prefix/* and requires a logged-in session. +# To add a new service: set its URL here and register a mount in cmd/dashboard/main.go. +# CLAUDOMATOR_URL=http://127.0.0.1:8484 # already wired; override if running elsewhere +# PLAYGROUND_URL=http://127.0.0.1:9090 # playground demo (playground/web/server.py) diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index c648ede..596932a 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -163,8 +163,29 @@ func main() { r.Use(appmiddleware.SecurityHeaders(cfg.Debug)) // Security headers r.Use(sessionManager.LoadAndSave) // Session middleware must be applied globally - // Initialize Claudomator reverse proxy - claudomatorProxy := handlers.NewClaudomatorProxy(cfg.ClaudomatorURL, cfg.WebAuthnOrigin) + // Service gateway — upstream services proxied through doot's auth + SSL layer. + // Each mount registers a path prefix, upstream URL, and any public webhook paths. + type serviceMount struct { + name string + upstream string + prefix string + webhookPaths []string // POST paths that bypass session auth (e.g. HMAC-signed webhooks) + } + mounts := []serviceMount{ + { + name: "claudomator", + upstream: cfg.ClaudomatorURL, + prefix: "/claudomator", + webhookPaths: []string{"/claudomator/api/webhooks/github"}, + }, + } + if cfg.PlaygroundURL != "" { + mounts = append(mounts, serviceMount{ + name: "playground", + upstream: cfg.PlaygroundURL, + prefix: "/playground", + }) + } // Rate limiter for auth endpoints authRateLimiter := appmiddleware.NewRateLimiter(config.AuthRateLimitRequests, config.AuthRateLimitWindow) @@ -225,21 +246,29 @@ func main() { }) }) - // Claudomator proxy routes - // /claudomator (no trailing slash) -> 301 redirect - r.Get("/claudomator", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/claudomator/", http.StatusMovedPermanently) - }) + // Register service gateway mounts — each upstream proxied behind doot's auth layer. + for _, m := range mounts { + proxy := handlers.NewServiceProxy(m.upstream, cfg.WebAuthnOrigin, m.prefix) - // GitHub webhook: no auth (GitHub POSTs with HMAC, no session) - r.Post("/claudomator/api/webhooks/github", claudomatorProxy.ServeHTTP) + // Bare prefix → redirect to prefix/ + prefix := m.prefix + r.Get(prefix, func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, prefix+"/", http.StatusMovedPermanently) + }) + + // Public webhook paths bypass session auth (rely on service-level HMAC/token) + for _, wp := range m.webhookPaths { + r.Post(wp, proxy.ServeHTTP) + } + + // All other routes require auth + origin check (CSRF protection for proxy) + r.Group(func(r chi.Router) { + r.Use(authHandlers.Middleware().RequireAuth) + r.Use(auth.OriginCheck(cfg.WebAuthnOrigin)) + r.Handle(prefix+"/*", proxy) + }) + } - // All other Claudomator routes: RequireAuth + origin check (CSRF protection for proxy) - r.Group(func(r chi.Router) { - r.Use(authHandlers.Middleware().RequireAuth) - r.Use(auth.OriginCheck(cfg.WebAuthnOrigin)) - r.Handle("/claudomator/*", claudomatorProxy) - }) // Protected routes (auth required) r.Group(func(r chi.Router) { diff --git a/internal/config/config.go b/internal/config/config.go index d3770f1..fe7bca5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,6 +42,9 @@ type Config struct { // Claudomator ClaudomatorURL string // URL of Claudomator service + + // Service gateway — additional upstream services proxied through doot's auth layer + PlaygroundURL string // URL of playground demo service (optional) } // Load reads configuration from environment variables @@ -81,6 +84,9 @@ func Load() (*Config, error) { // Claudomator ClaudomatorURL: getEnvWithDefault("CLAUDOMATOR_URL", "http://127.0.0.1:8484"), + + // Service gateway + PlaygroundURL: os.Getenv("PLAYGROUND_URL"), } // Validate required fields diff --git a/internal/handlers/claudomator_proxy.go b/internal/handlers/proxy.go index f3f0cd2..ce10a6a 100644 --- a/internal/handlers/claudomator_proxy.go +++ b/internal/handlers/proxy.go @@ -9,38 +9,34 @@ import ( "strings" ) -// NewClaudomatorProxy returns an http.Handler that reverse-proxies requests to -// targetURL, stripping the "/claudomator" prefix from the path. WebSocket -// upgrade requests are handled via raw TCP hijacking to support long-lived -// connections. -func NewClaudomatorProxy(targetURL, allowedOrigin string) http.Handler { +// NewServiceProxy returns an http.Handler that reverse-proxies requests to +// targetURL, stripping pathPrefix from the request path before forwarding. +// WebSocket upgrade requests are handled via raw TCP hijacking to support +// long-lived connections. +func NewServiceProxy(targetURL, allowedOrigin, pathPrefix string) http.Handler { target, err := url.Parse(targetURL) if err != nil { - panic("claudomator: invalid target URL: " + err.Error()) + panic("service proxy: invalid target URL: " + err.Error()) + } + + stripPrefix := func(p string) string { + s := strings.TrimPrefix(p, pathPrefix) + if s == "" { + s = "/" + } + return s } rp := &httputil.ReverseProxy{ Director: func(req *http.Request) { req.URL.Scheme = target.Scheme req.URL.Host = target.Host - - // Strip /claudomator prefix - stripped := strings.TrimPrefix(req.URL.Path, "/claudomator") - if stripped == "" { - stripped = "/" - } - req.URL.Path = stripped - + req.URL.Path = stripPrefix(req.URL.Path) if req.URL.RawPath != "" { - rawStripped := strings.TrimPrefix(req.URL.RawPath, "/claudomator") - if rawStripped == "" { - rawStripped = "/" - } - req.URL.RawPath = rawStripped + req.URL.RawPath = stripPrefix(req.URL.RawPath) } }, ModifyResponse: func(resp *http.Response) error { - // Preserve Service-Worker-Allowed header if swa := resp.Header.Get("Service-Worker-Allowed"); swa != "" { resp.Header.Set("Service-Worker-Allowed", swa) } @@ -54,7 +50,7 @@ func NewClaudomatorProxy(targetURL, allowedOrigin string) http.Handler { http.Error(w, "forbidden", http.StatusForbidden) return } - proxyWebSocket(w, r, target) + proxyWebSocket(w, r, target, pathPrefix) return } rp.ServeHTTP(w, r) @@ -62,8 +58,7 @@ func NewClaudomatorProxy(targetURL, allowedOrigin string) http.Handler { } // proxyWebSocket handles WebSocket upgrade via raw TCP hijacking. -func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL) { - // Determine host:port for dialing +func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL, pathPrefix string) { host := target.Host if target.Port() == "" { switch target.Scheme { @@ -81,16 +76,15 @@ func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL) { } defer upstream.Close() - // Rewrite path on the request before forwarding r.URL.Scheme = target.Scheme r.URL.Host = target.Host - stripped := strings.TrimPrefix(r.URL.Path, "/claudomator") + stripped := strings.TrimPrefix(r.URL.Path, pathPrefix) if stripped == "" { stripped = "/" } r.URL.Path = stripped if r.URL.RawPath != "" { - rawStripped := strings.TrimPrefix(r.URL.RawPath, "/claudomator") + rawStripped := strings.TrimPrefix(r.URL.RawPath, pathPrefix) if rawStripped == "" { rawStripped = "/" } @@ -98,13 +92,11 @@ func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL) { } r.RequestURI = r.URL.RequestURI() - // Write the HTTP request to the upstream connection if err := r.Write(upstream); err != nil { http.Error(w, "bad gateway", http.StatusBadGateway) return } - // Hijack the client connection hijacker, ok := w.(http.Hijacker) if !ok { http.Error(w, "websocket not supported", http.StatusInternalServerError) @@ -116,7 +108,6 @@ func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL) { } defer clientConn.Close() - // Bidirectional copy — no deadlines so long-lived WS connections survive done := make(chan struct{}, 2) go func() { _, _ = io.Copy(upstream, clientConn) diff --git a/internal/handlers/claudomator_proxy_test.go b/internal/handlers/proxy_test.go index bb2842a..69d701d 100644 --- a/internal/handlers/claudomator_proxy_test.go +++ b/internal/handlers/proxy_test.go @@ -10,13 +10,13 @@ import ( "testing" ) -func TestClaudomatorProxy_HTTP_Forward(t *testing.T) { +func TestServiceProxy_HTTP_Forward(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "hello") })) defer backend.Close() - proxy := NewClaudomatorProxy(backend.URL) + proxy := NewServiceProxy(backend.URL, "", "/claudomator") req := httptest.NewRequest("GET", "/claudomator/", nil) rr := httptest.NewRecorder() @@ -30,7 +30,7 @@ func TestClaudomatorProxy_HTTP_Forward(t *testing.T) { } } -func TestClaudomatorProxy_StripPrefix(t *testing.T) { +func TestServiceProxy_StripPrefix(t *testing.T) { var seenPath string backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seenPath = r.URL.Path @@ -38,7 +38,7 @@ func TestClaudomatorProxy_StripPrefix(t *testing.T) { })) defer backend.Close() - proxy := NewClaudomatorProxy(backend.URL) + proxy := NewServiceProxy(backend.URL, "", "/claudomator") req := httptest.NewRequest("GET", "/claudomator/api/tasks", nil) rr := httptest.NewRecorder() @@ -49,7 +49,7 @@ func TestClaudomatorProxy_StripPrefix(t *testing.T) { } } -func TestClaudomatorProxy_RawPathStrip(t *testing.T) { +func TestServiceProxy_RawPathStrip(t *testing.T) { var seenRawPath string backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seenRawPath = r.URL.RawPath @@ -57,26 +57,25 @@ func TestClaudomatorProxy_RawPathStrip(t *testing.T) { })) defer backend.Close() - proxy := NewClaudomatorProxy(backend.URL) + proxy := NewServiceProxy(backend.URL, "", "/claudomator") req := httptest.NewRequest("GET", "/claudomator/api/tasks%2Ffoo", nil) rr := httptest.NewRecorder() proxy.ServeHTTP(rr, req) - // RawPath should have /claudomator stripped if strings.HasPrefix(seenRawPath, "/claudomator") { t.Errorf("upstream still sees /claudomator prefix in RawPath: %q", seenRawPath) } } -func TestClaudomatorProxy_ServiceWorkerHeader(t *testing.T) { +func TestServiceProxy_ServiceWorkerHeader(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Service-Worker-Allowed", "/") w.WriteHeader(http.StatusOK) })) defer backend.Close() - proxy := NewClaudomatorProxy(backend.URL) + proxy := NewServiceProxy(backend.URL, "", "/claudomator") req := httptest.NewRequest("GET", "/claudomator/sw.js", nil) rr := httptest.NewRecorder() @@ -87,6 +86,25 @@ func TestClaudomatorProxy_ServiceWorkerHeader(t *testing.T) { } } +func TestServiceProxy_DifferentPrefix(t *testing.T) { + var seenPath string + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + proxy := NewServiceProxy(backend.URL, "", "/playground") + + req := httptest.NewRequest("GET", "/playground/status", nil) + rr := httptest.NewRecorder() + proxy.ServeHTTP(rr, req) + + if seenPath != "/status" { + t.Errorf("expected upstream to see /status, got %q", seenPath) + } +} + // responseHijacker wraps httptest.ResponseRecorder and implements http.Hijacker // so WebSocket hijack tests work without a real TCP connection. type responseHijacker struct { @@ -99,8 +117,7 @@ func (h *responseHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) { return h.serverConn, rw, nil } -func TestClaudomatorProxy_WebSocket(t *testing.T) { - // Stand up a minimal WebSocket echo backend that speaks raw HTTP upgrade +func TestServiceProxy_WebSocket(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) @@ -116,18 +133,15 @@ func TestClaudomatorProxy_WebSocket(t *testing.T) { } defer conn.Close() - // Read the HTTP upgrade request from the proxy buf := make([]byte, 4096) n, err := conn.Read(buf) if err != nil || n == 0 { return } - // Send a minimal HTTP 101 response so the proxy considers this a WS resp := "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n" _, _ = conn.Write([]byte(resp)) - // Echo one message: read "ping", write "pong" msgBuf := make([]byte, 64) n, _ = conn.Read(msgBuf) if n > 0 { @@ -137,9 +151,8 @@ func TestClaudomatorProxy_WebSocket(t *testing.T) { targetURL := "http://" + listener.Addr().String() - proxy := NewClaudomatorProxy(targetURL) + proxy := NewServiceProxy(targetURL, "", "/claudomator") - // Build a client-side TCP pipe to simulate the browser side clientSide, serverSide := net.Pipe() defer clientSide.Close() @@ -156,13 +169,11 @@ func TestClaudomatorProxy_WebSocket(t *testing.T) { proxy.ServeHTTP(rh, req) }() - // Write a message from the "browser" side and read the echo _, err = clientSide.Write([]byte("ping")) if err != nil { t.Fatal(err) } - // Read back — we expect "pong" from the echo server (or at least some data) got := make([]byte, 64) n, _ := clientSide.Read(got) if n == 0 { |
