diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16e23f69..675f8991 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,18 +41,12 @@ jobs: run: go build -o ${{ matrix.binary }} -ldflags "-s -w" . # Phase B + C build-tag matrix. Each tag variant must compile so the - # tag boundaries don't drift. The OTel and wazero tags are gated - # behind `continue-on-error: true` until the upstream modules land in - # go.mod (tracked in PHASE_BC_LOCAL_TODO.md). Once the modules are - # added, drop continue-on-error so a missing tag combo fails CI. + # tag boundaries don't drift. - name: Build with -tags otel (Phase B Step 8) - continue-on-error: true run: go build -tags otel ./... - name: Build with -tags wazero (Phase C Step 9) - continue-on-error: true run: go build -tags wazero ./... - name: Build with -tags otel,wazero (full community-hub build) - continue-on-error: true run: go build -tags otel,wazero ./... - name: Go vulnerability check diff --git a/Server/api/plugins_handler.go b/Server/api/plugins_handler.go index 6fa750c1..aa9b65cd 100644 --- a/Server/api/plugins_handler.go +++ b/Server/api/plugins_handler.go @@ -7,6 +7,7 @@ package api import ( "io" + "log/slog" "net/http" "strconv" @@ -76,7 +77,11 @@ func (h *PluginAdminHandler) install(w http.ResponseWriter, r *http.Request) { } name, err := h.registry.InstallFromZip(r.Context(), body) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + slog.Error("plugin install failed", "error", err) + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INSTALL_FAILED", + Message: "plugin installation failed", + }) return } writeJSON(w, http.StatusCreated, map[string]any{"name": name}) diff --git a/Server/db/queries/sqlite/events.sql b/Server/db/queries/sqlite/events.sql index ec23190a..2fa05a77 100644 --- a/Server/db/queries/sqlite/events.sql +++ b/Server/db/queries/sqlite/events.sql @@ -3,7 +3,7 @@ INSERT INTO events (seq, event_type, channel_id, payload) VALUES (?, ?, ?, ?); -- name: GetMaxEventSeq :one -SELECT COALESCE(MAX(seq), 0) FROM events; +SELECT CAST(COALESCE(MAX(seq), 0) AS INTEGER) AS max_seq FROM events; -- name: GetEventsSince :many SELECT seq, event_type, channel_id, payload, created_at diff --git a/Server/plugin/loader.go b/Server/plugin/loader.go index 3173739f..458660e0 100644 --- a/Server/plugin/loader.go +++ b/Server/plugin/loader.go @@ -29,8 +29,8 @@ type foundPlugin struct { } // scanPluginDirectory walks dir non-recursively and parses plugin.json from -// every immediate subdirectory. Errors on individual plugins are wrapped and -// returned alongside the successful entries. +// every immediate subdirectory. Returns on the first error encountered; +// partial results are not returned alongside errors. func scanPluginDirectory(dir string) ([]foundPlugin, error) { if dir == "" { return nil, nil @@ -75,9 +75,9 @@ func scanPluginDirectory(dir string) ([]foundPlugin, error) { // handler enforces that resolved paths stay rooted at pluginDir, but // http.ServeFile / os.Open follow symlinks transparently — a malicious // plugin .zip containing `assets/index.html -> /etc/passwd` would - // otherwise serve host files. Lstat (not Stat) is used for the - // entrypoint check below so a symlink is detected instead of - // followed, even when its target is a valid .wasm file. + // otherwise serve host files. os.Lstat is used for the entrypoint + // check below so a symlink is detected instead of followed, even + // when its target is a valid .wasm file. if err := rejectSymlinksUnder(pluginDir); err != nil { return nil, fmt.Errorf("plugin %q: %w", e.Name(), err) } diff --git a/Server/service/user.go b/Server/service/user.go index ab998550..4569d1b6 100644 --- a/Server/service/user.go +++ b/Server/service/user.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "log/slog" "time" @@ -50,7 +51,7 @@ func (s *UserService) UpdateProfile(userID int64, username string, avatar *strin return user, nil } -// ChangePassword verifies the old password hash matches, then updates. +// ChangePassword updates the user's password and revokes other sessions. // Returns the number of other sessions revoked. func (s *UserService) ChangePassword(userID int64, newPasswordHash string, keepSessionID int64) (int64, error) { if err := s.st.UpdateUserPassword(userID, newPasswordHash); err != nil { @@ -77,7 +78,10 @@ func (s *UserService) ListSessions(userID int64) ([]db.Session, error) { // RevokeSession deletes a specific session owned by the user. func (s *UserService) RevokeSession(userID, sessionID int64) error { if err := s.st.DeleteSessionByID(sessionID, userID); err != nil { - return fmt.Errorf("%w: session not found", ErrNotFound) + if errors.Is(err, db.ErrNotFound) { + return fmt.Errorf("%w: session not found", ErrNotFound) + } + return fmt.Errorf("%w: failed to revoke session", ErrInternal) } _ = s.st.LogAudit(userID, "session_revoke", "session", sessionID, "session revoked") slog.Info("session revoked", "user_id", userID, "session_id", sessionID)