From a851630d85b1c7fc5a340d30353dabf249c251e2 Mon Sep 17 00:00:00 2001 From: Forbes Date: Sun, 1 Mar 2026 09:56:43 -0600 Subject: [PATCH] feat(sessions): workstation table, registration API, and module scaffold - Add 022_workstations.sql migration (UUID PK, user_id FK, UNIQUE(user_id, name)) - Add Sessions module (depends on Auth, default enabled) with config toggle - Add WorkstationRepository with Upsert, GetByID, ListByUser, Touch, Delete - Add workstation handlers: register (POST upsert), list (GET), delete (DELETE) - Add /api/workstations routes gated by sessions module - Wire WorkstationRepository into Server struct - Update module tests for new Sessions module Closes #161 --- internal/api/handlers.go | 3 + internal/api/routes.go | 8 ++ internal/api/workstation_handlers.go | 138 ++++++++++++++++++++ internal/config/config.go | 1 + internal/db/migrations/022_workstations.sql | 11 ++ internal/db/workstations.go | 95 ++++++++++++++ internal/modules/loader.go | 1 + internal/modules/loader_test.go | 8 +- internal/modules/modules.go | 2 + internal/modules/modules_test.go | 4 +- 10 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 internal/api/workstation_handlers.go create mode 100644 internal/db/migrations/022_workstations.sql create mode 100644 internal/db/workstations.go diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 66e77aa..a9331de 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -62,6 +62,7 @@ type Server struct { approvals *db.ItemApprovalRepository workflows map[string]*workflow.Workflow solverResults *db.SolverResultRepository + workstations *db.WorkstationRepository } // NewServer creates a new API server. @@ -96,6 +97,7 @@ func NewServer( itemMacros := db.NewItemMacroRepository(database) itemApprovals := db.NewItemApprovalRepository(database) solverResults := db.NewSolverResultRepository(database) + workstations := db.NewWorkstationRepository(database) seqStore := &dbSequenceStore{db: database, schemas: schemas} partgen := partnum.NewGenerator(schemas, seqStore) @@ -130,6 +132,7 @@ func NewServer( approvals: itemApprovals, workflows: workflows, solverResults: solverResults, + workstations: workstations, } } diff --git a/internal/api/routes.go b/internal/api/routes.go index 3e4a722..b2bc8b7 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -71,6 +71,14 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { // Workflows (viewer+) r.Get("/workflows", server.HandleListWorkflows) + // Workstations (gated by sessions module) + r.Route("/workstations", func(r chi.Router) { + r.Use(server.RequireModule("sessions")) + r.Get("/", server.HandleListWorkstations) + r.Post("/", server.HandleRegisterWorkstation) + r.Delete("/{id}", server.HandleDeleteWorkstation) + }) + // Auth endpoints r.Get("/auth/me", server.HandleGetCurrentUser) r.Route("/auth/tokens", func(r chi.Router) { diff --git a/internal/api/workstation_handlers.go b/internal/api/workstation_handlers.go new file mode 100644 index 0000000..8f74835 --- /dev/null +++ b/internal/api/workstation_handlers.go @@ -0,0 +1,138 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/auth" + "github.com/kindredsystems/silo/internal/db" +) + +// HandleRegisterWorkstation registers or re-registers a workstation for the current user. +func (s *Server) HandleRegisterWorkstation(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := auth.UserFromContext(ctx) + if user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + var req struct { + Name string `json:"name"` + Hostname string `json:"hostname"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "validation_error", "name is required") + return + } + + ws := &db.Workstation{ + Name: req.Name, + UserID: user.ID, + Hostname: req.Hostname, + } + if err := s.workstations.Upsert(ctx, ws); err != nil { + s.logger.Error().Err(err).Str("name", req.Name).Msg("failed to register workstation") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to register workstation") + return + } + + s.broker.Publish("workstation.registered", mustMarshal(map[string]any{ + "id": ws.ID, + "name": ws.Name, + "user_id": ws.UserID, + "hostname": ws.Hostname, + })) + + writeJSON(w, http.StatusOK, map[string]any{ + "id": ws.ID, + "name": ws.Name, + "hostname": ws.Hostname, + "last_seen": ws.LastSeen.UTC().Format("2006-01-02T15:04:05Z"), + "created_at": ws.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + }) +} + +// HandleListWorkstations returns all workstations for the current user. +func (s *Server) HandleListWorkstations(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := auth.UserFromContext(ctx) + if user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + workstations, err := s.workstations.ListByUser(ctx, user.ID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to list workstations") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list workstations") + return + } + + type wsResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Hostname string `json:"hostname"` + LastSeen string `json:"last_seen"` + CreatedAt string `json:"created_at"` + } + + out := make([]wsResponse, len(workstations)) + for i, ws := range workstations { + out[i] = wsResponse{ + ID: ws.ID, + Name: ws.Name, + Hostname: ws.Hostname, + LastSeen: ws.LastSeen.UTC().Format("2006-01-02T15:04:05Z"), + CreatedAt: ws.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + } + } + + writeJSON(w, http.StatusOK, out) +} + +// HandleDeleteWorkstation removes a workstation owned by the current user (or any, for admins). +func (s *Server) HandleDeleteWorkstation(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := auth.UserFromContext(ctx) + if user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + id := chi.URLParam(r, "id") + ws, err := s.workstations.GetByID(ctx, id) + if err != nil { + s.logger.Error().Err(err).Str("id", id).Msg("failed to get workstation") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get workstation") + return + } + if ws == nil { + writeError(w, http.StatusNotFound, "not_found", "Workstation not found") + return + } + + if ws.UserID != user.ID && user.Role != auth.RoleAdmin { + writeError(w, http.StatusForbidden, "forbidden", "You can only delete your own workstations") + return + } + + if err := s.workstations.Delete(ctx, id); err != nil { + s.logger.Error().Err(err).Str("id", id).Msg("failed to delete workstation") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete workstation") + return + } + + s.broker.Publish("workstation.removed", mustMarshal(map[string]any{ + "id": ws.ID, + "name": ws.Name, + "user_id": ws.UserID, + })) + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/config/config.go b/internal/config/config.go index 9ab3a63..4b9d81a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,6 +34,7 @@ type ModulesConfig struct { Jobs *ModuleToggle `yaml:"jobs"` DAG *ModuleToggle `yaml:"dag"` Solver *ModuleToggle `yaml:"solver"` + Sessions *ModuleToggle `yaml:"sessions"` } // ModuleToggle holds an optional enabled flag. The pointer allows diff --git a/internal/db/migrations/022_workstations.sql b/internal/db/migrations/022_workstations.sql new file mode 100644 index 0000000..70f058a --- /dev/null +++ b/internal/db/migrations/022_workstations.sql @@ -0,0 +1,11 @@ +-- 022_workstations.sql — workstation identity for edit sessions + +CREATE TABLE workstations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + hostname TEXT NOT NULL DEFAULT '', + last_seen TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(user_id, name) +); diff --git a/internal/db/workstations.go b/internal/db/workstations.go new file mode 100644 index 0000000..91aebc2 --- /dev/null +++ b/internal/db/workstations.go @@ -0,0 +1,95 @@ +package db + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5" +) + +// Workstation represents a registered client machine. +type Workstation struct { + ID string + Name string + UserID string + Hostname string + LastSeen time.Time + CreatedAt time.Time +} + +// WorkstationRepository provides workstation database operations. +type WorkstationRepository struct { + db *DB +} + +// NewWorkstationRepository creates a new workstation repository. +func NewWorkstationRepository(db *DB) *WorkstationRepository { + return &WorkstationRepository{db: db} +} + +// Upsert registers a workstation, updating hostname and last_seen if it already exists. +func (r *WorkstationRepository) Upsert(ctx context.Context, w *Workstation) error { + return r.db.pool.QueryRow(ctx, ` + INSERT INTO workstations (name, user_id, hostname) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, name) DO UPDATE + SET hostname = EXCLUDED.hostname, last_seen = now() + RETURNING id, last_seen, created_at + `, w.Name, w.UserID, w.Hostname).Scan(&w.ID, &w.LastSeen, &w.CreatedAt) +} + +// GetByID returns a workstation by its ID. +func (r *WorkstationRepository) GetByID(ctx context.Context, id string) (*Workstation, error) { + w := &Workstation{} + err := r.db.pool.QueryRow(ctx, ` + SELECT id, name, user_id, hostname, last_seen, created_at + FROM workstations + WHERE id = $1 + `, id).Scan(&w.ID, &w.Name, &w.UserID, &w.Hostname, &w.LastSeen, &w.CreatedAt) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return w, nil +} + +// ListByUser returns all workstations for a user. +func (r *WorkstationRepository) ListByUser(ctx context.Context, userID string) ([]*Workstation, error) { + rows, err := r.db.pool.Query(ctx, ` + SELECT id, name, user_id, hostname, last_seen, created_at + FROM workstations + WHERE user_id = $1 + ORDER BY name + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var workstations []*Workstation + for rows.Next() { + w := &Workstation{} + if err := rows.Scan(&w.ID, &w.Name, &w.UserID, &w.Hostname, &w.LastSeen, &w.CreatedAt); err != nil { + return nil, err + } + workstations = append(workstations, w) + } + return workstations, rows.Err() +} + +// Touch updates a workstation's last_seen timestamp. +func (r *WorkstationRepository) Touch(ctx context.Context, id string) error { + _, err := r.db.pool.Exec(ctx, ` + UPDATE workstations SET last_seen = now() WHERE id = $1 + `, id) + return err +} + +// Delete removes a workstation. +func (r *WorkstationRepository) Delete(ctx context.Context, id string) error { + _, err := r.db.pool.Exec(ctx, `DELETE FROM workstations WHERE id = $1`, id) + return err +} diff --git a/internal/modules/loader.go b/internal/modules/loader.go index 90a99e5..2cc56e1 100644 --- a/internal/modules/loader.go +++ b/internal/modules/loader.go @@ -34,6 +34,7 @@ func LoadState(r *Registry, cfg *config.Config, pool *pgxpool.Pool) error { applyToggle(r, Jobs, cfg.Modules.Jobs) applyToggle(r, DAG, cfg.Modules.DAG) applyToggle(r, Solver, cfg.Modules.Solver) + applyToggle(r, Sessions, cfg.Modules.Sessions) // Step 3: Apply database overrides (highest precedence). if pool != nil { diff --git a/internal/modules/loader_test.go b/internal/modules/loader_test.go index 28bd161..75451a1 100644 --- a/internal/modules/loader_test.go +++ b/internal/modules/loader_test.go @@ -11,6 +11,9 @@ func boolPtr(v bool) *bool { return &v } func TestLoadState_DefaultsOnly(t *testing.T) { r := NewRegistry() cfg := &config.Config{} + // Sessions depends on Auth; when auth is disabled via backward-compat + // zero value, sessions must also be explicitly disabled. + cfg.Modules.Sessions = &config.ModuleToggle{Enabled: boolPtr(false)} if err := LoadState(r, cfg, nil); err != nil { t.Fatalf("LoadState: %v", err) @@ -44,8 +47,9 @@ func TestLoadState_BackwardCompat(t *testing.T) { func TestLoadState_YAMLModulesOverrideCompat(t *testing.T) { r := NewRegistry() cfg := &config.Config{} - cfg.Auth.Enabled = true // compat says enabled - cfg.Modules.Auth = &config.ModuleToggle{Enabled: boolPtr(false)} // explicit says disabled + cfg.Auth.Enabled = true // compat says enabled + cfg.Modules.Auth = &config.ModuleToggle{Enabled: boolPtr(false)} // explicit says disabled + cfg.Modules.Sessions = &config.ModuleToggle{Enabled: boolPtr(false)} // sessions depends on auth if err := LoadState(r, cfg, nil); err != nil { t.Fatalf("LoadState: %v", err) diff --git a/internal/modules/modules.go b/internal/modules/modules.go index 7360aa4..f4b9b00 100644 --- a/internal/modules/modules.go +++ b/internal/modules/modules.go @@ -22,6 +22,7 @@ const ( Jobs = "jobs" DAG = "dag" Solver = "solver" + Sessions = "sessions" ) // ModuleInfo describes a module's metadata. @@ -60,6 +61,7 @@ var builtinModules = []ModuleInfo{ {ID: Jobs, Name: "Job Queue", Description: "Async compute jobs, runner management", DependsOn: []string{Auth}}, {ID: DAG, Name: "Dependency DAG", Description: "Feature DAG sync, validation states, interference detection", DependsOn: []string{Jobs}}, {ID: Solver, Name: "Solver", Description: "Assembly constraint solving via server-side runners", DependsOn: []string{Jobs}}, + {ID: Sessions, Name: "Sessions", Description: "Workstation registration, edit sessions, and presence tracking", DependsOn: []string{Auth}, DefaultEnabled: true}, } // NewRegistry creates a registry with all builtin modules set to their default state. diff --git a/internal/modules/modules_test.go b/internal/modules/modules_test.go index c89b0b8..a15a907 100644 --- a/internal/modules/modules_test.go +++ b/internal/modules/modules_test.go @@ -137,8 +137,8 @@ func TestAll_ReturnsAllModules(t *testing.T) { r := NewRegistry() all := r.All() - if len(all) != 11 { - t.Errorf("expected 11 modules, got %d", len(all)) + if len(all) != 12 { + t.Errorf("expected 12 modules, got %d", len(all)) } // Should be sorted by ID. -- 2.49.1