From e25f8dc89e0ec5134e5b79ceaf9035c32810aca2 Mon Sep 17 00:00:00 2001 From: reVrost Date: Sun, 25 Jan 2026 19:01:51 +1100 Subject: [PATCH 1/3] fix: linting --- cmd/app/main.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/app/main.go b/cmd/app/main.go index da1046b..4a05390 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -23,6 +23,10 @@ import ( "github.com/revrost/code/counterspell/ui" ) +type contextKey string + +const subdomainKey contextKey = "subdomain" + func main() { // Parse flags addr := flag.String("addr", ":8710", "Server address") @@ -114,7 +118,7 @@ func main() { subdomain = parts[0] } - ctx := context.WithValue(r.Context(), "subdomain", subdomain) + ctx := context.WithValue(r.Context(), subdomainKey, subdomain) next.ServeHTTP(w, r.WithContext(ctx)) }) }) @@ -258,7 +262,7 @@ func spaHandler(fsys fs.FS) http.HandlerFunc { // SubdomainFromContext extracts the subdomain from the request context. // Returns empty string if no subdomain is present. func SubdomainFromContext(ctx context.Context) string { - if subdomain, ok := ctx.Value("subdomain").(string); ok { + if subdomain, ok := ctx.Value(subdomainKey).(string); ok { return subdomain } return "" From 68b13f38fd6c9c7cf8766d7ce23a00b40af37988 Mon Sep 17 00:00:00 2001 From: reVrost Date: Sun, 25 Jan 2026 21:57:32 +1100 Subject: [PATCH 2/3] feat: stuff --- .env.example | 22 +-- cmd/app/main.go | 66 +++++++ internal/cli/auth.go | 124 +++++++++++++ internal/cli/callback.go | 280 +++++++++++++++++++++++++++++ internal/config/config.go | 8 + internal/db/queries/auth.sql | 20 +++ internal/db/queries/machines.sql | 17 ++ internal/db/schema.sql | 37 ++++ internal/db/sqlc/agent_runs.sql.go | 2 +- internal/db/sqlc/artifacts.sql.go | 2 +- internal/db/sqlc/auth.sql.go | 131 ++++++++++++++ internal/db/sqlc/db.go | 2 +- internal/db/sqlc/github.sql.go | 2 +- internal/db/sqlc/machines.sql.go | 129 +++++++++++++ internal/db/sqlc/messages.sql.go | 2 +- internal/db/sqlc/models.go | 23 ++- internal/db/sqlc/querier.go | 13 +- internal/db/sqlc/settings.sql.go | 2 +- internal/db/sqlc/tasks.sql.go | 2 +- internal/services/auth.go | 280 +++++++++++++++++++++++++++++ internal/services/controlplane.go | 227 +++++++++++++++++++++++ 21 files changed, 1372 insertions(+), 19 deletions(-) create mode 100644 internal/cli/auth.go create mode 100644 internal/cli/callback.go create mode 100644 internal/db/queries/auth.sql create mode 100644 internal/db/queries/machines.sql create mode 100644 internal/db/sqlc/auth.sql.go create mode 100644 internal/db/sqlc/machines.sql.go create mode 100644 internal/services/auth.go create mode 100644 internal/services/controlplane.go diff --git a/.env.example b/.env.example index a11b956..f24f733 100644 --- a/.env.example +++ b/.env.example @@ -6,10 +6,8 @@ # Database (Required) # ============================================================================= -# PostgreSQL connection string -# For local development with Docker: -# docker run -d -p 5432:5432 -e POSTGRES_DB=counterspell -e POSTGRES_PASSWORD=dev postgres:16 -DATABASE_URL=postgres://postgres:dev@localhost:5432/counterspell?sslmode=disable +# SQLite database path (local-first) +DATABASE_PATH=./data/counterspell.db # ============================================================================= # GitHub OAuth (Required for repo access) @@ -26,14 +24,18 @@ GITHUB_CLIENT_SECRET=your_github_client_secret_here GITHUB_REDIRECT_URI=http://localhost:8710/github/callback # ============================================================================= -# Supabase Auth (Optional - for multi-user mode) +# Control Plane (Required for tunneling and multi-user features) # ============================================================================= -# Leave these empty for single-user mode (all requests use "default" user) -# Get these from: https://supabase.com/dashboard +# The control plane handles auth, subdomain assignment, and tunnel routing. +# This is the hosted service at counterspell.io +# Leave empty for offline mode (no tunneling, no auth) -# SUPABASE_URL=https://xxx.supabase.co -# SUPABASE_ANON_KEY=eyJ... -# SUPABASE_JWT_SECRET=your-jwt-secret +# Control plane API URL (use https://api.counterspell.io in production) +CONTROL_PLANE_URL=http://localhost:3001 + +# Pre-configured auth token (optional - if set, skip interactive auth) +# Leave empty to trigger interactive auth flow on first run +AUTH_TOKEN= # ============================================================================= # Worker Pool Configuration diff --git a/cmd/app/main.go b/cmd/app/main.go index 4a05390..f905a1d 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -16,6 +16,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/render" + "github.com/revrost/code/counterspell/internal/cli" "github.com/revrost/code/counterspell/internal/config" "github.com/revrost/code/counterspell/internal/db" "github.com/revrost/code/counterspell/internal/handlers" @@ -83,6 +84,71 @@ func main() { os.Exit(1) } + // Initialize control plane client (if configured) + var controlPlane *services.ControlPlaneClient + if cfg.ControlPlaneURL != "" { + controlPlane = services.NewControlPlaneClient(cfg.ControlPlaneURL) + logger.Info("Control plane configured", "url", cfg.ControlPlaneURL) + } else { + logger.Info("No control plane configured, running in offline mode") + } + + // Create auth service + authService := services.NewAuthService(database, controlPlane, cfg.AuthToken) + + // Ensure machine exists in database + machineID, err := authService.EnsureMachine(ctx) + if err != nil { + logger.Error("Failed to ensure machine", "error", err) + os.Exit(1) + } + + // Check authentication and prompt if needed + if controlPlane != nil { + // Check if already authenticated + authenticated, err := authService.IsAuthenticated(ctx, machineID) + if err != nil { + logger.Error("Failed to check auth status", "error", err) + os.Exit(1) + } + + var userID string + var exchangeResp *services.ExchangeCodeResponse + + if !authenticated { + // Not authenticated - start auth flow with callback + authCallback := cli.NewAuthWithCallback(authService, controlPlane, 8711) + userID, exchangeResp, err = authCallback.Authenticate(ctx, machineID) + if err != nil { + logger.Error("Authentication failed", "error", err) + os.Exit(1) + } + + // Store auth token + if err := authService.StoreAuth(ctx, machineID, exchangeResp.JWT, exchangeResp.UserID, exchangeResp.Email, exchangeResp.ExpiresAt); err != nil { + logger.Error("Failed to store auth", "error", err) + os.Exit(1) + } + + // Register machine and get subdomain + subdomain, err := authService.RegisterMachine(ctx, exchangeResp.JWT, machineID) + if err != nil { + logger.Error("Failed to register machine", "error", err) + os.Exit(1) + } + logger.Info("Machine registered", "subdomain", subdomain) + } else { + // Already authenticated + auth, err := authService.GetStoredAuth(ctx, machineID) + if err != nil { + logger.Error("Failed to get stored auth", "error", err) + os.Exit(1) + } + userID = auth.UserID + logger.Info("Already authenticated", "user_id", userID, "machine_id", machineID) + } + } + // Create event bus eventBus := services.NewEventBus() diff --git a/internal/cli/auth.go b/internal/cli/auth.go new file mode 100644 index 0000000..473bcf9 --- /dev/null +++ b/internal/cli/auth.go @@ -0,0 +1,124 @@ +package cli + +import ( + "bufio" + "context" + "fmt" + "log/slog" + "os" + + "github.com/revrost/code/counterspell/internal/services" +) + +// AuthCLI handles authentication from CLI. +type AuthCLI struct { + authService *services.AuthService +} + +// NewAuthCLI creates a new auth CLI. +func NewAuthCLI(authService *services.AuthService) *AuthCLI { + return &AuthCLI{ + authService: authService, + } +} + +// CheckAuth checks if user is authenticated, prompts if not. +func (a *AuthCLI) CheckAuth(ctx context.Context, machineID string) (string, *services.ExchangeCodeResponse, error) { + // Check if already authenticated + authenticated, err := a.authService.IsAuthenticated(ctx, machineID) + if err != nil { + return "", nil, fmt.Errorf("failed to check auth status: %w", err) + } + + if authenticated { + auth, err := a.authService.GetStoredAuth(ctx, machineID) + if err != nil { + return "", nil, fmt.Errorf("failed to get stored auth: %w", err) + } + slog.Info("Already authenticated", "user", auth.Email, "user_id", auth.UserID) + return auth.UserID, nil, nil + } + + // Not authenticated - start auth flow + return a.startAuthFlow(ctx, machineID) +} + +// startAuthFlow starts the interactive auth flow. +func (a *AuthCLI) startAuthFlow(ctx context.Context, machineID string) (string, *services.ExchangeCodeResponse, error) { + fmt.Println("\n" + "============================================================") + fmt.Println("Welcome to Counterspell!") + fmt.Println("============================================================") + fmt.Println("\nYou need to authenticate to use Counterspell.") + fmt.Println("This allows us to:") + fmt.Println(" - Provide your personal subdomain (e.g., username.counterspell.app)") + fmt.Println(" - Create a secure tunnel to your machine") + fmt.Println(" - Manage your cloud deployments") + fmt.Println() + + // Get auth URL + authURL, state, err := a.authService.StartAuthFlow(ctx) + if err != nil { + return "", nil, fmt.Errorf("failed to get auth URL: %w", err) + } + + fmt.Printf("\n1. Visit this URL in your browser:\n\n") + fmt.Printf(" %s\n\n", authURL) + fmt.Printf("2. Log in or create an account\n") + fmt.Printf("3. After logging in, you'll receive a code\n\n") + + // Prompt for code + fmt.Print("Enter the code from the browser: ") + reader := bufio.NewReader(os.Stdin) + code, err := reader.ReadString('\n') + if err != nil { + return "", nil, fmt.Errorf("failed to read code: %w", err) + } + code = code[:len(code)-1] // Remove newline + + if code == "" { + return "", nil, fmt.Errorf("code cannot be empty") + } + + fmt.Println("\nAuthenticating...") + + // Exchange code for JWT + resp, err := a.authService.CompleteAuthFlow(ctx, code, state) + if err != nil { + return "", nil, fmt.Errorf("failed to authenticate: %w", err) + } + + // Store auth + if err := a.authService.StoreAuth(ctx, machineID, resp.JWT, resp.UserID, resp.Email, resp.ExpiresAt); err != nil { + return "", nil, fmt.Errorf("failed to store auth: %w", err) + } + + fmt.Printf("\n✓ Authenticated as %s\n", resp.Email) + fmt.Printf("✓ Your user ID: %s\n\n", resp.UserID) + + return resp.UserID, resp, nil +} + +// RegisterMachine registers the machine and returns the subdomain. +func (a *AuthCLI) RegisterMachine(ctx context.Context, machineID, userID string) (string, error) { + // Get stored auth + auth, err := a.authService.GetStoredAuth(ctx, machineID) + if err != nil { + return "", fmt.Errorf("failed to get auth: %w", err) + } + + fmt.Println("Registering your machine...") + + subdomain, err := a.authService.RegisterMachine(ctx, auth.JwtToken, machineID) + if err != nil { + return "", fmt.Errorf("failed to register machine: %w", err) + } + + fmt.Printf("✓ Machine registered!\n") + fmt.Printf("✓ Your subdomain: %s.counterspell.app\n\n", subdomain) + + fmt.Println("You can now access Counterspell from:") + fmt.Printf(" https://%s.counterspell.app\n\n", subdomain) + fmt.Println("This is a secure tunnel to your local machine.") + + return subdomain, nil +} diff --git a/internal/cli/callback.go b/internal/cli/callback.go new file mode 100644 index 0000000..55a717e --- /dev/null +++ b/internal/cli/callback.go @@ -0,0 +1,280 @@ +package cli + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "github.com/revrost/code/counterspell/internal/services" +) + +// AuthCallbackServer handles OAuth callback from browser. +type AuthCallbackServer struct { + server *http.Server + resultChan chan *AuthResult + authURL string + state string +} + +type AuthResult struct { + Code string `json:"code"` + State string `json:"state"` + Error string `json:"error,omitempty"` +} + +// NewAuthCallbackServer creates a new callback server. +func NewAuthCallbackServer(port int) *AuthCallbackServer { + mux := http.NewServeMux() + server := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 30 * time.Second, + } + + acs := &AuthCallbackServer{ + server: server, + resultChan: make(chan *AuthResult, 1), + } + + mux.HandleFunc("/callback", acs.handleCallback) + mux.HandleFunc("/ping", acs.handlePing) + + return acs +} + +// Start starts the callback server. +func (a *AuthCallbackServer) Start() error { + go func() { + if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("Callback server error", "error", err) + } + }() + return nil +} + +// Stop stops the callback server. +func (a *AuthCallbackServer) Stop(ctx context.Context) error { + return a.server.Shutdown(ctx) +} + +// WaitForResult waits for the auth callback result with timeout. +func (a *AuthCallbackServer) WaitForResult(timeout time.Duration) (*AuthResult, error) { + select { + case result := <-a.resultChan: + return result, nil + case <-time.After(timeout): + return nil, fmt.Errorf("timeout waiting for callback") + } +} + +// GetCallbackURL returns the URL that the browser should redirect to. +func (a *AuthCallbackServer) GetCallbackURL() string { + return fmt.Sprintf("http://localhost%s/callback", a.server.Addr) +} + +func (a *AuthCallbackServer) handlePing(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +func (a *AuthCallbackServer) handleCallback(w http.ResponseWriter, r *http.Request) { + // Close the resultChan when done + defer close(a.resultChan) + + var result AuthResult + + // Check for error in query params + if err := r.URL.Query().Get("error"); err != "" { + result.Error = err + a.sendResult(w, &result) + return + } + + // Get authorization code from query params + code := r.URL.Query().Get("code") + if code == "" { + result.Error = "no authorization code provided" + a.sendResult(w, &result) + return + } + + // Get state + state := r.URL.Query().Get("state") + + // Store code and state for back-end exchange + result.Code = code + result.State = state + + a.sendResult(w, &result) +} + +func (a *AuthCallbackServer) sendResult(w http.ResponseWriter, result *AuthResult) { + // Send result to channel (non-blocking) + select { + case a.resultChan <- result: + default: + } + + // Send HTML response to browser + html := ` + + + Authentication Successful + + +` + + if result.Error != "" { + html += ` +
+
+

Authentication Failed

+

` + result.Error + `

+

You can close this window and try again.

+
+ +` + } else { + html += ` +
+
+

Authentication Successful

+

Exchanging authorization code for token...

+
+ +` + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + io.WriteString(w, html) + + slog.Info("Auth callback received", "has_code", result.Code != "", "error", result.Error) +} + +// AuthWithCallback performs auth using local callback server. +type AuthWithCallback struct { + authService *services.AuthService + callbackPort int + controlPlane *services.ControlPlaneClient +} + +// NewAuthWithCallback creates auth with callback. +func NewAuthWithCallback(authService *services.AuthService, controlPlane *services.ControlPlaneClient, callbackPort int) *AuthWithCallback { + return &AuthWithCallback{ + authService: authService, + callbackPort: callbackPort, + controlPlane: controlPlane, + } +} + +// Authenticate performs auth flow with browser callback. +func (a *AuthWithCallback) Authenticate(ctx context.Context, machineID string) (string, *services.ExchangeCodeResponse, error) { + // Start callback server + callbackServer := NewAuthCallbackServer(a.callbackPort) + if err := callbackServer.Start(); err != nil { + return "", nil, fmt.Errorf("failed to start callback server: %w", err) + } + defer callbackServer.Stop(ctx) + + callbackURL := callbackServer.GetCallbackURL() + + fmt.Println("\n" + "============================================================") + fmt.Println("Welcome to Counterspell!") + fmt.Println("============================================================") + fmt.Println("\nYou need to authenticate to use Counterspell.") + fmt.Println("This allows us to:") + fmt.Println(" - Provide your personal subdomain (e.g., username.counterspell.app)") + fmt.Println(" - Create a secure tunnel to your machine") + fmt.Println(" - Manage your cloud deployments") + fmt.Println() + + // Get auth URL with our callback URL (with PKCE) + authResp, err := a.controlPlane.GetAuthURLWithPKCE( + ctx, + machineID, + callbackURL, + a.authService.GetCodeChallenge(), + a.authService.GetState(), + ) + if err != nil { + return "", nil, fmt.Errorf("failed to get auth URL: %w", err) + } + + fmt.Printf("\n1. Opening your browser...\n") + fmt.Printf("\n If it doesn't open automatically, visit:\n\n") + fmt.Printf(" %s\n\n", authResp.AuthURL) + fmt.Printf("2. Log in or create an account\n") + fmt.Printf("3. After logging in, we'll automatically exchange code for a JWT\n\n") + + // Open browser (optional - let user do it) + fmt.Printf("Waiting for authorization... (press Ctrl+C to cancel)\n") + + // Wait for callback with timeout + result, err := callbackServer.WaitForResult(5 * time.Minute) + if err != nil { + return "", nil, fmt.Errorf("timeout waiting for authentication: %w", err) + } + + if result.Error != "" { + return "", nil, fmt.Errorf("authentication failed: %s", result.Error) + } + + fmt.Println("\n✓ Authorization code received!") + fmt.Println("Exchanging code for JWT...") + + // Perform back-end code exchange (JWT never goes through URL!) + exchangeResp, err := a.authService.CompleteAuthFlow(ctx, result.Code, result.State) + if err != nil { + return "", nil, fmt.Errorf("failed to exchange code for JWT: %w", err) + } + + fmt.Println("\n✓ Successfully authenticated!") + + return exchangeResp.UserID, exchangeResp, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 8662438..b8a0535 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,6 +32,10 @@ type Config struct { // GitHub OAuth GitHubClientID string GitHubClientSecret string + + // Control Plane (counterspell.io) + ControlPlaneURL string + AuthToken string // JWT from control plane } // Load loads configuration from environment variables. @@ -59,6 +63,10 @@ func Load() *Config { // GitHub OAuth GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"), GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), + + // Control Plane (counterspell.io) + ControlPlaneURL: os.Getenv("CONTROL_PLANE_URL"), + AuthToken: os.Getenv("AUTH_TOKEN"), } log.Printf("Config loaded: DATABASE_PATH=%s, NATIVE_ALLOWLIST=%d, DATA_DIR=%d", diff --git a/internal/db/queries/auth.sql b/internal/db/queries/auth.sql new file mode 100644 index 0000000..9a3aae6 --- /dev/null +++ b/internal/db/queries/auth.sql @@ -0,0 +1,20 @@ +-- name: CreateAuth :one +INSERT INTO auth (machine_id, jwt_token, user_id, email, expires_at, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING *; + +-- name: GetAuth :one +SELECT * FROM auth WHERE machine_id = ? ORDER BY created_at DESC LIMIT 1; + +-- name: GetAuthByMachineID :one +SELECT * FROM auth WHERE machine_id = ? LIMIT 1; + +-- name: UpdateAuth :exec +UPDATE auth SET jwt_token = ?, expires_at = ?, updated_at = ? +WHERE id = ?; + +-- name: DeleteAuth :exec +DELETE FROM auth WHERE id = ?; + +-- name: DeleteAuthByMachine :exec +DELETE FROM auth WHERE machine_id = ?; diff --git a/internal/db/queries/machines.sql b/internal/db/queries/machines.sql new file mode 100644 index 0000000..15a3bdc --- /dev/null +++ b/internal/db/queries/machines.sql @@ -0,0 +1,17 @@ +-- name: CreateMachine :one +INSERT INTO machines (id, name, mode, capabilities, created_at, updated_at, last_seen_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING *; + +-- name: GetMachine :one +SELECT * FROM machines WHERE id = ? LIMIT 1; + +-- name: UpdateMachineLastSeen :exec +UPDATE machines SET last_seen_at = ?, updated_at = ? +WHERE id = ?; + +-- name: GetAllMachines :many +SELECT * FROM machines ORDER BY created_at DESC; + +-- name: DeleteMachine :exec +DELETE FROM machines WHERE id = ?; diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 80ab769..4731528 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -143,6 +143,43 @@ UPDATE github_connections SET updated_at = strftime('%s', 'now') WHERE id = new.id; END; +-- Machines: Devices running counterspell (laptop, cloud, etc.) +CREATE TABLE IF NOT EXISTS machines ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + mode TEXT CHECK(mode IN ('local', 'cloud')) NOT NULL DEFAULT 'local', + capabilities TEXT, -- JSON: {"os": "darwin", "cpus": 8} + last_seen_at INTEGER NOT NULL, -- Unix ms + created_at INTEGER NOT NULL, -- Unix ms + updated_at INTEGER NOT NULL -- Unix ms +); + +CREATE TRIGGER IF NOT EXISTS update_machines_updated_at +AFTER UPDATE ON machines +BEGIN +UPDATE machines SET updated_at = strftime('%s', 'now') +WHERE id = new.id; +END; + +-- Auth: Store JWT tokens and user info from Supabase +CREATE TABLE IF NOT EXISTS auth ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + machine_id TEXT NOT NULL REFERENCES machines(id) ON DELETE CASCADE, + jwt_token TEXT NOT NULL, + user_id TEXT NOT NULL, + email TEXT, + expires_at INTEGER NOT NULL, -- Unix ms + created_at INTEGER NOT NULL, -- Unix ms + updated_at INTEGER NOT NULL -- Unix ms +); + +CREATE TRIGGER IF NOT EXISTS update_auth_updated_at +AFTER UPDATE ON auth +BEGIN +UPDATE auth SET updated_at = strftime('%s', 'now') +WHERE id = new.id; +END; + -- Repositories: Available repos for selection CREATE TABLE IF NOT EXISTS repositories ( id TEXT PRIMARY KEY, diff --git a/internal/db/sqlc/agent_runs.sql.go b/internal/db/sqlc/agent_runs.sql.go index ef84c64..0dae27a 100644 --- a/internal/db/sqlc/agent_runs.sql.go +++ b/internal/db/sqlc/agent_runs.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.30.0 // source: agent_runs.sql package sqlc diff --git a/internal/db/sqlc/artifacts.sql.go b/internal/db/sqlc/artifacts.sql.go index 4d0c84e..b816def 100644 --- a/internal/db/sqlc/artifacts.sql.go +++ b/internal/db/sqlc/artifacts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.30.0 // source: artifacts.sql package sqlc diff --git a/internal/db/sqlc/auth.sql.go b/internal/db/sqlc/auth.sql.go new file mode 100644 index 0000000..71050fd --- /dev/null +++ b/internal/db/sqlc/auth.sql.go @@ -0,0 +1,131 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: auth.sql + +package sqlc + +import ( + "context" + "database/sql" +) + +const createAuth = `-- name: CreateAuth :one +INSERT INTO auth (machine_id, jwt_token, user_id, email, expires_at, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING id, machine_id, jwt_token, user_id, email, expires_at, created_at, updated_at +` + +type CreateAuthParams struct { + MachineID string `json:"machine_id"` + JwtToken string `json:"jwt_token"` + UserID string `json:"user_id"` + Email sql.NullString `json:"email"` + ExpiresAt int64 `json:"expires_at"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func (q *Queries) CreateAuth(ctx context.Context, arg CreateAuthParams) (Auth, error) { + row := q.db.QueryRowContext(ctx, createAuth, + arg.MachineID, + arg.JwtToken, + arg.UserID, + arg.Email, + arg.ExpiresAt, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i Auth + err := row.Scan( + &i.ID, + &i.MachineID, + &i.JwtToken, + &i.UserID, + &i.Email, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteAuth = `-- name: DeleteAuth :exec +DELETE FROM auth WHERE id = ? +` + +func (q *Queries) DeleteAuth(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteAuth, id) + return err +} + +const deleteAuthByMachine = `-- name: DeleteAuthByMachine :exec +DELETE FROM auth WHERE machine_id = ? +` + +func (q *Queries) DeleteAuthByMachine(ctx context.Context, machineID string) error { + _, err := q.db.ExecContext(ctx, deleteAuthByMachine, machineID) + return err +} + +const getAuth = `-- name: GetAuth :one +SELECT id, machine_id, jwt_token, user_id, email, expires_at, created_at, updated_at FROM auth WHERE machine_id = ? ORDER BY created_at DESC LIMIT 1 +` + +func (q *Queries) GetAuth(ctx context.Context, machineID string) (Auth, error) { + row := q.db.QueryRowContext(ctx, getAuth, machineID) + var i Auth + err := row.Scan( + &i.ID, + &i.MachineID, + &i.JwtToken, + &i.UserID, + &i.Email, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getAuthByMachineID = `-- name: GetAuthByMachineID :one +SELECT id, machine_id, jwt_token, user_id, email, expires_at, created_at, updated_at FROM auth WHERE machine_id = ? LIMIT 1 +` + +func (q *Queries) GetAuthByMachineID(ctx context.Context, machineID string) (Auth, error) { + row := q.db.QueryRowContext(ctx, getAuthByMachineID, machineID) + var i Auth + err := row.Scan( + &i.ID, + &i.MachineID, + &i.JwtToken, + &i.UserID, + &i.Email, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateAuth = `-- name: UpdateAuth :exec +UPDATE auth SET jwt_token = ?, expires_at = ?, updated_at = ? +WHERE id = ? +` + +type UpdateAuthParams struct { + JwtToken string `json:"jwt_token"` + ExpiresAt int64 `json:"expires_at"` + UpdatedAt int64 `json:"updated_at"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateAuth(ctx context.Context, arg UpdateAuthParams) error { + _, err := q.db.ExecContext(ctx, updateAuth, + arg.JwtToken, + arg.ExpiresAt, + arg.UpdatedAt, + arg.ID, + ) + return err +} diff --git a/internal/db/sqlc/db.go b/internal/db/sqlc/db.go index 2248616..3af1d5e 100644 --- a/internal/db/sqlc/db.go +++ b/internal/db/sqlc/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.30.0 package sqlc diff --git a/internal/db/sqlc/github.sql.go b/internal/db/sqlc/github.sql.go index b2a7383..e0a94a0 100644 --- a/internal/db/sqlc/github.sql.go +++ b/internal/db/sqlc/github.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.30.0 // source: github.sql package sqlc diff --git a/internal/db/sqlc/machines.sql.go b/internal/db/sqlc/machines.sql.go new file mode 100644 index 0000000..efab082 --- /dev/null +++ b/internal/db/sqlc/machines.sql.go @@ -0,0 +1,129 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: machines.sql + +package sqlc + +import ( + "context" + "database/sql" +) + +const createMachine = `-- name: CreateMachine :one +INSERT INTO machines (id, name, mode, capabilities, created_at, updated_at, last_seen_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING id, name, mode, capabilities, last_seen_at, created_at, updated_at +` + +type CreateMachineParams struct { + ID string `json:"id"` + Name string `json:"name"` + Mode string `json:"mode"` + Capabilities sql.NullString `json:"capabilities"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + LastSeenAt int64 `json:"last_seen_at"` +} + +func (q *Queries) CreateMachine(ctx context.Context, arg CreateMachineParams) (Machine, error) { + row := q.db.QueryRowContext(ctx, createMachine, + arg.ID, + arg.Name, + arg.Mode, + arg.Capabilities, + arg.CreatedAt, + arg.UpdatedAt, + arg.LastSeenAt, + ) + var i Machine + err := row.Scan( + &i.ID, + &i.Name, + &i.Mode, + &i.Capabilities, + &i.LastSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteMachine = `-- name: DeleteMachine :exec +DELETE FROM machines WHERE id = ? +` + +func (q *Queries) DeleteMachine(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteMachine, id) + return err +} + +const getAllMachines = `-- name: GetAllMachines :many +SELECT id, name, mode, capabilities, last_seen_at, created_at, updated_at FROM machines ORDER BY created_at DESC +` + +func (q *Queries) GetAllMachines(ctx context.Context) ([]Machine, error) { + rows, err := q.db.QueryContext(ctx, getAllMachines) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Machine{} + for rows.Next() { + var i Machine + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Mode, + &i.Capabilities, + &i.LastSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getMachine = `-- name: GetMachine :one +SELECT id, name, mode, capabilities, last_seen_at, created_at, updated_at FROM machines WHERE id = ? LIMIT 1 +` + +func (q *Queries) GetMachine(ctx context.Context, id string) (Machine, error) { + row := q.db.QueryRowContext(ctx, getMachine, id) + var i Machine + err := row.Scan( + &i.ID, + &i.Name, + &i.Mode, + &i.Capabilities, + &i.LastSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateMachineLastSeen = `-- name: UpdateMachineLastSeen :exec +UPDATE machines SET last_seen_at = ?, updated_at = ? +WHERE id = ? +` + +type UpdateMachineLastSeenParams struct { + LastSeenAt int64 `json:"last_seen_at"` + UpdatedAt int64 `json:"updated_at"` + ID string `json:"id"` +} + +func (q *Queries) UpdateMachineLastSeen(ctx context.Context, arg UpdateMachineLastSeenParams) error { + _, err := q.db.ExecContext(ctx, updateMachineLastSeen, arg.LastSeenAt, arg.UpdatedAt, arg.ID) + return err +} diff --git a/internal/db/sqlc/messages.sql.go b/internal/db/sqlc/messages.sql.go index f06e256..b968927 100644 --- a/internal/db/sqlc/messages.sql.go +++ b/internal/db/sqlc/messages.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.30.0 // source: messages.sql package sqlc diff --git a/internal/db/sqlc/models.go b/internal/db/sqlc/models.go index 66c050d..7ba848c 100644 --- a/internal/db/sqlc/models.go +++ b/internal/db/sqlc/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.30.0 package sqlc @@ -36,6 +36,17 @@ type Artifact struct { UpdatedAt int64 `json:"updated_at"` } +type Auth struct { + ID int64 `json:"id"` + MachineID string `json:"machine_id"` + JwtToken string `json:"jwt_token"` + UserID string `json:"user_id"` + Email sql.NullString `json:"email"` + ExpiresAt int64 `json:"expires_at"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + type GithubConnection struct { ID string `json:"id"` GithubUserID string `json:"github_user_id"` @@ -46,6 +57,16 @@ type GithubConnection struct { UpdatedAt int64 `json:"updated_at"` } +type Machine struct { + ID string `json:"id"` + Name string `json:"name"` + Mode string `json:"mode"` + Capabilities sql.NullString `json:"capabilities"` + LastSeenAt int64 `json:"last_seen_at"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + type Message struct { ID string `json:"id"` TaskID string `json:"task_id"` diff --git a/internal/db/sqlc/querier.go b/internal/db/sqlc/querier.go index ed37c0b..629e70b 100644 --- a/internal/db/sqlc/querier.go +++ b/internal/db/sqlc/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.30.0 package sqlc @@ -11,23 +11,32 @@ import ( type Querier interface { CreateAgentRun(ctx context.Context, arg CreateAgentRunParams) error CreateArtifact(ctx context.Context, arg CreateArtifactParams) error + CreateAuth(ctx context.Context, arg CreateAuthParams) (Auth, error) CreateGithubConnection(ctx context.Context, arg CreateGithubConnectionParams) (GithubConnection, error) + CreateMachine(ctx context.Context, arg CreateMachineParams) (Machine, error) CreateMessage(ctx context.Context, arg CreateMessageParams) error CreateRepository(ctx context.Context, arg CreateRepositoryParams) (Repository, error) CreateTask(ctx context.Context, arg CreateTaskParams) error DeleteAgentRunsByTask(ctx context.Context, taskID string) error DeleteArtifactsByRun(ctx context.Context, runID string) error + DeleteAuth(ctx context.Context, id int64) error + DeleteAuthByMachine(ctx context.Context, machineID string) error DeleteGithubConnection(ctx context.Context, id string) error + DeleteMachine(ctx context.Context, id string) error DeleteMessagesByTask(ctx context.Context, taskID string) error DeleteRepositoriesByConnection(ctx context.Context, connectionID string) error DeleteTask(ctx context.Context, id string) error GetAgentRun(ctx context.Context, id string) (AgentRun, error) + GetAllMachines(ctx context.Context) ([]Machine, error) GetArtifact(ctx context.Context, id string) (Artifact, error) GetArtifactsByRun(ctx context.Context, runID string) ([]Artifact, error) GetArtifactsByTask(ctx context.Context, taskID string) ([]Artifact, error) + GetAuth(ctx context.Context, machineID string) (Auth, error) + GetAuthByMachineID(ctx context.Context, machineID string) (Auth, error) GetGithubConnection(ctx context.Context) (GithubConnection, error) GetGithubConnectionByID(ctx context.Context, id string) (GithubConnection, error) GetLatestRun(ctx context.Context, taskID string) (AgentRun, error) + GetMachine(ctx context.Context, id string) (Machine, error) GetMessage(ctx context.Context, id string) (Message, error) GetMessagesByRun(ctx context.Context, runID string) ([]Message, error) GetMessagesByTask(ctx context.Context, taskID string) ([]Message, error) @@ -42,7 +51,9 @@ type Querier interface { ListTasksWithRepository(ctx context.Context) ([]ListTasksWithRepositoryRow, error) UpdateAgentRunBackendSessionID(ctx context.Context, arg UpdateAgentRunBackendSessionIDParams) error UpdateAgentRunCompleted(ctx context.Context, arg UpdateAgentRunCompletedParams) error + UpdateAuth(ctx context.Context, arg UpdateAuthParams) error UpdateGithubConnection(ctx context.Context, arg UpdateGithubConnectionParams) (GithubConnection, error) + UpdateMachineLastSeen(ctx context.Context, arg UpdateMachineLastSeenParams) error UpdateTaskPosition(ctx context.Context, arg UpdateTaskPositionParams) error UpdateTaskPositionAndStatus(ctx context.Context, arg UpdateTaskPositionAndStatusParams) error UpdateTaskStatus(ctx context.Context, arg UpdateTaskStatusParams) error diff --git a/internal/db/sqlc/settings.sql.go b/internal/db/sqlc/settings.sql.go index f1106d8..8dbd0e8 100644 --- a/internal/db/sqlc/settings.sql.go +++ b/internal/db/sqlc/settings.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.30.0 // source: settings.sql package sqlc diff --git a/internal/db/sqlc/tasks.sql.go b/internal/db/sqlc/tasks.sql.go index 23daa76..6f6575e 100644 --- a/internal/db/sqlc/tasks.sql.go +++ b/internal/db/sqlc/tasks.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.30.0 // source: tasks.sql package sqlc diff --git a/internal/services/auth.go b/internal/services/auth.go new file mode 100644 index 0000000..cf024ab --- /dev/null +++ b/internal/services/auth.go @@ -0,0 +1,280 @@ +package services + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "os" + "runtime" + "time" + + "github.com/revrost/code/counterspell/internal/db" + "github.com/revrost/code/counterspell/internal/db/sqlc" +) + +// AuthService handles authentication and machine registration. +type AuthService struct { + db *db.DB + controlPlane *ControlPlaneClient + machineID string + machineName string + defaultMachineID string + + // PKCE & State for auth flow + codeVerifier string + codeChallenge string + state string +} + +// NewAuthService creates a new auth service. +func NewAuthService(database *db.DB, controlPlane *ControlPlaneClient, defaultMachineID string) *AuthService { + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "unknown" + } + + machineName := fmt.Sprintf("%s-%s", runtime.GOOS, hostname) + + as := &AuthService{ + db: database, + controlPlane: controlPlane, + machineID: defaultMachineID, + machineName: machineName, + defaultMachineID: defaultMachineID, + } + + // Generate PKCE code_verifier and code_challenge + as.codeVerifier = generateRandomString(128) + as.codeChallenge = generateCodeChallenge(as.codeVerifier) + + // Generate random state for CSRF protection + as.state = generateRandomString(32) + + return as +} + +// generateRandomString generates a cryptographically random string. +func generateRandomString(length int) string { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + panic(fmt.Sprintf("failed to generate random string: %v", err)) + } + return base64.RawURLEncoding.EncodeToString(bytes) +} + +// generateCodeChallenge creates PKCE code_challenge from code_verifier. +func generateCodeChallenge(codeVerifier string) string { + hash := sha256.Sum256([]byte(codeVerifier)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +// generateMachineID generates a unique machine ID. +func (a *AuthService) generateMachineID() (string, error) { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate machine ID: %w", err) + } + return hex.EncodeToString(bytes), nil +} + +// GetCodeVerifier returns the PKCE code_verifier. +func (a *AuthService) GetCodeVerifier() string { + return a.codeVerifier +} + +// GetCodeChallenge returns the PKCE code_challenge. +func (a *AuthService) GetCodeChallenge() string { + return a.codeChallenge +} + +// GetState returns the random state for CSRF protection. +func (a *AuthService) GetState() string { + return a.state +} + +// EnsureMachine ensures this machine exists in the database. +func (a *AuthService) EnsureMachine(ctx context.Context) (string, error) { + machineID := a.defaultMachineID + if machineID == "" { + var err error + machineID, err = a.generateMachineID() + if err != nil { + return "", err + } + a.machineID = machineID + } + + now := time.Now().UnixMilli() + capabilities := map[string]interface{}{ + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "cpus": runtime.NumCPU(), + } + capabilitiesJSON, _ := json.Marshal(capabilities) + + // Try to get existing machine + existing, err := a.db.Queries.GetMachine(ctx, machineID) + if err != nil { + // Create new machine + var capabilitiesPtr sql.NullString + if len(capabilitiesJSON) > 0 { + capabilitiesPtr = sql.NullString{String: string(capabilitiesJSON), Valid: true} + } + _, err = a.db.Queries.CreateMachine(ctx, sqlc.CreateMachineParams{ + ID: machineID, + Name: a.machineName, + Mode: "local", + Capabilities: capabilitiesPtr, + CreatedAt: now, + UpdatedAt: now, + LastSeenAt: now, + }) + if err != nil { + return "", fmt.Errorf("failed to create machine: %w", err) + } + slog.Info("Created new machine", "machine_id", machineID, "name", a.machineName) + } else { + // Update last seen + err = a.db.Queries.UpdateMachineLastSeen(ctx, sqlc.UpdateMachineLastSeenParams{ + LastSeenAt: now, + UpdatedAt: now, + ID: existing.ID, + }) + if err != nil { + slog.Warn("Failed to update machine last seen", "error", err) + } + } + + return machineID, nil +} + +// GetStoredAuth retrieves stored auth for this machine. +func (a *AuthService) GetStoredAuth(ctx context.Context, machineID string) (*sqlc.Auth, error) { + auth, err := a.db.Queries.GetAuthByMachineID(ctx, machineID) + if err != nil { + return nil, err + } + return &auth, nil +} + +// StoreAuth stores auth token and user info. +func (a *AuthService) StoreAuth(ctx context.Context, machineID, jwt, userID, email string, expiresAt int64) error { + now := time.Now().UnixMilli() + + // Check if auth exists for this machine + existing, err := a.db.Queries.GetAuthByMachineID(ctx, machineID) + if err == nil { + // Update existing + err = a.db.Queries.UpdateAuth(ctx, sqlc.UpdateAuthParams{ + JwtToken: jwt, + ExpiresAt: expiresAt, + UpdatedAt: now, + ID: existing.ID, + }) + return err + } + + // Create new auth + var emailPtr sql.NullString + if email != "" { + emailPtr = sql.NullString{String: email, Valid: true} + } + _, err = a.db.Queries.CreateAuth(ctx, sqlc.CreateAuthParams{ + MachineID: machineID, + JwtToken: jwt, + UserID: userID, + Email: emailPtr, + ExpiresAt: expiresAt, + CreatedAt: now, + UpdatedAt: now, + }) + return err +} + +// ValidateToken validates the stored JWT token. +func (a *AuthService) ValidateToken(ctx context.Context, jwt string) (bool, error) { + if a.controlPlane == nil { + return false, fmt.Errorf("control plane client not configured") + } + return a.controlPlane.ValidateToken(ctx, jwt) +} + +// IsAuthenticated checks if this machine has valid auth. +func (a *AuthService) IsAuthenticated(ctx context.Context, machineID string) (bool, error) { + auth, err := a.GetStoredAuth(ctx, machineID) + if err != nil { + return false, nil // No auth stored + } + + // Check if token is expired + now := time.Now().UnixMilli() + if auth.ExpiresAt < now { + return false, nil // Token expired + } + + // Validate with control plane + valid, err := a.ValidateToken(ctx, auth.JwtToken) + if err != nil { + slog.Error("Failed to validate token", "error", err) + return false, nil + } + + return valid, nil +} + +// RegisterMachine registers this machine with the control plane. +func (a *AuthService) RegisterMachine(ctx context.Context, jwt, machineID string) (string, error) { + if a.controlPlane == nil { + return "", fmt.Errorf("control plane client not configured") + } + + capabilities := map[string]interface{}{ + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "cpus": runtime.NumCPU(), + } + + resp, err := a.controlPlane.RegisterMachine(ctx, jwt, machineID, a.machineName, capabilities) + if err != nil { + return "", fmt.Errorf("failed to register machine: %w", err) + } + + return resp.Subdomain, nil +} + +// StartAuthFlow starts the auth flow - returns auth URL and state. +func (a *AuthService) StartAuthFlow(ctx context.Context) (string, string, error) { + if a.controlPlane == nil { + return "", "", fmt.Errorf("control plane client not configured") + } + + // For CLI flow, redirect URL can be a local callback or just the app URL + redirectURL := "counterspell://auth/callback" + + resp, err := a.controlPlane.GetAuthURL(ctx, a.machineName, redirectURL) + if err != nil { + return "", "", fmt.Errorf("failed to get auth URL: %w", err) + } + + return resp.AuthURL, resp.State, nil +} + +// CompleteAuthFlow exchanges the auth code for a JWT. +func (a *AuthService) CompleteAuthFlow(ctx context.Context, code, state string) (*ExchangeCodeResponse, error) { + if a.controlPlane == nil { + return nil, fmt.Errorf("control plane client not configured") + } + + // Validate state matches to prevent CSRF + if state != a.state { + return nil, fmt.Errorf("invalid state - potential CSRF attack") + } + + return a.controlPlane.ExchangeCode(ctx, code, a.codeVerifier, state) +} diff --git a/internal/services/controlplane.go b/internal/services/controlplane.go new file mode 100644 index 0000000..b4fcf64 --- /dev/null +++ b/internal/services/controlplane.go @@ -0,0 +1,227 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" +) + +// ControlPlaneClient talks to the control plane API (counterspell.io). +// The control plane handles auth with Supabase internally - the binary doesn't know about Supabase. +type ControlPlaneClient struct { + baseURL string + httpClient *http.Client +} + +// NewControlPlaneClient creates a new control plane client. +func NewControlPlaneClient(baseURL string) *ControlPlaneClient { + return &ControlPlaneClient{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// AuthURLRequest is the request to get an auth URL. +type AuthURLRequest struct { + MachineName string `json:"machine_name"` + RedirectURL string `json:"redirect_url"` + CodeChallenge string `json:"code_challenge"` // PKCE - hash of code_verifier + State string `json:"state"` // CSRF protection +} + +// AuthURLResponse is the response containing the auth URL. +type AuthURLResponse struct { + AuthURL string `json:"auth_url"` + State string `json:"state"` +} + +// GetAuthURL returns the auth URL that the user should visit to log in. +func (c *ControlPlaneClient) GetAuthURL(ctx context.Context, machineName, redirectURL string) (*AuthURLResponse, error) { + // Deprecated - use GetAuthURLWithPKCE for security + return c.GetAuthURLWithPKCE(ctx, machineName, redirectURL, "", "") +} + +// GetAuthURLWithPKCE returns the auth URL with PKCE for security. +func (c *ControlPlaneClient) GetAuthURLWithPKCE(ctx context.Context, machineName, redirectURL, codeChallenge, state string) (*AuthURLResponse, error) { + reqBody := AuthURLRequest{ + MachineName: machineName, + RedirectURL: redirectURL, + CodeChallenge: codeChallenge, + State: state, + } + + reqData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/auth/url", &readerCloser{Reader: reqData}) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call control plane: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("control plane returned status %d", resp.StatusCode) + } + + var response AuthURLResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &response, nil +} + +// ExchangeCodeRequest exchanges an auth code for a JWT token. +type ExchangeCodeRequest struct { + Code string `json:"code"` + CodeVerifier string `json:"code_verifier"` // PKCE - original random string + State string `json:"state"` +} + +// ExchangeCodeResponse contains the JWT token and user info. +type ExchangeCodeResponse struct { + JWT string `json:"jwt"` + UserID string `json:"user_id"` + Email string `json:"email"` + ExpiresAt int64 `json:"expires_at"` // Unix ms +} + +// ExchangeCode exchanges the auth callback code for a JWT token. +func (c *ControlPlaneClient) ExchangeCode(ctx context.Context, code, codeVerifier, state string) (*ExchangeCodeResponse, error) { + reqBody := ExchangeCodeRequest{ + Code: code, + CodeVerifier: codeVerifier, + State: state, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/auth/exchange", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Body = &readerCloser{Reader: body} + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call control plane: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("control plane returned status %d", resp.StatusCode) + } + + var response ExchangeCodeResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &response, nil +} + +// RegisterMachineRequest registers a machine with the control plane. +type RegisterMachineRequest struct { + MachineID string `json:"machine_id"` + MachineName string `json:"machine_name"` + Mode string `json:"mode"` // "local" or "cloud" + Capabilities map[string]interface{} `json:"capabilities"` +} + +// RegisterMachineResponse contains the tunnel/subdomain info. +type RegisterMachineResponse struct { + Subdomain string `json:"subdomain"` + TunnelURL string `json:"tunnel_url"` +} + +// RegisterMachine registers this machine and gets tunnel configuration. +func (c *ControlPlaneClient) RegisterMachine(ctx context.Context, jwt, machineID, machineName string, capabilities map[string]interface{}) (*RegisterMachineResponse, error) { + reqBody := RegisterMachineRequest{ + MachineID: machineID, + MachineName: machineName, + Mode: "local", + Capabilities: capabilities, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/machines/register", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+jwt) + req.Body = &readerCloser{Reader: body} + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call control plane: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + slog.Error("Failed to register machine", "status", resp.StatusCode) + return nil, fmt.Errorf("control plane returned status %d", resp.StatusCode) + } + + var response RegisterMachineResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &response, nil +} + +// ValidateToken validates a JWT token with the control plane. +func (c *ControlPlaneClient) ValidateToken(ctx context.Context, jwt string) (bool, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/auth/validate", nil) + if err != nil { + return false, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+jwt) + + resp, err := c.httpClient.Do(req) + if err != nil { + return false, fmt.Errorf("failed to call control plane: %w", err) + } + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK, nil +} + +// readerCloser wraps a []byte to implement io.ReadCloser. +type readerCloser struct { + Reader []byte +} + +func (r *readerCloser) Read(p []byte) (int, error) { + return copy(p, r.Reader), nil +} + +func (r *readerCloser) Close() error { + return nil +} From 185a0789e478ab321e67d45b9bd4344733399185 Mon Sep 17 00:00:00 2001 From: reVrost Date: Mon, 26 Jan 2026 13:30:25 +1100 Subject: [PATCH 3/3] feat: tunneling plan --- TUNNELING_PLAN.md | 126 +++++++++++++++++ ui/src/lib/components/ErrorView.svelte | 180 ++++++++++++------------- ui/src/routes/dashboard/+layout.svelte | 102 +++++++------- 3 files changed, 256 insertions(+), 152 deletions(-) create mode 100644 TUNNELING_PLAN.md diff --git a/TUNNELING_PLAN.md b/TUNNELING_PLAN.md new file mode 100644 index 0000000..c0c289a --- /dev/null +++ b/TUNNELING_PLAN.md @@ -0,0 +1,126 @@ +# Counterspell Tunneling Architecture & Plan + +## 1. The Vision: "Local First, Globally Accessible" + +We are building a **Split-Brain Architecture** where: +- **The Brain (Logic/Agents)** runs locally on the user's machine (Data Plane). +- **The Face (UI/Auth)** runs on the Edge (Control Plane). +- **The Connection** is a secure tunnel linking them. + +**Goal:** Allow users to access their local coding agent from any device (phone, tablet, laptop) via a secure `username.counterspell.app` URL, without complex networking setup. + +--- + +## 2. Architecture Overview + +```mermaid +graph TD + User[User's Browser] -->|https://alice.counterspell.app| Edge[Control Plane (Cloudflare/Vercel)] + + subgraph "Control Plane (counterspell.io)" + Edge -->|Serving Static UI| UI_Bucket[UI Assets] + Edge -->|Auth & Routing| Supabase[Auth / DB] + Edge -->|Tunnel Request| TunnelServer[Tunnel Ingress] + end + + subgraph "Data Plane (User's Machine)" + TunnelClient[Binary Tunnel Client] -->|Secure WebSocket| TunnelServer + TunnelClient -->|Local API Request| GoServer[Go Backend :8710] + GoServer -->|SQL| SQLite[Local DB] + GoServer -->|Exec| Agents[Claude/Native Agents] + end +``` + +### Key Components + +| Component | Responsibility | Location | Status | +|-----------|----------------|----------|--------| +| **Control Plane** | Auth, Billing, Tunnel Routing, Subdomain Management | `counterspell.io` | Spec Defined | +| **Data Plane** | Agent Logic, File Access, Local DB, Tunnel Client | User's Machine | **In Progress** | +| **The Tunnel** | Secure pipe between Control & Data Plane | Cloudflare / Ngrok | Next Step | + +--- + +## 3. Implementation Status: Authentication (Completed) + +We have implemented a secure **OAuth 2.0 Authorization Code Flow with PKCE**. + +### Why this approach? +- **Security:** The JWT *never* appears in the URL or browser history. +- **UX:** No copy-pasting codes. The browser creates a magical hand-off to the CLI. +- **Standards:** Compliant with OAuth 2.0 best practices for public clients. + +### The Flow +1. **CLI Start:** Generates PKCE `code_verifier` & `code_challenge`. +2. **Request:** CLI asks Control Plane for a login URL (sending `code_challenge`). +3. **Login:** User logs in at `counterspell.io` (Supabase handled internally). +4. **Redirect:** Control Plane redirects browser to `http://localhost:8711/callback?code=abc...`. +5. **Exchange:** CLI (listening on :8711) takes `code`, validates `state`, and POSTs to Control Plane to get JWT. +6. **Token:** JWT is stored locally in `counterspell.db` (sqlite). + +**Files Implemented:** +- `internal/services/auth.go`: PKCE generation, Token storage. +- `internal/services/controlplane.go`: API client for Auth & Machine Registration. +- `internal/cli/callback.go`: Local HTTP server for zero-friction handoff. +- `cmd/app/main.go`: Integration into startup flow. + +--- + +## 4. Next Step: The Tunneling Implementation + +### 4.1. Registration +Once authenticated, the binary calls `POST /api/v1/machines/register`. +- **Input:** Machine info (OS, Arch, CPU). +- **Output:** Authorized Subdomain (e.g., `alice`) and Tunnel Configuration. + +### 4.2. Tunnel Establishment +We will use **Cloudflare Tunnels (cloudflared)** via the Go SDK or wrapper. + +**Command:** +```bash +# Concept +cloudflared tunnel run --token +``` + +**Implementation Plan:** +1. **Tunnel Service:** Create `internal/services/tunnel.go`. +2. **Auto-Download:** If `cloudflared` isn't found, download the binary for the OS. +3. **Configuration:** Configure ingress rules to forward `https://alice.counterspell.app` -> `http://localhost:8710`. +4. **Lifecycle:** Start tunnel on app launch, stop on shutdown. + +--- + +## 5. Control Plane API Specification + +The Data Plane (Binary) expects these endpoints to exist on the Control Plane: + +### Auth +- `POST /api/v1/auth/url` + - Body: `{ "machine_name": "...", "redirect_url": "...", "code_challenge": "...", "state": "..." }` + - Returns: `{ "auth_url": "..." }` + +- `POST /api/v1/auth/exchange` + - Body: `{ "code": "...", "code_verifier": "...", "state": "..." }` + - Returns: `{ "jwt": "...", "user_id": "...", "email": "..." }` + +### Machines & Tunnel +- `POST /api/v1/machines/register` (Protected) + - Headers: `Authorization: Bearer ` + - Body: `{ "machine_id": "...", "capabilities": {...} }` + - Returns: `{ "subdomain": "alice", "tunnel_token": "..." }` + +--- + +## 6. Subdomain Routing (The "Split Brain" Logic) + +The Frontend (Svelte) is a **Single Page App** served from the Edge, but it needs to talk to the *correct* backend. + +**Logic:** +1. User visits `alice.counterspell.app`. +2. Frontend loads assets from CDN. +3. Frontend API Client (`api.ts`) detects hostname `alice.counterspell.app`. +4. Requests sent to `/api/*` are routed by the Control Plane's Edge Worker. +5. Edge Worker looks up `alice` -> Finds Tunnel ID -> Proxies request down the tunnel. +6. Local Binary receives request -> Executes Agent -> Returns JSON. + +This architecture allows for **infinite scaling** of users without running infinite servers in the cloud. We only pay for the "pipe" (tunneling), while users pay for the "compute" (running the agent locally). diff --git a/ui/src/lib/components/ErrorView.svelte b/ui/src/lib/components/ErrorView.svelte index 5f9bf1f..70891d7 100644 --- a/ui/src/lib/components/ErrorView.svelte +++ b/ui/src/lib/components/ErrorView.svelte @@ -1,118 +1,104 @@
- -
-
-
-
- - + +
- {#if mounted} -
- -
- - {title} - -
-
- -
-
-
-
+ class="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/5 blur-[120px] rounded-full" + >
+
+
+ +
+ {#if mounted} +
+ +
+ + {title} + +
-

- {message} -

-

- {description} -

+
- {/if} -
+
+
+
- -
- {#if onRetry} - - {/if} +
+

+ {message} +

+

+ {description} +

+
+ {/if} +
- + +
+ {#if onRetry} + + {/if} -
- Counterspell -
+ + +
+ Counterspell
+
diff --git a/ui/src/routes/dashboard/+layout.svelte b/ui/src/routes/dashboard/+layout.svelte index 9a5c915..fe83290 100644 --- a/ui/src/routes/dashboard/+layout.svelte +++ b/ui/src/routes/dashboard/+layout.svelte @@ -1,23 +1,23 @@
@@ -305,17 +302,12 @@
-
+
{@render children()}
-
+
{#if appState.showChatInput}
appState.closeChatInput()} />