diff --git a/CHANGELOG.md b/CHANGELOG.md index ea12fcb1..237662ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Slack Notification Provider**: Send alerts to Slack channels via Incoming Webhooks + - Supports JSON templates (minimal, detailed, custom) with Slack's native `text` format + - Webhook URL stored securely — never exposed in API responses + - Optional channel display name for easy identification in provider list + - Feature flag: `feature.notifications.service.slack.enabled` (on by default) + - See [Notification Guide](docs/features/notifications.md) for setup instructions + ### CI/CD - **Supply Chain**: Optimized verification workflow to prevent redundant builds - Change: Removed direct Push/PR triggers; now waits for 'Docker Build' via `workflow_run` diff --git a/backend/internal/api/handlers/notification_coverage_test.go b/backend/internal/api/handlers/notification_coverage_test.go index 4b56cb9e..2b072741 100644 --- a/backend/internal/api/handlers/notification_coverage_test.go +++ b/backend/internal/api/handlers/notification_coverage_test.go @@ -948,14 +948,14 @@ func TestNotificationProviderHandler_Update_UnsupportedType(t *testing.T) { existing := models.NotificationProvider{ ID: "unsupported-type", Name: "Custom Provider", - Type: "slack", - URL: "https://hooks.slack.com/test", + Type: "pushover", + URL: "https://pushover.example.com/test", } require.NoError(t, db.Create(&existing).Error) payload := map[string]any{ - "name": "Updated Slack Provider", - "url": "https://hooks.slack.com/updated", + "name": "Updated Pushover Provider", + "url": "https://pushover.example.com/updated", } body, _ := json.Marshal(payload) diff --git a/backend/internal/api/handlers/notification_provider_blocker3_test.go b/backend/internal/api/handlers/notification_provider_blocker3_test.go index 3d71d38e..71568b7f 100644 --- a/backend/internal/api/handlers/notification_provider_blocker3_test.go +++ b/backend/internal/api/handlers/notification_provider_blocker3_test.go @@ -39,7 +39,7 @@ func TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T }{ {"webhook", "webhook", http.StatusCreated}, {"gotify", "gotify", http.StatusCreated}, - {"slack", "slack", http.StatusBadRequest}, + {"slack", "slack", http.StatusCreated}, {"email", "email", http.StatusCreated}, } diff --git a/backend/internal/api/handlers/notification_provider_discord_only_test.go b/backend/internal/api/handlers/notification_provider_discord_only_test.go index f9f67d62..d96a1b0d 100644 --- a/backend/internal/api/handlers/notification_provider_discord_only_test.go +++ b/backend/internal/api/handlers/notification_provider_discord_only_test.go @@ -35,7 +35,7 @@ func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) { }{ {"webhook", "webhook", http.StatusCreated, ""}, {"gotify", "gotify", http.StatusCreated, ""}, - {"slack", "slack", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"}, + {"slack", "slack", http.StatusCreated, ""}, {"telegram", "telegram", http.StatusCreated, ""}, {"generic", "generic", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"}, {"email", "email", http.StatusCreated, ""}, @@ -363,7 +363,7 @@ func TestDiscordOnly_ErrorCodes(t *testing.T) { requestFunc: func(id string) (*http.Request, gin.Params) { payload := map[string]interface{}{ "name": "Test", - "type": "slack", + "type": "pushover", "url": "https://example.com", } body, _ := json.Marshal(payload) diff --git a/backend/internal/api/handlers/notification_provider_handler.go b/backend/internal/api/handlers/notification_provider_handler.go index e45f5b8f..b6b28637 100644 --- a/backend/internal/api/handlers/notification_provider_handler.go +++ b/backend/internal/api/handlers/notification_provider_handler.go @@ -136,6 +136,16 @@ func classifyProviderTestFailure(err error) (code string, category string, messa return "PROVIDER_TEST_UNREACHABLE", "dispatch", "Could not reach provider endpoint. Verify URL, DNS, and network connectivity" } + if strings.Contains(errText, "invalid_payload") || + strings.Contains(errText, "missing_text_or_fallback") { + return "PROVIDER_TEST_VALIDATION_FAILED", "validation", + "Slack rejected the payload. Ensure your template includes a 'text' or 'blocks' field" + } + if strings.Contains(errText, "no_service") { + return "PROVIDER_TEST_AUTH_REJECTED", "dispatch", + "Slack webhook is revoked or the app is disabled. Create a new webhook" + } + return "PROVIDER_TEST_FAILED", "dispatch", "Provider test failed" } @@ -172,7 +182,7 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) { } providerType := strings.ToLower(strings.TrimSpace(req.Type)) - if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" { + if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" { respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type") return } @@ -232,12 +242,12 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) { } providerType := strings.ToLower(strings.TrimSpace(existing.Type)) - if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" { + if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" { respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type") return } - if (providerType == "gotify" || providerType == "telegram") && strings.TrimSpace(req.Token) == "" { + if (providerType == "gotify" || providerType == "telegram" || providerType == "slack") && strings.TrimSpace(req.Token) == "" { // Keep existing token if update payload omits token req.Token = existing.Token } @@ -278,7 +288,8 @@ func isProviderValidationError(err error) bool { strings.Contains(errMsg, "rendered template") || strings.Contains(errMsg, "failed to parse template") || strings.Contains(errMsg, "failed to render template") || - strings.Contains(errMsg, "invalid Discord webhook URL") + strings.Contains(errMsg, "invalid Discord webhook URL") || + strings.Contains(errMsg, "invalid Slack webhook URL") } func (h *NotificationProviderHandler) Delete(c *gin.Context) { @@ -310,6 +321,11 @@ func (h *NotificationProviderHandler) Test(c *gin.Context) { return } + if providerType == "slack" && strings.TrimSpace(req.Token) != "" { + respondSanitizedProviderError(c, http.StatusBadRequest, "TOKEN_WRITE_ONLY", "validation", "Slack webhook URL is accepted only on provider create/update") + return + } + // Email providers use global SMTP + recipients from the URL field; they don't require a saved provider ID. if providerType == "email" { provider := models.NotificationProvider{ @@ -343,7 +359,7 @@ func (h *NotificationProviderHandler) Test(c *gin.Context) { return } - if strings.TrimSpace(provider.URL) == "" { + if providerType != "slack" && strings.TrimSpace(provider.URL) == "" { respondSanitizedProviderError(c, http.StatusBadRequest, "PROVIDER_CONFIG_MISSING", "validation", "Trusted provider configuration is incomplete") return } diff --git a/backend/internal/notifications/feature_flags.go b/backend/internal/notifications/feature_flags.go index 7a3a3405..7443f896 100644 --- a/backend/internal/notifications/feature_flags.go +++ b/backend/internal/notifications/feature_flags.go @@ -7,5 +7,6 @@ const ( FlagGotifyServiceEnabled = "feature.notifications.service.gotify.enabled" FlagWebhookServiceEnabled = "feature.notifications.service.webhook.enabled" FlagTelegramServiceEnabled = "feature.notifications.service.telegram.enabled" + FlagSlackServiceEnabled = "feature.notifications.service.slack.enabled" FlagSecurityProviderEventsEnabled = "feature.notifications.security_provider_events.enabled" ) diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 7d8a08c6..1656ea65 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -48,6 +48,17 @@ var allowedDiscordWebhookHosts = map[string]struct{}{ "canary.discord.com": {}, } +var slackWebhookRegex = regexp.MustCompile(`^https://hooks\.slack\.com/services/T[A-Za-z0-9_-]+/B[A-Za-z0-9_-]+/[A-Za-z0-9_-]+$`) + +func validateSlackWebhookURL(rawURL string) error { + if !slackWebhookRegex.MatchString(rawURL) { + return fmt.Errorf("invalid Slack webhook URL: must match https://hooks.slack.com/services/T.../B.../xxx") + } + return nil +} + +var validateSlackProviderURLFunc = validateSlackWebhookURL + func normalizeURL(serviceType, rawURL string) string { if serviceType == "discord" { matches := discordWebhookRegex.FindStringSubmatch(rawURL) @@ -110,7 +121,7 @@ func supportsJSONTemplates(providerType string) bool { func isSupportedNotificationProviderType(providerType string) bool { switch strings.ToLower(strings.TrimSpace(providerType)) { - case "discord", "email", "gotify", "webhook", "telegram": + case "discord", "email", "gotify", "webhook", "telegram", "slack": return true default: return false @@ -129,6 +140,8 @@ func (s *NotificationService) isDispatchEnabled(providerType string) bool { return s.getFeatureFlagValue(notifications.FlagWebhookServiceEnabled, true) case "telegram": return s.getFeatureFlagValue(notifications.FlagTelegramServiceEnabled, true) + case "slack": + return s.getFeatureFlagValue(notifications.FlagSlackServiceEnabled, true) default: return false } @@ -440,10 +453,21 @@ func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.Noti } } case "slack": - // Slack requires either 'text' or 'blocks' if _, hasText := jsonPayload["text"]; !hasText { if _, hasBlocks := jsonPayload["blocks"]; !hasBlocks { - return fmt.Errorf("slack payload requires 'text' or 'blocks' field") + if messageValue, hasMessage := jsonPayload["message"]; hasMessage { + jsonPayload["text"] = messageValue + normalizedBody, marshalErr := json.Marshal(jsonPayload) + if marshalErr != nil { + return fmt.Errorf("failed to normalize slack payload: %w", marshalErr) + } + body.Reset() + if _, writeErr := body.Write(normalizedBody); writeErr != nil { + return fmt.Errorf("failed to write normalized slack payload: %w", writeErr) + } + } else { + return fmt.Errorf("slack payload requires 'text' or 'blocks' field") + } } } case "gotify": @@ -470,7 +494,7 @@ func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.Noti } } - if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" { + if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" || providerType == "slack" { headers := map[string]string{ "Content-Type": "application/json", "User-Agent": "Charon-Notify/1.0", @@ -516,6 +540,17 @@ func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.Noti body.Write(updatedBody) } + if providerType == "slack" { + decryptedWebhookURL := p.Token + if strings.TrimSpace(decryptedWebhookURL) == "" { + return fmt.Errorf("slack webhook URL is not configured") + } + if validateErr := validateSlackProviderURLFunc(decryptedWebhookURL); validateErr != nil { + return validateErr + } + dispatchURL = decryptedWebhookURL + } + if _, sendErr := s.httpWrapper.Send(ctx, notifications.HTTPWrapperRequest{ URL: dispatchURL, Headers: headers, @@ -739,7 +774,7 @@ func (s *NotificationService) CreateProvider(provider *models.NotificationProvid return err } - if provider.Type != "gotify" && provider.Type != "telegram" { + if provider.Type != "gotify" && provider.Type != "telegram" && provider.Type != "slack" { provider.Token = "" } @@ -775,7 +810,7 @@ func (s *NotificationService) UpdateProvider(provider *models.NotificationProvid return err } - if provider.Type == "gotify" || provider.Type == "telegram" { + if provider.Type == "gotify" || provider.Type == "telegram" || provider.Type == "slack" { if strings.TrimSpace(provider.Token) == "" { provider.Token = existing.Token } diff --git a/backend/internal/services/notification_service_discord_only_test.go b/backend/internal/services/notification_service_discord_only_test.go index 9fb9b19b..8ca4b9ff 100644 --- a/backend/internal/services/notification_service_discord_only_test.go +++ b/backend/internal/services/notification_service_discord_only_test.go @@ -22,7 +22,7 @@ func TestDiscordOnly_CreateProviderRejectsUnsupported(t *testing.T) { service := NewNotificationService(db, nil) - testCases := []string{"slack", "generic"} + testCases := []string{"generic"} for _, providerType := range testCases { t.Run(providerType, func(t *testing.T) { diff --git a/backend/internal/services/notification_service_json_test.go b/backend/internal/services/notification_service_json_test.go index 1e3d9dc9..2ca4727a 100644 --- a/backend/internal/services/notification_service_json_test.go +++ b/backend/internal/services/notification_service_json_test.go @@ -190,6 +190,10 @@ func TestSendJSONPayload_Slack(t *testing.T) { })) defer server.Close() + origValidate := validateSlackProviderURLFunc + defer func() { validateSlackProviderURLFunc = origValidate }() + validateSlackProviderURLFunc = func(rawURL string) error { return nil } + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) @@ -197,7 +201,8 @@ func TestSendJSONPayload_Slack(t *testing.T) { provider := models.NotificationProvider{ Type: "slack", - URL: server.URL, + URL: "#test", + Token: server.URL, Template: "custom", Config: `{"text": {{toJSON .Message}}}`, } diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index d79f7b50..6b2aa547 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -516,14 +516,16 @@ func TestNotificationService_TestProvider_Errors(t *testing.T) { assert.Error(t, err) }) - t.Run("slack type not supported", func(t *testing.T) { + t.Run("slack with missing webhook URL", func(t *testing.T) { provider := models.NotificationProvider{ - Type: "slack", - URL: "https://hooks.slack.com/services/INVALID/WEBHOOK/URL", + Type: "slack", + URL: "#alerts", + Token: "", + Template: "minimal", } err := svc.TestProvider(provider) assert.Error(t, err) - assert.Contains(t, err.Error(), "unsupported provider type") + assert.Contains(t, err.Error(), "slack webhook URL is not configured") }) t.Run("webhook success", func(t *testing.T) { @@ -1451,17 +1453,16 @@ func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) { }) t.Run("slack_requires_text_or_blocks", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer server.Close() + origValidate := validateSlackProviderURLFunc + defer func() { validateSlackProviderURLFunc = origValidate }() + validateSlackProviderURLFunc = func(rawURL string) error { return nil } - // Slack without text or blocks should fail provider := models.NotificationProvider{ Type: "slack", - URL: server.URL, + URL: "#test", + Token: "https://hooks.slack.com/services/T00/B00/xxx", Template: "custom", - Config: `{"message": {{toJSON .Message}}}`, // Missing text/blocks + Config: `{"username": "Charon"}`, } data := map[string]any{ "Title": "Test", @@ -1481,9 +1482,14 @@ func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) { })) defer server.Close() + origValidate := validateSlackProviderURLFunc + defer func() { validateSlackProviderURLFunc = origValidate }() + validateSlackProviderURLFunc = func(rawURL string) error { return nil } + provider := models.NotificationProvider{ Type: "slack", - URL: server.URL, + URL: "#test", + Token: server.URL, Template: "custom", Config: `{"text": {{toJSON .Message}}}`, } @@ -1504,9 +1510,14 @@ func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) { })) defer server.Close() + origValidate := validateSlackProviderURLFunc + defer func() { validateSlackProviderURLFunc = origValidate }() + validateSlackProviderURLFunc = func(rawURL string) error { return nil } + provider := models.NotificationProvider{ Type: "slack", - URL: server.URL, + URL: "#test", + Token: server.URL, Template: "custom", Config: `{"blocks": [{"type": "section", "text": {"type": "mrkdwn", "text": {{toJSON .Message}}}}]}`, } @@ -1826,7 +1837,6 @@ func TestTestProvider_NotifyOnlyRejectsUnsupportedProvider(t *testing.T) { providerType string url string }{ - {"slack", "slack", "https://hooks.slack.com/services/T/B/X"}, {"pushover", "pushover", "pushover://token@user"}, } @@ -3169,3 +3179,299 @@ func TestIsDispatchEnabled_TelegramDisabledByFlag(t *testing.T) { db.Create(&models.Setting{Key: "feature.notifications.service.telegram.enabled", Value: "false"}) assert.False(t, svc.isDispatchEnabled("telegram")) } + +// --- Slack Notification Provider Tests --- + +func TestSlackWebhookURLValidation(t *testing.T) { + tests := []struct { + name string + url string + wantErr bool + }{ + {"valid_url", "https://hooks.slack.com/services/T00000000/B00000000/abcdefghijklmnop", false}, + {"valid_url_with_dashes", "https://hooks.slack.com/services/T0-A_z/B0-A_z/abc-def_123", false}, + {"http_scheme", "http://hooks.slack.com/services/T00000000/B00000000/abcdefghijklmnop", true}, + {"wrong_host", "https://evil.com/services/T00000000/B00000000/abcdefghijklmnop", true}, + {"ip_address", "https://192.168.1.1/services/T00000000/B00000000/abcdefghijklmnop", true}, + {"missing_T_prefix", "https://hooks.slack.com/services/X00000000/B00000000/abcdefghijklmnop", true}, + {"missing_B_prefix", "https://hooks.slack.com/services/T00000000/X00000000/abcdefghijklmnop", true}, + {"query_params", "https://hooks.slack.com/services/T00000000/B00000000/abcdefghijklmnop?token=leak", true}, + {"empty_string", "", true}, + {"just_host", "https://hooks.slack.com", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSlackWebhookURL(tt.url) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestSlackWebhookURLValidation_RejectsHTTP(t *testing.T) { + err := validateSlackWebhookURL("http://hooks.slack.com/services/T00000/B00000/token123") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid Slack webhook URL") +} + +func TestSlackWebhookURLValidation_RejectsIPAddress(t *testing.T) { + err := validateSlackWebhookURL("https://192.168.1.1/services/T00000/B00000/token123") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid Slack webhook URL") +} + +func TestSlackWebhookURLValidation_RejectsWrongHost(t *testing.T) { + err := validateSlackWebhookURL("https://evil.com/services/T00000/B00000/token123") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid Slack webhook URL") +} + +func TestSlackWebhookURLValidation_RejectsQueryParams(t *testing.T) { + err := validateSlackWebhookURL("https://hooks.slack.com/services/T00000/B00000/token123?token=leak") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid Slack webhook URL") +} + +func TestNotificationService_CreateProvider_Slack(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + provider := &models.NotificationProvider{ + Name: "Slack Alerts", + Type: "slack", + URL: "#alerts", + Token: "https://hooks.slack.com/services/T00000/B00000/xxxx", + } + err := svc.CreateProvider(provider) + require.NoError(t, err) + + var saved models.NotificationProvider + require.NoError(t, db.Where("id = ?", provider.ID).First(&saved).Error) + assert.Equal(t, "https://hooks.slack.com/services/T00000/B00000/xxxx", saved.Token) + assert.Equal(t, "#alerts", saved.URL) + assert.Equal(t, "slack", saved.Type) +} + +func TestNotificationService_CreateProvider_Slack_ClearsTokenField(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + provider := &models.NotificationProvider{ + Name: "Webhook Test", + Type: "webhook", + URL: "https://example.com/hook", + Token: "should-be-cleared", + } + err := svc.CreateProvider(provider) + require.NoError(t, err) + + var saved models.NotificationProvider + require.NoError(t, db.Where("id = ?", provider.ID).First(&saved).Error) + assert.Empty(t, saved.Token) +} + +func TestNotificationService_UpdateProvider_Slack_PreservesToken(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + existing := models.NotificationProvider{ + ID: "prov-slack-token", + Type: "slack", + Name: "Slack Alerts", + URL: "#alerts", + Token: "https://hooks.slack.com/services/T00000/B00000/xxxx", + } + require.NoError(t, db.Create(&existing).Error) + + update := models.NotificationProvider{ + ID: "prov-slack-token", + Type: "slack", + Name: "Slack Alerts Updated", + URL: "#general", + Token: "", + } + err := svc.UpdateProvider(&update) + require.NoError(t, err) + assert.Equal(t, "https://hooks.slack.com/services/T00000/B00000/xxxx", update.Token) +} + +func TestNotificationService_TestProvider_Slack(t *testing.T) { + db := setupNotificationTestDB(t) + + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer server.Close() + + origValidate := validateSlackProviderURLFunc + defer func() { validateSlackProviderURLFunc = origValidate }() + validateSlackProviderURLFunc = func(rawURL string) error { return nil } + + svc := NewNotificationService(db, nil) + + provider := models.NotificationProvider{ + Type: "slack", + URL: "#test", + Token: server.URL, + Template: "minimal", + } + + err := svc.TestProvider(provider) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(capturedBody, &payload)) + assert.NotEmpty(t, payload["text"]) +} + +func TestNotificationService_SendExternal_Slack(t *testing.T) { + db := setupNotificationTestDB(t) + _ = db.AutoMigrate(&models.Setting{}) + + received := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + received <- body + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer server.Close() + + origValidate := validateSlackProviderURLFunc + defer func() { validateSlackProviderURLFunc = origValidate }() + validateSlackProviderURLFunc = func(rawURL string) error { return nil } + + svc := NewNotificationService(db, nil) + + provider := models.NotificationProvider{ + Name: "Slack E2E", + Type: "slack", + URL: "#alerts", + Token: server.URL, + Enabled: true, + NotifyProxyHosts: true, + Template: "minimal", + } + require.NoError(t, svc.CreateProvider(&provider)) + + svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil) + + select { + case body := <-received: + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.NotEmpty(t, payload["text"]) + case <-time.After(2 * time.Second): + t.Fatal("Timed out waiting for slack webhook") + } +} + +func TestNotificationService_Slack_PayloadNormalizesMessageToText(t *testing.T) { + db := setupNotificationTestDB(t) + + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer server.Close() + + origValidate := validateSlackProviderURLFunc + defer func() { validateSlackProviderURLFunc = origValidate }() + validateSlackProviderURLFunc = func(rawURL string) error { return nil } + + svc := NewNotificationService(db, nil) + + provider := models.NotificationProvider{ + Type: "slack", + URL: "#test", + Token: server.URL, + Template: "custom", + Config: `{"message": {{toJSON .Message}}}`, + } + data := map[string]any{ + "Title": "Test", + "Message": "Normalize me", + "Time": time.Now().Format(time.RFC3339), + "EventType": "test", + } + + err := svc.sendJSONPayload(context.Background(), provider, data) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(capturedBody, &payload)) + assert.Equal(t, "Normalize me", payload["text"]) +} + +func TestNotificationService_Slack_PayloadRequiresTextOrBlocks(t *testing.T) { + db := setupNotificationTestDB(t) + + origValidate := validateSlackProviderURLFunc + defer func() { validateSlackProviderURLFunc = origValidate }() + validateSlackProviderURLFunc = func(rawURL string) error { return nil } + + svc := NewNotificationService(db, nil) + + provider := models.NotificationProvider{ + Type: "slack", + URL: "#test", + Token: "https://hooks.slack.com/services/T00/B00/xxx", + Template: "custom", + Config: `{"title": {{toJSON .Title}}}`, + } + data := map[string]any{ + "Title": "Test", + "Message": "Test Message", + "Time": time.Now().Format(time.RFC3339), + "EventType": "test", + } + + err := svc.sendJSONPayload(context.Background(), provider, data) + require.Error(t, err) + assert.Contains(t, err.Error(), "slack payload requires 'text' or 'blocks' field") +} + +func TestFlagSlackServiceEnabled_ConstantValue(t *testing.T) { + assert.Equal(t, "feature.notifications.service.slack.enabled", notifications.FlagSlackServiceEnabled) +} + +func TestNotificationService_Slack_IsDispatchEnabled(t *testing.T) { + db := setupNotificationTestDB(t) + _ = db.AutoMigrate(&models.Setting{}) + svc := NewNotificationService(db, nil) + + assert.True(t, svc.isDispatchEnabled("slack")) + + db.Create(&models.Setting{Key: "feature.notifications.service.slack.enabled", Value: "false"}) + assert.False(t, svc.isDispatchEnabled("slack")) +} + +func TestNotificationService_Slack_TokenNotExposedInList(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + provider := &models.NotificationProvider{ + Name: "Slack Secret", + Type: "slack", + URL: "#secret", + Token: "https://hooks.slack.com/services/T00000/B00000/secrettoken", + } + require.NoError(t, svc.CreateProvider(provider)) + + providers, err := svc.ListProviders() + require.NoError(t, err) + require.Len(t, providers, 1) + + providers[0].HasToken = providers[0].Token != "" + providers[0].Token = "" + assert.True(t, providers[0].HasToken) + assert.Empty(t, providers[0].Token) +} diff --git a/docs/features/notifications.md b/docs/features/notifications.md index 6dc421dd..d3463cb4 100644 --- a/docs/features/notifications.md +++ b/docs/features/notifications.md @@ -15,8 +15,7 @@ Notifications can be triggered by various events: | Service | JSON Templates | Native API | Rich Formatting | |---------|----------------|------------|-----------------| -| **Discord** | ✅ Yes | ✅ Webhooks | ✅ Embeds | -| **Gotify** | ✅ Yes | ✅ HTTP API | ✅ Priority + Extras | +| **Discord** | ✅ Yes | ✅ Webhooks | ✅ Embeds || **Slack** | ✅ Yes | ✅ Webhooks | ✅ Native Formatting || **Gotify** | ✅ Yes | ✅ HTTP API | ✅ Priority + Extras | | **Custom Webhook** | ✅ Yes | ✅ HTTP API | ✅ Template-Controlled | | **Email** | ❌ No | ✅ SMTP | ✅ HTML Branded Templates | @@ -60,7 +59,7 @@ JSON templates give you complete control over notification formatting, allowing ### JSON Template Support -For JSON-based services (Discord, Gotify, and Custom Webhook), you can choose from three template options. Email uses its own built-in HTML templates and does not use JSON templates. +For JSON-based services (Discord, Slack, Gotify, and Custom Webhook), you can choose from three template options. Email uses its own built-in HTML templates and does not use JSON templates. #### 1. Minimal Template (Default) @@ -174,11 +173,53 @@ Discord supports rich embeds with colors, fields, and timestamps. - `16776960` - Yellow (warning) - `3066993` - Green (success) +### Slack Webhooks + +Slack notifications send messages to a channel using an Incoming Webhook URL. + +**Setup:** + +1. In Slack, go to **[Your Apps](https://api.slack.com/apps)** → **Create New App** → **From scratch** +2. Under **Features**, select **Incoming Webhooks** and toggle it **on** +3. Click **"Add New Webhook to Workspace"** and choose the channel to post to +4. Copy the Webhook URL (it looks like `https://hooks.slack.com/services/T.../B.../...`) +5. In Charon, go to **Settings** → **Notifications** and click **"Add Provider"** +6. Select **Slack** as the service type +7. Paste your Webhook URL into the **Webhook URL** field +8. Optionally enter a channel display name (e.g., `#alerts`) for easy identification +9. Configure notification triggers and save + +> **Security:** Your Webhook URL is stored securely and is never exposed in API responses. The settings page only shows a `has_token: true` indicator, so your URL stays private even if someone gains read-only access to the API. + +> **Feature Flag:** Slack notifications must be enabled via `feature.notifications.service.slack.enabled` in **Settings** → **Feature Flags** before the Slack provider option appears. + +#### Basic Message + +```json +{ + "text": "{{.Title}}: {{.Message}}" +} +``` + +#### Formatted Message with Context + +```json +{ + "text": "*{{.Title}}*\n{{.Message}}\n\n• *Event:* {{.EventType}}\n• *Host:* {{.HostName}}\n• *Severity:* {{.Severity}}\n• *Time:* {{.Timestamp}}" +} +``` + +**Slack formatting tips:** + +- Use `*bold*` for emphasis +- Use `\n` for line breaks +- Use `•` for bullet points +- Slack automatically linkifies URLs + ## Planned Provider Expansion -Additional providers (for example Slack and Telegram) are planned for later -staged releases. This page will be expanded as each provider is validated and -released. +Additional providers (for example Telegram) are planned for later staged +releases. This page will be expanded as each provider is validated and released. ## Template Variables @@ -341,6 +382,7 @@ Use separate Discord providers for different event types: Be mindful of service limits: - **Discord**: 5 requests per 2 seconds per webhook +- **Slack**: 1 request per second per webhook - **Email**: Subject to your SMTP server's sending limits ### 6. Keep Templates Maintainable diff --git a/docs/issues/slack-manual-testing.md b/docs/issues/slack-manual-testing.md new file mode 100644 index 00000000..7a2fc373 --- /dev/null +++ b/docs/issues/slack-manual-testing.md @@ -0,0 +1,76 @@ +--- +title: "Manual Testing: Slack Notification Provider" +labels: + - testing + - feature + - frontend + - backend +priority: medium +milestone: "v0.2.0-beta.2" +assignees: [] +--- + +# Manual Testing: Slack Notification Provider + +## Description + +Manual test plan for the Slack notification provider feature. Covers scenarios that automated E2E tests cannot fully validate, such as real Slack workspace delivery, message formatting, and edge cases around webhook lifecycle. + +## Pre-requisites + +- A Slack workspace with at least one channel +- An Incoming Webhook URL created via Slack App configuration (https://api.slack.com/messaging/webhooks) +- Access to the Charon instance + +## Test Cases + +### Provider CRUD + +- [ ] **Create**: Add a Slack provider with a valid webhook URL and optional channel name (`#alerts`) +- [ ] **Edit**: Change the channel display name — verify webhook URL is preserved (not cleared) +- [ ] **Test**: Click "Send Test Notification" — verify message appears in Slack channel +- [ ] **Delete**: Remove the Slack provider — verify it no longer appears in the list +- [ ] **Re-create**: Add a new Slack provider after deletion — verify clean state + +### Security + +- [ ] Webhook URL is NOT visible in the provider list UI (only `has_token: true` indicator) +- [ ] Webhook URL is NOT returned in GET `/api/v1/notifications/providers` response body +- [ ] Editing an existing provider does NOT expose the webhook URL in any form field +- [ ] Browser DevTools Network tab shows no webhook URL in any API response + +### Message Delivery + +- [ ] Default template sends a readable notification to Slack +- [ ] Custom JSON template with `text` field renders correctly +- [ ] Custom JSON template with `blocks` renders Block Kit layout +- [ ] Notifications triggered by proxy host changes arrive in Slack +- [ ] Notifications triggered by certificate events arrive in Slack +- [ ] Notifications triggered by uptime events arrive in Slack (if enabled) + +### Error Handling + +- [ ] Invalid webhook URL (not matching `hooks.slack.com/services/` pattern) shows validation error +- [ ] Expired/revoked webhook URL returns `no_service` classification error +- [ ] Disabled feature flag (`feature.notifications.service.slack.enabled=false`) prevents Slack dispatch + +### Edge Cases + +- [ ] Creating provider with empty URL field succeeds (URL is optional channel display name) +- [ ] Very long channel name in URL field is handled gracefully +- [ ] Multiple Slack providers with different webhooks can coexist +- [ ] Switching provider type from Slack to Discord clears the token field appropriately +- [ ] Switching provider type from Discord to Slack shows the webhook URL input field + +### Cross-Browser + +- [ ] Provider CRUD works in Chrome/Chromium +- [ ] Provider CRUD works in Firefox +- [ ] Provider CRUD works in Safari/WebKit + +## Acceptance Criteria + +- [ ] All security test cases pass — webhook URL never exposed +- [ ] End-to-end message delivery confirmed in a real Slack workspace +- [ ] No console errors during any provider operations +- [ ] Feature flag correctly gates Slack functionality diff --git a/docs/plans/archive/eslint-ts-vite-upgrade-spec.md b/docs/plans/archive/eslint-ts-vite-upgrade-spec.md new file mode 100644 index 00000000..b3e6d3a3 --- /dev/null +++ b/docs/plans/archive/eslint-ts-vite-upgrade-spec.md @@ -0,0 +1,1158 @@ +# Major Dependency Upgrade Plan — ESLint v10, TypeScript 6.0, Vite 8 + +**Date:** 2026-03-12 +**Author:** Planning Agent +**Status:** Ready for Review +**Confidence Score:** 82% (High for ESLint v10 + TS 6.0; Medium for Vite 8 — beta with Rolldown migration) + +--- + +## 1. Executive Summary + +This plan covers the upgrade of three major frontend toolchain dependencies in the Charon project: + +| Dependency | Current Version | Target Version | Status | Risk | +|---|---|---|---|---| +| **ESLint** | `^9.39.3 <10.0.0` | `^10.0.0` | Released | **Medium** — plugin compat gate | +| **TypeScript** | `^5.9.3` | `^6.0.0` | Beta (Feb 11) / RC (Mar 6) | **Medium** — 17+ deprecations | +| **Vite** | `^7.3.1` | `8.0.0-beta.18` | Beta (Dec 3, 2025) | **High** — beta, Rolldown replaces Rollup+esbuild | + +### Key Findings + +1. **ESLint v10** is released with a comprehensive migration guide. The primary blocker is a note in `lefthook.yml`: _"ESLint pinned at v9.x.x — do not upgrade until react-hooks plugin supports v10."_ The `eslint-plugin-react-hooks@7.0.1` must be verified for ESLint v10 compatibility before proceeding. + +2. **TypeScript 6.0** is real (Beta: Feb 11, 2026; RC: Mar 6, 2026). It is explicitly designed as a **bridge release** between TS 5.9 and the native Go-based TS 7.0. It introduces 17+ deprecations/breaking changes (new defaults for `strict`, `module`, `target`, `types`, `rootDir`; removal of `outFile`, legacy module systems; deprecated `baseUrl`, `moduleResolution: node`). Charon's current `tsconfig.json` is well-positioned — it already uses `moduleResolution: bundler`, `strict: true`, and `module: ESNext`. The **critical impact** is the `types` default changing to `[]`. + +3. **Vite 8 exists as `8.0.0-beta.18`** (announced Dec 3, 2025). The headline change is **Rolldown replaces both Rollup and esbuild**. JS transforms and minification now use Oxc; CSS minification uses Lightning CSS. The `build.rollupOptions` config key is deprecated in favor of `build.rolldownOptions`, and `output.manualChunks` (object form) is removed. Charon's `vite.config.ts` uses `rollupOptions` with `inlineDynamicImports: true` — both need migration. Ecosystem packages (`@vitejs/plugin-react`, `vitest`) require beta versions for Vite 8 compatibility. + +### Recommended Execution Order + +``` +PR-1: TypeScript 6.0 upgrade (fewer external dependencies, most self-contained) +PR-2: ESLint v10 upgrade (blocked on plugin compat verification) +PR-3: Vite 8 upgrade (beta — stacked on PR-1 + PR-2 branch) +``` + +--- + +## 2. Current Dependency Inventory + +### Root `package.json` (`/projects/Charon/package.json`) + +| Package | Current Version | Category | +|---|---|---| +| `typescript` | `^5.9.3` | devDependency | +| `vite` | `^7.3.1` | devDependency | +| `@playwright/test` | `^1.58.2` | devDependency | +| `prettier` | `^3.8.1` | devDependency | +| `markdownlint-cli2` | `^0.21.0` | devDependency | + +### Frontend `package.json` (`/projects/Charon/frontend/package.json`) + +| Package | Current Version | Category | +|---|---|---| +| `typescript` | `^5.9.3` | devDependency | +| `vite` | `^7.3.1` | devDependency | +| `vitest` | `^4.0.18` | devDependency | +| `eslint` | `^9.39.3 <10.0.0` | devDependency | +| `@eslint/js` | `^9.39.3 <10.0.0` | devDependency | +| `@eslint/css` | `^1.0.0` | devDependency | +| `@eslint/json` | `^1.1.0` | devDependency | +| `@eslint/markdown` | `^7.5.1` | devDependency | +| `typescript-eslint` | `^8.57.0` | devDependency | +| `@typescript-eslint/eslint-plugin` | `^8.57.0` | devDependency | +| `@typescript-eslint/parser` | `^8.57.0` | devDependency | +| `@vitejs/plugin-react` | `^5.1.4` | devDependency | +| `@vitest/coverage-istanbul` | `^4.0.18` | devDependency | +| `@vitest/coverage-v8` | `^4.0.18` | devDependency | +| `@vitest/eslint-plugin` | `^1.6.10` | devDependency | +| `react` | `^19.2.4` | dependency | +| `react-dom` | `^19.2.4` | dependency | +| `react-router-dom` | `^7.13.1` | dependency | +| `@tanstack/react-query` | `^5.90.21` | dependency | + +### ESLint Plugin Inventory (18 plugins) + +| Plugin | Current Version | ESLint v10 Risk | +|---|---|---| +| `eslint-plugin-react-hooks` | `^7.0.1` | **HIGH** — explicit blocker in `lefthook.yml` | +| `eslint-plugin-react-compiler` | `^19.1.0-rc.2` | Medium — RC, check compat | +| `eslint-plugin-react-refresh` | `^0.5.2` | Low | +| `eslint-plugin-import-x` | `^4.16.1` | Low — modern fork | +| `eslint-plugin-jsx-a11y` | `^6.10.2` | Medium | +| `eslint-plugin-security` | `^4.0.0` | Low | +| `eslint-plugin-sonarjs` | `^4.0.2` | Low | +| `eslint-plugin-unicorn` | `^63.0.0` | Low — actively maintained | +| `eslint-plugin-promise` | `^7.2.1` | Low | +| `eslint-plugin-unused-imports` | `^4.4.1` | Low | +| `eslint-plugin-no-unsanitized` | `^4.1.5` | Medium | +| `eslint-plugin-testing-library` | `^7.16.0` | Low | +| `typescript-eslint` | `^8.57.0` | Low — tracks ESLint closely | +| `@vitest/eslint-plugin` | `^1.6.10` | Low | +| `@eslint/css` | `^1.0.0` | Low — official ESLint | +| `@eslint/json` | `^1.1.0` | Low — official ESLint | +| `@eslint/markdown` | `^7.5.1` | Low — official ESLint | + +### Config Files Affected + +| File | Impact Area | +|---|---| +| `frontend/tsconfig.json` | TS 6.0 — `types`, `lib`, defaults | +| `frontend/tsconfig.node.json` | TS 6.0 — minor | +| `frontend/tsconfig.build.json` | TS 6.0 — extends base | +| `frontend/eslint.config.js` | ESLint v10 — plugin compat | +| `eslint.config.js` (root) | ESLint v10 — imports frontend config | +| `frontend/package.json` | All — version bumps | +| `package.json` (root) | TS + Vite version bumps | +| `lefthook.yml` | ESLint v10 — remove pin note | +| `Dockerfile` | Node.js version (already compatible) | + +### Infrastructure + +- **Node.js:** `24.14.0-alpine` (Dockerfile) — meets all upgrade requirements +- **No `.npmrc` file exists** in the project +- **Go:** `1.26.1` (not affected by frontend upgrades) + +--- + +## 3. Breaking Changes Analysis + +### 3.1 ESLint v10 Breaking Changes + +**Source:** [ESLint v10 Migration Guide](https://eslint.org/docs/latest/use/migrate-to-10.0.0) + +| # | Breaking Change | Impact on Charon | Action Required | +|---|---|---|---| +| 1 | **Node.js ≥ v20.19, v22.13, or v24** required | None — already on Node 24.14.0 | None | +| 2 | **`eslint:recommended` updated** — 3 new rules: `no-unassigned-vars`, `no-useless-assignment`, `preserve-caught-error` | May flag new violations in codebase | Fix flagged code or disable rules | +| 3 | **New config file lookup** — searches from linted file, not cwd | Flat config already used; minor risk for monorepo patterns | Verify root config is found correctly | +| 4 | **Old `.eslintrc` format completely removed** | None — already using flat config | None | +| 5 | **JSX references now tracked** — fixes `no-unused-vars` for JSX components | Positive — fewer false positives | May surface new true positives | +| 6 | **`eslint-env` comments reported as errors** | Search codebase for `/* eslint-env */` | Remove if found | +| 7 | **Jiti ≥ v2.2.0 required** | Check transitive dep version | May need explicit install | +| 8 | **Removed deprecated `context` members** — `context.getScope()`, `context.getAncestors()`, etc. | Affects **plugins**, not our config directly | All 18 plugins must be compatible | +| 9 | **Removed deprecated `SourceCode` methods** | Same — plugin concern | Plugin compat verification | +| 10 | **Program AST node range spans entire source** | Unlikely to affect us | None | + +**Critical Plugin Gate:** The `eslint-plugin-react-hooks` compatibility with ESLint v10 must be verified. The `lefthook.yml` at line ~98 explicitly states: _"NOTE: ESLint pinned at v9.x.x — do not upgrade until react-hooks plugin supports v10."_ + +### 3.2 TypeScript 6.0 Breaking Changes + +**Source:** [TypeScript 6.0 Beta Announcement](https://devblogs.microsoft.com/typescript/announcing-typescript-6-0-beta/) and [6.0 Deprecation List](https://github.com/microsoft/TypeScript/issues/54500) + +#### Default Value Changes + +| Setting | Old Default | New Default | Charon Current | Action | +|---|---|---|---|---| +| `strict` | `false` | **`true`** | `true` (explicit) | None — already set | +| `module` | `commonjs` | **`esnext`** | `ESNext` (explicit) | None — already set | +| `target` | `es5` | **`es2025`** (floating) | `ES2022` (explicit) | None — already set | +| `types` | `["*"]` (all @types) | **`[]`** (none) | **Not set** | **ACTION: Add `"types": []`** | +| `rootDir` | inferred | **`.`** (tsconfig dir) | Not set | Verify — no emit, `noEmit: true` | +| `noUncheckedSideEffectImports` | `false` | **`true`** | Not set | Verify no side-effect import issues | +| `libReplacement` | `true` | **`false`** | Not set | None — improves perf | + +#### Deprecations (with `ignoreDeprecations: "6.0"` escape hatch) + +| Deprecation | Charon Uses? | Impact | +|---|---|---| +| `target: es5` | No (`ES2022`) | None | +| `--outFile` | No | None | +| `--downlevelIteration` | No | None | +| `--moduleResolution node/node10` | No (`bundler`) | None | +| `--moduleResolution classic` | No | None | +| `--baseUrl` | No | None | +| `module: amd/umd/systemjs` | No (`ESNext`) | None | +| `esModuleInterop: false` | Not explicitly set | None | +| `allowSyntheticDefaultImports: false` | Not set (`true` in tsconfig.node) | None | +| `alwaysStrict: false` | Not set (`strict: true` covers) | None | +| Legacy `module` keyword for namespaces | No | None | +| `asserts` keyword on imports | No | None | +| `no-default-lib` directives | No | None | + +#### New Features Available + +| Feature | Relevance | +|---|---| +| `import defer` syntax | Future use — deferred module evaluation | +| `--module node20` | Not needed — using bundler | +| `es2025` target/lib | Can update `target` from `ES2022` to `ES2025` | +| Temporal types | Available via `esnext` lib | +| `dom.iterable` included in `dom` | Can simplify `lib` array | +| `--stableTypeOrdering` | Useful for TS 7.0 migration prep | +| Expandable hovers | Editor UX improvement | +| `Map.getOrInsert` / `getOrInsertComputed` | Available via `esnext` lib | +| `RegExp.escape` | Available via `es2025` lib | +| `#/` subpath imports | Available for future module aliasing | + +#### lib.d.ts Changes — ArrayBuffer/Buffer Breaking Change + +TypeScript 5.9 introduced a behavioral change where `ArrayBuffer` is no longer a supertype of several `TypedArray` types. This may cause errors like: + +``` +error TS2345: Argument of type 'ArrayBufferLike' is not assignable to parameter of type 'BufferSource'. +error TS2322: Type 'Buffer' is not assignable to type 'Uint8Array'. +``` + +**Mitigation:** Ensure `@types/node` is at latest version. This is a 5.9 → 6.0 carryover that must be verified. + +### 3.3 Vite 8 Breaking Changes + +**Source:** [Vite 8 Beta Announcement](https://vite.dev/blog/announcing-vite8-beta) and [Migration from v7 Guide](https://main.vite.dev/guide/migration) + +**Version:** `8.0.0-beta.18` (dist-tag: `beta`, announced Dec 3, 2025) + +#### Core Architecture Change: Rolldown Replaces Rollup + esbuild + +Vite 8's defining change is replacing **two bundlers** (esbuild for dev transforms, Rollup for production builds) with a single Rust-based toolchain: + +| Component | Vite 7 | Vite 8 | Impact on Charon | +|---|---|---|---| +| **Bundler** | Rollup | **Rolldown** (`1.0.0-rc.8`) | `rollupOptions` → `rolldownOptions` | +| **JS Transforms** | esbuild | **Oxc** (`@oxc-project/runtime@0.115.0`) | `esbuild` config key deprecated | +| **JS Minification** | esbuild | **Oxc Minifier** | Different minification assumptions | +| **CSS Minification** | esbuild | **Lightning CSS** (`^1.31.1`) | Slightly different output, bundle size may change | +| **Dep Optimization** | esbuild | **Rolldown** | `optimizeDeps.esbuildOptions` deprecated | + +#### Breaking Changes Impacting Charon + +| # | Breaking Change | Impact on Charon | Action Required | +|---|---|---|---| +| 1 | **Node.js `^20.19.0 \|\| >=22.12.0`** required | None — already on Node 24.14.0 | None | +| 2 | **`build.rollupOptions` deprecated** → `build.rolldownOptions` | **HIGH** — `vite.config.ts` uses `rollupOptions` | Rename config key | +| 3 | **`output.manualChunks` object form removed**, function form deprecated | **HIGH** — config sets `manualChunks: undefined` | Remove or migrate to `codeSplitting` | +| 4 | **`output.inlineDynamicImports`** — supported in Rolldown but **deprecated** in favor of `codeSplitting: false` ([rolldown docs](https://rolldown.rs/reference/OutputOptions.inlineDynamicImports)) | **HIGH** — config uses `inlineDynamicImports: true` as temporary workaround | Migrate to `codeSplitting: false`; `inlineDynamicImports` works as fallback | +| 5 | **Default browser targets updated** (Chrome 107→111, Firefox 104→114, Safari 16.0→16.4) | Low — Charon doesn't set explicit `build.target` | None — new defaults are fine | +| 6 | **esbuild no longer a direct dependency** | Low — Charon doesn't use esbuild config | None | +| 7 | **Oxc Minifier** replaces esbuild minifier | Low — different assumptions about source code | Test build output; verify no minification breakage | +| 8 | **Lightning CSS** for CSS minification | Low — may produce slightly different CSS output | Verify CSS output visually | +| 9 | **Consistent CommonJS interop** — `default` import behavior changes for CJS modules | Medium — could affect CJS dependencies (axios, etc.) | Test all runtime imports | +| 10 | **Module resolution format sniffing removed** — `browser`/`module` field heuristic gone | Low — modern packages use `exports` field | Verify no resolution regressions | +| 11 | **`@vitejs/plugin-react` 5.x does NOT support Vite 8** — requires `6.0.0-beta.0` | **HIGH** — must upgrade plugin-react | Upgrade to `@vitejs/plugin-react@6.0.0-beta.0` | +| 12 | **Plugin-react 6.0 uses `@rolldown/pluginutils`** instead of Rollup utils | Low — internal plugin change | None — handled by plugin upgrade | + +#### New Features Available + +| Feature | Relevance to Charon | +|---|---| +| Built-in tsconfig `paths` support (`resolve.tsconfigPaths: true`) | Could replace manual alias config if needed | +| `emitDecoratorMetadata` support | Not needed — Charon doesn't use decorators | +| Performance: 10–30× faster production builds | Direct benefit — faster Docker builds and CI | +| Full Bundle Mode (upcoming) | Future — 3× faster dev server startup | +| Module-level persistent cache (upcoming) | Future — faster rebuilds | + +#### Dockerfile Impact: Rollup Native Skip Flags + +The current Dockerfile sets: + +```dockerfile +ENV npm_config_rollup_skip_nodejs_native=1 \ + ROLLUP_SKIP_NODEJS_NATIVE=1 +``` + +These env vars are **Rollup-specific** for cross-platform builds. With Vite 8, Rollup is replaced by Rolldown, which uses its own native bindings (`@rolldown/binding-linux-x64-musl` for Alpine). These env vars become no-ops but do not cause harm. Rolldown's native bindings are installed per-platform by npm's `optionalDependencies` mechanism — the same mechanism that works for the `$BUILDPLATFORM` Docker flag. + +**Action:** Remove the Rollup skip flags from Dockerfile and verify cross-platform builds still work. Rolldown includes `@rolldown/binding-linux-x64-musl` which is exactly what Alpine requires. + +--- + +## 4. Compatibility Matrix + +### ESLint v10 Plugin Compatibility Verification Matrix + +Each plugin must be verified before the ESLint v10 upgrade. The agent performing PR-2 must run these checks: + +```bash +# For each plugin, check peer dependency support +npm info eslint-plugin-react-hooks peerDependencies +npm info eslint-plugin-react-compiler peerDependencies +npm info eslint-plugin-jsx-a11y peerDependencies +npm info eslint-plugin-import-x peerDependencies +npm info eslint-plugin-security peerDependencies +npm info eslint-plugin-sonarjs peerDependencies +npm info eslint-plugin-unicorn peerDependencies +npm info eslint-plugin-promise peerDependencies +npm info eslint-plugin-unused-imports peerDependencies +npm info eslint-plugin-no-unsanitized peerDependencies +npm info eslint-plugin-testing-library peerDependencies +npm info eslint-plugin-react-refresh peerDependencies +npm info @vitest/eslint-plugin peerDependencies +npm info typescript-eslint peerDependencies +npm info @eslint/css peerDependencies +npm info @eslint/json peerDependencies +npm info @eslint/markdown peerDependencies +``` + +**Decision Gate:** If `eslint-plugin-react-hooks` does NOT support ESLint v10 in its `peerDependencies`, the ESLint v10 upgrade is **BLOCKED**. Do not use `--legacy-peer-deps` or `--force` as a workaround. + +### TypeScript 6.0 Ecosystem Compatibility + +| Tool | TS 6.0 Compat | Notes | +|---|---|---| +| `typescript-eslint@8.57.0` | Likely — tracks TS closely | Verify with `npm install` | +| `vite@7.3.1` | Yes — Vite uses esbuild/swc, not tsc directly | Type-check is separate | +| `vitest@4.0.18` | Yes — same reasoning | Type-check is separate | +| `@vitejs/plugin-react@5.1.4` | Yes | No TS compiler dependency | +| `react@19.2.4` / `@types/react` | Yes | Ensure `@types/react` latest | +| `@tanstack/react-query@5.90.21` | Likely — popular library | TanStack already preparing for TS 6 | +| `knip@5.86.0` | Verify | Uses TS programmatic API | + +### Node.js Compatibility + +| Tool | Min Node.js | Charon Node.js | Status | +|---|---|---|---| +| ESLint v10 | 20.19 / 22.13 / 24+ | 24.14.0 | Compatible | +| TypeScript 6.0 | TBD (likely same as 5.9) | 24.14.0 | Compatible | +| Vite 7 | 20.19 / 22.12+ | 24.14.0 | Compatible | +| Vite 8 | 20.19 / 22.12+ | 24.14.0 | Compatible | + +### Vite 8 Ecosystem Compatibility Matrix + +All Vite-related packages must be updated together. Stable releases do **not** support Vite 8. + +| Package | Current Version | Vite 8 Compatible? | Required Version | Override Needed? | +|---|---|---|---|---| +| `vite` | `^7.3.1` | — | `8.0.0-beta.18` | No — direct install | +| `@vitejs/plugin-react` | `^5.1.4` | **No** (5.x peer: `vite: ^4.2.0 \|\| ^5.0.0 \|\| ^6.0.0 \|\| ^7.0.0`) | `6.0.0-beta.0` (peer: `vite: ^8.0.0` — verified via `npm info`) | No — direct install | +| `vitest` | `^4.0.18` | **No** (deps: `^6.0.0 \|\| ^7.0.0`) | `4.1.0-beta.6` (deps: `^6.0.0 \|\| ^7.0.0 \|\| ^8.0.0-0`) | No — 4.1.0-beta.6 dep range includes Vite 8 | +| `@vitest/coverage-istanbul` | `^4.0.18` | **No** (peer: `vitest: 4.0.18`) | `4.1.0-beta.6` | No — matches vitest beta | +| `@vitest/coverage-v8` | `^4.0.18` | **No** (peer: `vitest: 4.0.18`) | `4.1.0-beta.6` | No — matches vitest beta | +| `@vitest/ui` | `^4.0.18` | **No** (peer: `vitest: 4.0.18`) | `4.1.0-beta.6` | No — matches vitest beta | +| `@vitest/eslint-plugin` | `^1.6.10` | Yes (peer: `vitest: *`) | Keep current | No | +| `@bgotink/playwright-coverage` | `^0.3.2` | Yes (no Vite peer dep) | Keep current | No | +| `@playwright/test` | `^1.58.2` | Yes (no Vite peer dep) | Keep current | No | + +**Key constraints:** + +- `vitest@4.0.18` has `vite` in its **dependencies** (not peer deps) pinned to `^6.0.0 || ^7.0.0` — this will refuse Vite 8 unless overridden +- `vitest@4.1.0-beta.6` extends this to `^6.0.0 || ^7.0.0 || ^8.0.0-0` — supports Vite 8 beta +- `@vitejs/plugin-react@6.0.0-beta.0` peers on `vite: ^8.0.0` (verified via `npm info`). New optional peer deps: `@rolldown/plugin-babel` and `babel-plugin-react-compiler` (both optional — not required) +- All `@vitest/*` packages at `4.1.0-beta.6` must be installed together (strict peer version matching: `vitest: 4.1.0-beta.6`) +- Since `vitest@4.1.0-beta.6` already includes `^8.0.0-0` in its `vite` dependency range, and all `@vitest/*` packages peer to exact `vitest: 4.1.0-beta.6`, **no npm overrides are needed** when all packages are installed in lockstep at their beta versions + +--- + +## 5. `.npmrc` Configuration + +**No `.npmrc` file currently exists in the project.** No changes needed for these upgrades. + +If plugin compatibility issues arise during ESLint v10 upgrade, **do NOT create an `.npmrc` with `legacy-peer-deps=true`**. Instead, wait for plugin updates or use granular `overrides` in `package.json`: + +```jsonc +// package.json — ONLY if a specific plugin ships a fix before updating peerDeps +{ + "overrides": { + "eslint-plugin-EXAMPLE": { + "eslint": "^10.0.0" + } + } +} +``` + +--- + +## 6. Dockerfile Changes + +**No Dockerfile changes required** for ESLint v10 or TypeScript 6.0. + +**Vite 8 requires Dockerfile changes** — the Rollup native skip flags become irrelevant: + +```diff + # Set environment to bypass native binary requirement for cross-arch builds +- ENV npm_config_rollup_skip_nodejs_native=1 \ +- ROLLUP_SKIP_NODEJS_NATIVE=1 ++ # Vite 8 uses Rolldown (Rust native bindings, auto-resolved per platform) ++ # No skip flags needed — Rolldown's optionalDependencies handle cross-platform +``` + +Current Dockerfile state (frontend-builder stage): + +```dockerfile +FROM --platform=$BUILDPLATFORM node:24.14.0-alpine AS frontend-builder +# ... +ENV npm_config_rollup_skip_nodejs_native=1 \ + ROLLUP_SKIP_NODEJS_NATIVE=1 +RUN npm ci +COPY frontend/ ./ +RUN npm run build +``` + +- Node.js 24.14.0 meets Vite 8's requirement (`^20.19.0 || >=22.12.0`) +- `npm ci` will install Rolldown's `@rolldown/binding-linux-x64-musl` automatically on Alpine +- `--platform=$BUILDPLATFORM` ensures native bindings match the build machine architecture +- The `VITE_APP_VERSION` env var and build output (`dist/`) remain unchanged +- No new environment variables or build args needed + +**Future (Vite 8):** If Vite 8 requires a higher Node.js, upgrade the base image at that time. + +--- + +## 7. Config File Changes + +### 7.1 TypeScript 6.0 — `frontend/tsconfig.json` + +```diff + { + "compilerOptions": { + "target": "ES2022", ++ // Consider upgrading to "ES2025" (TS 6.0 new target) + "useDefineForClassFields": true, +- "lib": ["ES2022", "DOM", "DOM.Iterable"], ++ "lib": ["ES2022", "DOM"], ++ // DOM.Iterable is now included in DOM as of TS 6.0 + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, ++ ++ /* TS 6.0 — explicit types to override new default of [] */ ++ "types": [] + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] + } +``` + +**Key changes:** + +1. **`"types": []`** — Explicitly set to `[]`. Charon uses `noEmit: true` and doesn't rely on global `@types` packages in the main tsconfig. All types come from explicit imports. +2. **`"lib"` simplification** — Remove `"DOM.Iterable"` since TS 6.0 includes it in `"DOM"` automatically. +3. **`"target"` consideration** — Can optionally upgrade from `ES2022` to `ES2025` to access `RegExp.escape` and other ES2025 types natively. Not required. + +### 7.2 TypeScript 6.0 — `frontend/tsconfig.node.json` + +```diff + { + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, +- "strict": true ++ "strict": true, ++ "types": [] + }, + "include": ["vite.config.ts"] + } +``` + +**Note:** `allowSyntheticDefaultImports` is fine — TS 6.0 deprecates setting it to `false`, not `true`. Setting it to `true` remains valid. + +### 7.3 ESLint v10 — `frontend/package.json` Version Caps + +```diff + "devDependencies": { +- "eslint": "^9.39.3 <10.0.0", ++ "eslint": "^10.0.0", +- "@eslint/js": "^9.39.3 <10.0.0", ++ "@eslint/js": "^10.0.0", + // ... all other ESLint plugins may need version bumps + } +``` + +### 7.4 ESLint v10 — `frontend/eslint.config.js` + +Likely no structural changes needed since Charon already uses flat config. Potential changes: + +- Remove any `/* eslint-env */` comments found in source files +- Handle new `eslint:recommended` rules (`no-unassigned-vars`, `no-useless-assignment`, `preserve-caught-error`) +- Verify `tseslint.config()` wrapper compatibility + +### 7.5 ESLint v10 — `lefthook.yml` + +```diff ++ # NOTE: ESLint v10 is supported — plugin compatibility verified on [DATE] +- # NOTE: ESLint pinned at v9.x.x — do not upgrade until react-hooks plugin supports v10. +``` + +### 7.6 TypeScript 6.0 — `package.json` (Root + Frontend) + +```diff + "devDependencies": { +- "typescript": "^5.9.3", ++ "typescript": "^6.0.0", + } +``` + +--- + +## 8. Phase-by-Phase Implementation Plan + +### Phase 1: Pre-Upgrade Verification (Both PRs) + +**Owner:** Frontend_Dev agent (or whoever picks up the PR) + +1. **Snapshot current state:** + + ```bash + cd /projects/Charon && npm run lint 2>&1 | tee /tmp/eslint-v9-baseline.log + cd /projects/Charon/frontend && npx tsc --noEmit 2>&1 | tee /tmp/tsc-v5-baseline.log + ``` + +2. **Verify ESLint plugin compatibility (PR-2 gate):** + + ```bash + for plugin in eslint-plugin-react-hooks eslint-plugin-react-compiler \ + eslint-plugin-jsx-a11y eslint-plugin-import-x eslint-plugin-security \ + eslint-plugin-sonarjs eslint-plugin-unicorn eslint-plugin-promise \ + eslint-plugin-unused-imports eslint-plugin-no-unsanitized \ + eslint-plugin-testing-library eslint-plugin-react-refresh \ + @vitest/eslint-plugin typescript-eslint @eslint/css @eslint/json @eslint/markdown; do + echo "=== $plugin ===" && npm info "$plugin" peerDependencies 2>/dev/null + done + ``` + +3. **Search for `eslint-env` comments:** + + ```bash + grep -r "eslint-env" frontend/src/ --include="*.ts" --include="*.tsx" --include="*.js" + ``` + +### Phase 2: TypeScript 6.0 Upgrade (PR-1) + +**Scope:** TypeScript version bump + tsconfig adjustments + +1. Update `typescript` version in both `package.json` files: + - Root: `^5.9.3` → `^6.0.0` + - Frontend: `^5.9.3` → `^6.0.0` + +2. Apply tsconfig changes (Section 7.1 and 7.2 above): + - Add `"types": []` to `tsconfig.json` and `tsconfig.node.json` + - Remove `"DOM.Iterable"` from `lib` array (now included in `"DOM"`) + +3. Run `npm install` to update lock file + +4. Run type-check and fix any new errors: + + ```bash + cd frontend && npx tsc --noEmit + ``` + +5. Common expected issues: + - Missing types from `@types/*` packages (solved by `"types": []` since we don't use globals) + - `ArrayBuffer`/`Buffer` type narrowing (from TS 5.9 lib.d.ts changes) + - Type argument inference changes (may need explicit type annotations) + +6. Run full test suite: + + ```bash + cd frontend && npx vitest run + ``` + +7. Run Playwright E2E tests to verify build works: + + ```bash + # The Dockerfile builds with npm ci && npm run build + # Verify: cd frontend && npx vite build + ``` + +### Phase 3: ESLint v10 Upgrade (PR-2) + +**Prerequisite:** Phase 1 plugin verification passes. `eslint-plugin-react-hooks` must declare ESLint v10 support. + +1. Remove version cap and update ESLint packages: + + ```bash + cd frontend + npm install -D eslint@^10.0.0 @eslint/js@^10.0.0 + ``` + +2. Update any plugins that need version bumps for ESLint v10 compat + +3. Run ESLint and compare against baseline: + + ```bash + cd /projects/Charon && npm run lint 2>&1 | tee /tmp/eslint-v10-output.log + diff /tmp/eslint-v9-baseline.log /tmp/eslint-v10-output.log + ``` + +4. Address new violations from updated `eslint:recommended`: + - `no-unassigned-vars` — variables declared but never assigned + - `no-useless-assignment` — assignments that are immediately overwritten + - `preserve-caught-error` — catch clause variables that are declared but unused + +5. Remove any `/* eslint-env */` comments found in Phase 1 + +6. Update `lefthook.yml` — remove the ESLint v9 pin note + +7. Run full test suite to confirm no regressions + +### Phase 4: Integration Testing + +1. **Full lint + type-check:** + + ```bash + cd /projects/Charon && npm run lint && cd frontend && npx tsc --noEmit + ``` + +2. **Frontend build:** + + ```bash + cd frontend && npx vite build + ``` + +3. **Unit tests:** + + ```bash + cd frontend && npx vitest run + ``` + +4. **Playwright E2E tests (all browsers):** + + ```bash + npx playwright test --project=chromium + npx playwright test --project=firefox + npx playwright test --project=webkit + ``` + +5. **Docker build verification:** + + ```bash + docker build -t charon:upgrade-test . + ``` + +### Phase 5: Vite 8 Upgrade (PR-3 — stacked commit on same branch) + +**Prerequisites:** PR-1 (TypeScript 6.0) and PR-2 (ESLint v10) already committed on branch. + +**Scope:** Vite `^7.3.1` → `8.0.0-beta.18`, plugin-react `^5.1.4` → `6.0.0-beta.0`, vitest `^4.0.18` → `4.1.0-beta.6`, vite.config.ts migration, Dockerfile cleanup. + +#### Step 1: Install Vite 8 and ecosystem packages + +```bash +cd /projects/Charon/frontend + +# Core Vite upgrade +npm install -D vite@8.0.0-beta.18 + +# Plugin-react upgrade (6.x required for Vite 8) +npm install -D @vitejs/plugin-react@6.0.0-beta.0 + +# Vitest + coverage upgrades (4.1.0-beta.6 supports Vite 8) +npm install -D vitest@4.1.0-beta.6 \ + @vitest/coverage-istanbul@4.1.0-beta.6 \ + @vitest/coverage-v8@4.1.0-beta.6 \ + @vitest/ui@4.1.0-beta.6 +``` + +#### Step 2: Update root `package.json` (direct version bump only — no overrides) + +The root `package.json` only has `vite` as a direct devDependency (used by Playwright). It does **not** need overrides — just a version bump: + +```bash +cd /projects/Charon +npm install -D vite@8.0.0-beta.18 +``` + +#### Step 3: Verify peer dep resolution (overrides likely NOT needed) + +With all packages at their Vite 8-compatible versions, overrides should not be necessary: + +- `vitest@4.1.0-beta.6` depends on `vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0` — already includes Vite 8 +- `@vitejs/plugin-react@6.0.0-beta.0` peers on `vite: ^8.0.0` — matches +- All `@vitest/*@4.1.0-beta.6` peer on `vitest: 4.1.0-beta.6` — matches when installed in lockstep + +Run `npm install` and check for peer dep warnings. **Only add overrides in `frontend/package.json`** (following the established pattern from TS 6.0 and ESLint v10 phases) if specific transitive packages fail to resolve: + +```jsonc +// frontend/package.json — ONLY if npm install reports unresolved peer deps +{ + "overrides": { + // ... existing TS and ESLint overrides ... + // Add scoped overrides ONLY for the specific package that fails, e.g.: + // "some-transitive-package": { "vite": "8.0.0-beta.18" } + } +} +``` + +**Do NOT add a top-level `"vite": "8.0.0-beta.18"` override** — this forces every transitive Vite consumer to resolve to the beta, which is overly broad. If a broad override is truly needed after testing, add it with a comment explaining which transitive package requires it. + +#### Step 4: Migrate `vite.config.ts` + +```diff + import react from '@vitejs/plugin-react' + import { defineConfig } from 'vite' + + export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true + } + } + }, + build: { + outDir: 'dist', + sourcemap: true, +- // TEMPORARY: Disable code splitting to diagnose React initialization issue +- // If this works, the problem is module loading order in async chunks + chunkSizeWarningLimit: 2000, +- rollupOptions: { +- output: { +- // Disable code splitting - bundle everything into one file +- manualChunks: undefined, +- inlineDynamicImports: true +- } +- } ++ rolldownOptions: { ++ output: { ++ // Disable code splitting — single bundle for React init stability ++ // codeSplitting: false is the Rolldown-native approach ++ // (inlineDynamicImports is deprecated in Rolldown) ++ codeSplitting: false ++ } ++ } + } + }) +``` + +**Key changes:** +1. `rollupOptions` → `rolldownOptions` (Rollup config key deprecated) +2. `manualChunks: undefined` removed (object form no longer supported; was already a no-op since `undefined`) +3. `inlineDynamicImports: true` replaced with `codeSplitting: false` — the Rolldown-native equivalent. Rolldown supports `inlineDynamicImports` but marks it as [deprecated](https://rolldown.rs/reference/OutputOptions.inlineDynamicImports) in favor of `codeSplitting: false`. +4. The TEMPORARY comment is preserved in intent — this workaround may still be needed + +**Fallback if `codeSplitting: false` behaves differently than expected:** + +```ts +build: { + rolldownOptions: { + output: { + // Deprecated but still functional in Rolldown 1.0.0-rc.8 + inlineDynamicImports: true + } + } +} +``` + +#### Step 5: Update Dockerfile + +Remove the now-irrelevant Rollup native skip flags: + +```diff +- ENV npm_config_rollup_skip_nodejs_native=1 \ +- ROLLUP_SKIP_NODEJS_NATIVE=1 ++ # Vite 8: Rolldown native bindings auto-resolved per platform via optionalDependencies +``` + +#### Step 6: Run `npm install` to regenerate lock file + +```bash +cd /projects/Charon && npm install +cd /projects/Charon/frontend && npm install +``` + +#### Step 7: Verify builds and tests + +```bash +# 1. Frontend build (most critical — tests Rolldown bundling) +cd /projects/Charon/frontend && npx vite build + +# 2. Type-check (should be unaffected) +cd /projects/Charon/frontend && npx tsc --noEmit + +# 3. Lint (should be unaffected) +cd /projects/Charon && npm run lint + +# 4. Unit tests +cd /projects/Charon/frontend && npx vitest run + +# 5. Docker build (tests Rolldown on Alpine/musl) +docker build -t charon:vite8-test . + +# 6. Playwright E2E (tests the built app end-to-end) +cd /projects/Charon && npx playwright test --project=firefox + +# 7. CJS interop smoke test (verify axios, react-hot-toast, react-hook-form) +# Run the app and manually verify pages that use CJS dependencies render correctly +# See Step 9 for detailed CJS interop verification checklist +``` + +#### Step 8: Verify build output + +```bash +# Compare build output size and structure +ls -la frontend/dist/assets/ +# Should still produce index-*.js, index-*.css +# With codeSplitting: false, should be a single JS bundle +``` + +#### Step 9: Verify CJS interop (Vite 8 behavior change) + +Vite 8's consistent CJS interop may affect imports from CJS packages like `axios` and `react-hot-toast`. **Explicitly verify these packages work at runtime:** + +```bash +# After Docker build or vite build + preview: +# 1. Verify axios API calls work (CJS package with __esModule flag) +# - Navigate to any page that makes API calls (e.g., Dashboard) +# - Check browser console for "default is not a function" errors +# 2. Verify react-hot-toast renders (CJS package) +# - Trigger a toast notification (e.g., save settings) +# - Check browser console for import errors +# 3. Verify react-hook-form works (CJS interop) +# - Open any form page, submit a form +``` + +If any runtime errors appear (e.g., `default is not a function`), use the temporary escape hatch: + +```ts +// vite.config.ts — ONLY if CJS interop breaks +export default defineConfig({ + legacy: { + inconsistentCjsInterop: true + } +}) +``` + +#### Step 10: Update `ARCHITECTURE.md` + +Update the Frontend technology stack table and directory structure to reflect current versions: + +```diff + ### Frontend + | Component | Technology | Version | Purpose | +- | **Build Tool** | Vite | 6.1.9 | Fast bundler and dev server | ++ | **Build Tool** | Vite | 8.0.0-beta.18 | Fast bundler and dev server | +- | **CSS Framework** | Tailwind CSS | 3.x | Utility-first CSS | ++ | **CSS Framework** | Tailwind CSS | 4.2.1 | Utility-first CSS | +- | **Unit Testing** | Vitest | 2.x | Fast unit test runner | ++ | **Unit Testing** | Vitest | 4.1.0-beta.6 | Fast unit test runner | +- | **E2E Testing** | Playwright | 1.50.x | Browser automation | ++ | **E2E Testing** | Playwright | 1.58.2 | Browser automation | +``` + +Also fix the directory structure reference: + +```diff +- │ └── vite.config.js # Vite configuration ++ │ └── vite.config.ts # Vite configuration +``` + +--- + +## 9. Rollback Strategy + +### TypeScript 6.0 Rollback (PR-1) + +1. Revert `package.json` changes (both root and frontend): + + ```diff + - "typescript": "^6.0.0" + + "typescript": "^5.9.3" + ``` + +2. Revert `tsconfig.json` changes (remove `"types": []`, restore `"DOM.Iterable"`) +3. Run `npm install` to restore lock file +4. Verify: `cd frontend && npx tsc --noEmit && npx vitest run` + +**Risk:** Low — TypeScript version is a devDependency only. No runtime impact. `git revert` of the PR commit is sufficient. + +### ESLint v10 Rollback (PR-2) + +1. Revert `package.json` changes: + + ```diff + - "eslint": "^10.0.0" + + "eslint": "^9.39.3 <10.0.0" + - "@eslint/js": "^10.0.0" + + "@eslint/js": "^9.39.3 <10.0.0" + ``` + +2. Revert any plugin version bumps +3. Revert `lefthook.yml` comment change +4. Run `npm install` to restore lock file +5. Verify: `cd /projects/Charon && npm run lint` + +**Risk:** Low — ESLint is a devDependency only. Code changes (fixing new rule violations) are harmless to keep even if ESLint is rolled back. + +### Vite 8 Rollback (PR-3 commit) + +1. Revert `vite` version in both `package.json` files: + + ```diff + - "vite": "8.0.0-beta.18" + + "vite": "^7.3.1" + ``` + +2. Revert ecosystem packages in `frontend/package.json`: + + ```diff + - "@vitejs/plugin-react": "6.0.0-beta.0" + + "@vitejs/plugin-react": "^5.1.4" + - "vitest": "4.1.0-beta.6" + + "vitest": "^4.0.18" + - "@vitest/coverage-istanbul": "4.1.0-beta.6" + + "@vitest/coverage-istanbul": "^4.0.18" + - "@vitest/coverage-v8": "4.1.0-beta.6" + + "@vitest/coverage-v8": "^4.0.18" + - "@vitest/ui": "4.1.0-beta.6" + + "@vitest/ui": "^4.0.18" + ``` + +3. Revert `vite.config.ts`: `rolldownOptions` → `rollupOptions`, restore `manualChunks: undefined` + +4. Revert Dockerfile: restore `ROLLUP_SKIP_NODEJS_NATIVE=1` env vars + +5. Remove Vite 8 overrides from `frontend/package.json` + +6. Run `npm install` to restore lock file + +7. Verify: `cd frontend && npx vite build && npx vitest run` + +**Risk:** Medium — Vite 8 is a pre-release beta. More likely to need rollback than stable upgrades. Since this is a stacked commit on the same branch, `git revert HEAD` cleanly removes only the Vite 8 changes while preserving TS 6.0 and ESLint v10. + +--- + +## 10. Testing Strategy + +### Automated Test Coverage + +| Test Layer | Tool | What It Validates | +|---|---|---| +| Type checking | `tsc --noEmit` | TS 6.0 compatibility, tsconfig changes | +| Linting | `eslint` | ESLint v10 config + plugin compat | +| Unit tests | `vitest run` | No runtime regressions from TS changes | +| E2E tests | Playwright (Chromium, Firefox, WebKit) | Full app build + functionality | +| Docker build | `docker build` | Dockerfile still works with new deps | +| Pre-commit hooks | `lefthook` | All hooks pass with new versions | + +### Specific Test Scenarios for TS 6.0 + +1. **Build output verification:** + + ```bash + cd frontend && npx vite build + # Verify dist/ output is correct, no new warnings + ``` + +2. **Type-check with `--stableTypeOrdering`** (prep for TS 7.0): + + ```bash + cd frontend && npx tsc --noEmit --stableTypeOrdering + # Note any differences — these will be real in TS 7.0 + ``` + +3. **Verify no `@types` resolution issues:** + + ```bash + # With types: [], ensure no global type errors appear + cd frontend && npx tsc --noEmit 2>&1 | grep "Cannot find" + ``` + +### Specific Test Scenarios for ESLint v10 + +1. **Verify all 18 plugins load without errors:** + + ```bash + cd /projects/Charon && npx eslint --print-config frontend/src/App.tsx | head -20 + ``` + +2. **Count new violations vs baseline:** + + ```bash + npx eslint frontend/src/ --format json 2>/dev/null | jq '.[] | .errorCount' | paste -sd+ | bc + ``` + +3. **Verify config lookup works correctly in monorepo:** + + ```bash + # Lint a file from the root — should find root eslint.config.js + npx eslint frontend/src/App.tsx + ``` + +--- + +## 11. Commit Slicing Strategy + +### Decision: 3 Stacked Commits on Single Branch + +**Trigger reasons:** + +- Cross-domain changes (TS and ESLint are independent tools) +- Risk isolation (if one breaks, the other can still merge) +- Review size (each PR is focused and reviewable) +- Plugin compatibility gate (ESLint v10 may be blocked) + +### PR-1: TypeScript 6.0 Upgrade + +| Attribute | Detail | +|---|---| +| **Scope** | TypeScript ^5.9.3 → ^6.0.0, tsconfig changes, fix type errors | +| **Files** | `package.json` (root), `frontend/package.json`, `package-lock.json`, `frontend/tsconfig.json`, `frontend/tsconfig.node.json`, possibly source files with type fixes | +| **Dependencies** | None — can start immediately | +| **Validation Gate** | `tsc --noEmit` passes, `vitest run` passes, `vite build` succeeds, Docker build succeeds | +| **Estimated Complexity** | Medium — mostly defaults are already correct, `types: []` is the main change | +| **Rollback** | `git revert` + `npm install` | + +### PR-2: ESLint v10 Upgrade + +| Attribute | Detail | +|---|---| +| **Scope** | ESLint ^9.x → ^10.0.0, plugin updates, fix new violations, update lefthook | +| **Files** | `frontend/package.json`, `package-lock.json`, `frontend/eslint.config.js` (if needed), `lefthook.yml`, source files with new violations | +| **Dependencies** | **BLOCKED** until `eslint-plugin-react-hooks` declares ESLint v10 support | +| **Validation Gate** | `npm run lint` passes, all plugins load, no new unhandled violations | +| **Estimated Complexity** | Medium — depends on plugin ecosystem readiness | +| **Rollback** | `git revert` + `npm install` | + +### PR-3: Vite 8 Upgrade (stacked commit on same branch) + +| Attribute | Detail | +|---|---| +| **Scope** | Vite 7→8, plugin-react 5→6, vitest 4.0→4.1-beta, vite.config.ts migration, Dockerfile cleanup | +| **Files** | `package.json` (root), `frontend/package.json`, `package-lock.json`, `frontend/vite.config.ts`, `Dockerfile`, `ARCHITECTURE.md` | +| **Dependencies** | PR-1 (TS 6.0) and PR-2 (ESLint v10) already committed on branch | +| **Validation Gate** | `vite build` succeeds with Rolldown, `vitest run` passes, Docker build succeeds, Playwright E2E passes | +| **Estimated Complexity** | **High** — beta software, bundler engine swap (Rollup→Rolldown), multiple ecosystem packages at beta versions | +| **Rollback** | `git revert HEAD` — cleanly removes only the Vite 8 commit | + +#### npm Overrides for PR-3 + +**No overrides expected** when all packages are installed at their beta versions in lockstep: +- `vitest@4.1.0-beta.6` deps include `vite: ^8.0.0-0` — resolves Vite 8 without override +- `@vitest/*@4.1.0-beta.6` peer on `vitest: 4.1.0-beta.6` — satisfied by direct install + +If `npm install` fails, add **scoped** overrides in `frontend/package.json` only for the failing package. Do not add a broad `"vite": "8.0.0-beta.18"` override. + +### Contingency + +- If TS 6.0 stable is delayed past RC, pin to `typescript@6.0.0-rc` temporarily +- If ESLint v10 plugin compat is blocked for >30 days, consider temporarily dropping the blocker plugin or using `--rulesdir` workaround +- If a plugin is permanently abandoned, research replacement plugins +- If Vite 8 beta has blocking regressions, `git revert` the Vite 8 commit and wait for the next beta or stable release — TS 6.0 + ESLint v10 upgrades remain unaffected +- If `vitest@4.1.0-beta.6` fails tests, try pinning `vitest@4.0.18` with an `overrides` entry for its `vite` dependency (force it to accept `^8.0.0-0`) +- If Rolldown's `codeSplitting: false` behaves differently than expected, try the deprecated `inlineDynamicImports: true` as a fallback, or re-investigate the React initialization issue that motivated the workaround + +--- + +## 12. Known Issues & Gotchas + +### ESLint v10 + +1. **react-hooks plugin blocker** — `lefthook.yml` explicitly states the upgrade is blocked until `eslint-plugin-react-hooks` supports v10. This is the #1 risk. + +2. **Config file lookup change** — ESLint v10 finds config files starting from the linted file and walking up. In Charon's monorepo setup (root `eslint.config.js` imports `frontend/eslint.config.js`), verify the root config is still discovered when linting `frontend/src/**`. + +3. **Jiti dependency** — ESLint v10 requires `jiti >= v2.2.0` for loading config files. This is typically a transitive dependency but may need explicit installation if conflicts arise. + +4. **Plugin API breakage** — Plugins that use deprecated `context.getScope()`, `context.getAncestors()`, `context.parserOptions`, or `context.parserPath` will break. All 18 plugins must be verified. + +### TypeScript 6.0 + +1. **`types: []` default** — This is the highest-impact change for Charon. Without explicitly setting `"types"`, TS 6.0 will not auto-load any `@types/*` packages. Since Charon uses `noEmit: true` and explicit imports, this should be fine, but test thoroughly. + +2. **TS 6.0 is a transition release** — It is explicitly designed as a bridge to TS 7.0 (native Go port). Adopting TS 6.0 now prepares us for TS 7.0 later. The `ignoreDeprecations: "6.0"` escape hatch exists if needed. + +3. **`typescript-eslint` compatibility** — If `typescript-eslint@8.57.0` doesn't support TS 6.0, we may need to update it. Check for a release that adds TS 6.0 support. + +4. **`knip` compatibility** — `knip` (`^5.86.0`) uses TS programmatic API internally. Verify it works with TS 6.0. + +5. **ArrayBuffer/Buffer types** — TS 5.9 changes to `lib.d.ts` around `ArrayBuffer` not being a supertype of `TypedArray` may surface with TS 6.0. Ensure `@types/node` is at latest. + +6. **`ts5to6` migration tool** — The experimental [ts5to6](https://github.com/andrewbranch/ts5to6) tool can automatically adjust `baseUrl` and `rootDir`. Charon doesn't use `baseUrl`, so this is of limited value, but worth knowing about. + +### Vite 8 + +1. **Beta software** — `8.0.0-beta.18` is pre-release. Expect edge cases and undocumented behavior. File issues at `https://github.com/vitejs/rolldown-vite/issues`. + +2. **Rolldown bundler is RC, not stable** — Vite 8 depends on `rolldown@1.0.0-rc.8`. Rolldown is feature-complete but may have edge cases with complex chunk splitting configurations. + +3. **`codeSplitting: false` replaces `inlineDynamicImports: true`** — `frontend/vite.config.ts` has a `TEMPORARY` workaround for a "React init issue". Rolldown supports `inlineDynamicImports` but marks it as [deprecated](https://rolldown.rs/reference/OutputOptions.inlineDynamicImports) in favor of `codeSplitting: false`. The migration uses `codeSplitting: false` as the primary approach; `inlineDynamicImports: true` can be used as a deprecated fallback. + +4. **Oxc Minifier assumptions differ from esbuild** — The Oxc Minifier makes [different assumptions](https://oxc.rs/docs/guide/usage/minifier.html#assumptions) about source code than esbuild. If runtime errors appear after build but not in dev, the minifier is the likely culprit. Use `build.minify: false` temporarily to diagnose. + +5. **CJS interop behavior change** — Vite 8 changes how `default` imports from CommonJS modules work. Packages like `axios` (CJS) may be affected. The `legacy.inconsistentCjsInterop: true` escape hatch exists if needed. + +6. **All ecosystem packages are beta** — `@vitejs/plugin-react@6.0.0-beta.0`, `vitest@4.1.0-beta.6`, and all `@vitest/*` packages are pre-release. They are tightly version-locked (e.g., `@vitest/coverage-v8` peers to exact `vitest: 4.1.0-beta.6`). + +7. **Plugin-react 6.0 API change** — The new `@vitejs/plugin-react@6.0.0-beta.0` uses `@rolldown/pluginutils` internally instead of `@rollup/pluginutils`. The public API (`react()` call in config) appears unchanged. New optional peer deps (`@rolldown/plugin-babel`, `babel-plugin-react-compiler`) are not required for Charon's usage. + +8. **Lightning CSS may increase CSS bundle size** — Lightning CSS produces slightly different output than esbuild's CSS minifier. Verify CSS output and check for visual regressions. + +9. **Cross-platform Docker builds** — Rolldown uses native Rust bindings per platform (`@rolldown/binding-linux-x64-musl` for Alpine). The `--platform=$BUILDPLATFORM` Docker flag ensures the correct binding is installed. If cross-arch builds fail, verify the correct `@rolldown/binding-*` package is being resolved. + +--- + +## 13. Risk Assessment + +| Risk | Probability | Impact | Mitigation | +|---|---|---|---| +| `eslint-plugin-react-hooks` doesn't support ESLint v10 | **Medium** | **High** — blocks PR-2 entirely | Monitor npm for updates; check GitHub issues | +| Other ESLint plugins break on v10 | **Low** | **Medium** — individual plugins can be disabled | Verify all 18 plugins; have disable config ready | +| TS 6.0 `types: []` causes unexpected errors | **Medium** | **Low** — easy to fix by adding types | Test with `tsc --noEmit`; add specific types | +| `typescript-eslint` incompatible with TS 6.0 | **Low** | **Medium** — blocks type-aware linting | Check releases; may need to update | +| `knip` breaks with TS 6.0 | **Low** | **Low** — `knip` is optional tooling | Test separately; pin if needed | +| TS 6.0 stable delayed | **Low** | **Low** — RC already available | Use RC or pin beta | +| Vite 8 beta breaks production build | **Medium** | **High** — blocks Docker/deployment | Test `vite build` thoroughly; rollback with `git revert` | +| Rolldown CJS interop breaks runtime imports | **Medium** | **Medium** — runtime errors on CJS packages | Test all CJS deps (axios, etc.); use `legacy.inconsistentCjsInterop` escape | +| Oxc Minifier causes runtime errors | **Low** | **High** — minification bugs are subtle | Compare dev vs prod behavior; use `build.minify: false` to diagnose | +| `vitest@4.1.0-beta.6` incompatible with test suite | **Low** | **Medium** — blocks unit test validation | Pin to `4.0.18` + override vite peer if needed | +| `@vitejs/plugin-react@6.0.0-beta.0` breaks React HMR | **Low** | **Medium** — dev experience degraded | Rollback to 5.1.4 + Vite 7 if critical | +| Rolldown native binding fails on Alpine cross-build | **Low** | **High** — blocks Docker build entirely | Verify `@rolldown/binding-linux-x64-musl` resolves; fall back to non-cross-platform build | +| Lightning CSS produces visual CSS regressions | **Low** | **Low** — cosmetic issues only | Visual diff E2E screenshots | +| Docker build fails after upgrades | **Low** | **Medium** — blocks CI/deployment | Test Docker build in PR CI | +| Playwright E2E failures from TS changes | **Very Low** | **High** — blocks merge | Run full E2E suite before merge | + +### Overall Risk: **MEDIUM-HIGH** + +- TypeScript 6.0 is well-characterized and Charon's tsconfig is well-aligned with the new defaults +- ESLint v10 is dependent on ecosystem readiness (plugin compatibility) +- **Vite 8 is the highest-risk change** — beta software with a complete bundler engine swap (Rollup→Rolldown). The saving grace is that all three upgrades are separate commits on the same branch, enabling surgical rollback of just the Vite 8 commit if needed + +--- + +## Acceptance Criteria + +### PR-1 (TypeScript 6.0) + +- [ ] `typescript` upgraded to `^6.0.0` in root and frontend `package.json` +- [ ] `tsconfig.json` updated with `types: []` and simplified `lib` +- [ ] `tsc --noEmit` passes with zero errors +- [ ] `vitest run` passes all tests +- [ ] `vite build` produces correct output +- [ ] Docker build succeeds +- [ ] No new `ignoreDeprecations` usage (clean upgrade) + +### PR-2 (ESLint v10) + +- [ ] Plugin compatibility verified for all 18 plugins +- [ ] `eslint` and `@eslint/js` upgraded to `^10.0.0` +- [ ] Version cap (`<10.0.0`) removed from both packages +- [ ] `npm run lint` passes (new violations fixed) +- [ ] `lefthook.yml` pin note removed/updated +- [ ] All pre-commit hooks pass + +### PR-3 (Vite 8) + +- [ ] `vite` upgraded to `8.0.0-beta.18` in root and frontend `package.json` +- [ ] `@vitejs/plugin-react` upgraded to `6.0.0-beta.0` +- [ ] `vitest` upgraded to `4.1.0-beta.6` with matching `@vitest/*` packages +- [ ] `vite.config.ts` migrated: `rollupOptions` → `rolldownOptions`, `manualChunks` removed +- [ ] npm overrides verified: no broad overrides needed (or scoped overrides added with justification) +- [ ] Dockerfile: Rollup native skip flags removed +- [ ] `vite build` produces correct output with Rolldown bundler +- [ ] `vitest run` passes all unit tests +- [ ] `tsc --noEmit` still passes (unchanged from PR-1) +- [ ] Docker build succeeds with Rolldown on Alpine/musl +- [ ] Playwright E2E tests pass (all browsers) +- [ ] No CJS interop runtime errors (axios, react-hot-toast, etc.) +- [ ] CJS interop verified: axios API calls, react-hot-toast renders, react-hook-form submits work +- [ ] CSS output visually correct (Lightning CSS minification) +- [ ] `ARCHITECTURE.md` updated: Vite 8.0.0-beta.18, Vitest 4.1.0-beta.6, Playwright 1.58.2, Tailwind CSS 4.2.1, `vite.config.ts` filename +- [ ] Pre-commit hooks pass (`lefthook`) diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index b3e6d3a3..1120efcb 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,1158 +1,706 @@ -# Major Dependency Upgrade Plan — ESLint v10, TypeScript 6.0, Vite 8 +# Slack Notification Provider — Implementation Specification -**Date:** 2026-03-12 -**Author:** Planning Agent -**Status:** Ready for Review -**Confidence Score:** 82% (High for ESLint v10 + TS 6.0; Medium for Vite 8 — beta with Rolldown migration) +**Status:** Draft +**Created:** 2026-03-12 +**Target:** Single PR (backend + frontend + E2E + docs) --- -## 1. Executive Summary +## 1. Overview -This plan covers the upgrade of three major frontend toolchain dependencies in the Charon project: +### What -| Dependency | Current Version | Target Version | Status | Risk | -|---|---|---|---|---| -| **ESLint** | `^9.39.3 <10.0.0` | `^10.0.0` | Released | **Medium** — plugin compat gate | -| **TypeScript** | `^5.9.3` | `^6.0.0` | Beta (Feb 11) / RC (Mar 6) | **Medium** — 17+ deprecations | -| **Vite** | `^7.3.1` | `8.0.0-beta.18` | Beta (Dec 3, 2025) | **High** — beta, Rolldown replaces Rollup+esbuild | +Add Slack as a supported notification provider type, using Slack Incoming Webhooks to post messages to channels. The webhook URL (`https://hooks.slack.com/services/T.../B.../xxx`) acts as the authentication mechanism — no separate API key is required. -### Key Findings +### How it Fits -1. **ESLint v10** is released with a comprehensive migration guide. The primary blocker is a note in `lefthook.yml`: _"ESLint pinned at v9.x.x — do not upgrade until react-hooks plugin supports v10."_ The `eslint-plugin-react-hooks@7.0.1` must be verified for ESLint v10 compatibility before proceeding. +Slack follows the **exact same pattern** as Discord, Gotify, Telegram, and Generic Webhook providers. It: +- Uses `sendJSONPayload()` for dispatch (same as Discord/Gotify/Telegram/Webhook) +- Requires `text` or `blocks` in the payload (validation already exists in `sendJSONPayload`) +- Stores the webhook URL in the `Token` column (same security treatment as Telegram bot token) +- Is gated by a feature flag `feature.notifications.service.slack.enabled` -2. **TypeScript 6.0** is real (Beta: Feb 11, 2026; RC: Mar 6, 2026). It is explicitly designed as a **bridge release** between TS 5.9 and the native Go-based TS 7.0. It introduces 17+ deprecations/breaking changes (new defaults for `strict`, `module`, `target`, `types`, `rootDir`; removal of `outFile`, legacy module systems; deprecated `baseUrl`, `moduleResolution: node`). Charon's current `tsconfig.json` is well-positioned — it already uses `moduleResolution: bundler`, `strict: true`, and `module: ESNext`. The **critical impact** is the `types` default changing to `[]`. +### Webhook URL Security -3. **Vite 8 exists as `8.0.0-beta.18`** (announced Dec 3, 2025). The headline change is **Rolldown replaces both Rollup and esbuild**. JS transforms and minification now use Oxc; CSS minification uses Lightning CSS. The `build.rollupOptions` config key is deprecated in favor of `build.rolldownOptions`, and `output.manualChunks` (object form) is removed. Charon's `vite.config.ts` uses `rollupOptions` with `inlineDynamicImports: true` — both need migration. Ecosystem packages (`@vitejs/plugin-react`, `vitest`) require beta versions for Vite 8 compatibility. +The Slack webhook URL contains embedded authentication credentials. The **entire Slack webhook URL is sensitive**. It must be treated the same as Telegram bot tokens and Gotify tokens: +- Redacted from `GET /api/v1/notifications/providers` responses +- Stored in the `Token` column (uses `json:"-"` tag) — never returned in plaintext to the frontend +- Frontend shows a masked placeholder and "stored" indicator via `HasToken` -### Recommended Execution Order +**Decision: Webhook URL stored in `Token` field.** +To reuse the existing token-redaction infrastructure (`json:"-"` tag, `HasToken` computed field, write-only semantics), the Slack webhook URL will be stored in the `Token` column, NOT the `URL` column. The `URL` column will hold an optional display-safe channel name. This follows the same security pattern as Telegram (where the bot token goes in `Token` and the chat ID goes in `URL`). -``` -PR-1: TypeScript 6.0 upgrade (fewer external dependencies, most self-contained) -PR-2: ESLint v10 upgrade (blocked on plugin compat verification) -PR-3: Vite 8 upgrade (beta — stacked on PR-1 + PR-2 branch) -``` +| Field | Slack Usage | Example | +|-------|------------|---------| +| `Token` | Full webhook URL (write-only, redacted) | `https://hooks.slack.com/services/T00.../B00.../xxxx` | +| `URL` | Channel display name (optional, user-facing) | `#alerts` | +| `HasToken` | `true` when webhook URL is set | — | --- -## 2. Current Dependency Inventory +## 2. Backend Changes (Go) -### Root `package.json` (`/projects/Charon/package.json`) +### 2.1 `backend/internal/notifications/feature_flags.go` -| Package | Current Version | Category | -|---|---|---| -| `typescript` | `^5.9.3` | devDependency | -| `vite` | `^7.3.1` | devDependency | -| `@playwright/test` | `^1.58.2` | devDependency | -| `prettier` | `^3.8.1` | devDependency | -| `markdownlint-cli2` | `^0.21.0` | devDependency | +**Add constant:** -### Frontend `package.json` (`/projects/Charon/frontend/package.json`) - -| Package | Current Version | Category | -|---|---|---| -| `typescript` | `^5.9.3` | devDependency | -| `vite` | `^7.3.1` | devDependency | -| `vitest` | `^4.0.18` | devDependency | -| `eslint` | `^9.39.3 <10.0.0` | devDependency | -| `@eslint/js` | `^9.39.3 <10.0.0` | devDependency | -| `@eslint/css` | `^1.0.0` | devDependency | -| `@eslint/json` | `^1.1.0` | devDependency | -| `@eslint/markdown` | `^7.5.1` | devDependency | -| `typescript-eslint` | `^8.57.0` | devDependency | -| `@typescript-eslint/eslint-plugin` | `^8.57.0` | devDependency | -| `@typescript-eslint/parser` | `^8.57.0` | devDependency | -| `@vitejs/plugin-react` | `^5.1.4` | devDependency | -| `@vitest/coverage-istanbul` | `^4.0.18` | devDependency | -| `@vitest/coverage-v8` | `^4.0.18` | devDependency | -| `@vitest/eslint-plugin` | `^1.6.10` | devDependency | -| `react` | `^19.2.4` | dependency | -| `react-dom` | `^19.2.4` | dependency | -| `react-router-dom` | `^7.13.1` | dependency | -| `@tanstack/react-query` | `^5.90.21` | dependency | - -### ESLint Plugin Inventory (18 plugins) - -| Plugin | Current Version | ESLint v10 Risk | -|---|---|---| -| `eslint-plugin-react-hooks` | `^7.0.1` | **HIGH** — explicit blocker in `lefthook.yml` | -| `eslint-plugin-react-compiler` | `^19.1.0-rc.2` | Medium — RC, check compat | -| `eslint-plugin-react-refresh` | `^0.5.2` | Low | -| `eslint-plugin-import-x` | `^4.16.1` | Low — modern fork | -| `eslint-plugin-jsx-a11y` | `^6.10.2` | Medium | -| `eslint-plugin-security` | `^4.0.0` | Low | -| `eslint-plugin-sonarjs` | `^4.0.2` | Low | -| `eslint-plugin-unicorn` | `^63.0.0` | Low — actively maintained | -| `eslint-plugin-promise` | `^7.2.1` | Low | -| `eslint-plugin-unused-imports` | `^4.4.1` | Low | -| `eslint-plugin-no-unsanitized` | `^4.1.5` | Medium | -| `eslint-plugin-testing-library` | `^7.16.0` | Low | -| `typescript-eslint` | `^8.57.0` | Low — tracks ESLint closely | -| `@vitest/eslint-plugin` | `^1.6.10` | Low | -| `@eslint/css` | `^1.0.0` | Low — official ESLint | -| `@eslint/json` | `^1.1.0` | Low — official ESLint | -| `@eslint/markdown` | `^7.5.1` | Low — official ESLint | - -### Config Files Affected - -| File | Impact Area | -|---|---| -| `frontend/tsconfig.json` | TS 6.0 — `types`, `lib`, defaults | -| `frontend/tsconfig.node.json` | TS 6.0 — minor | -| `frontend/tsconfig.build.json` | TS 6.0 — extends base | -| `frontend/eslint.config.js` | ESLint v10 — plugin compat | -| `eslint.config.js` (root) | ESLint v10 — imports frontend config | -| `frontend/package.json` | All — version bumps | -| `package.json` (root) | TS + Vite version bumps | -| `lefthook.yml` | ESLint v10 — remove pin note | -| `Dockerfile` | Node.js version (already compatible) | - -### Infrastructure - -- **Node.js:** `24.14.0-alpine` (Dockerfile) — meets all upgrade requirements -- **No `.npmrc` file exists** in the project -- **Go:** `1.26.1` (not affected by frontend upgrades) - ---- - -## 3. Breaking Changes Analysis - -### 3.1 ESLint v10 Breaking Changes - -**Source:** [ESLint v10 Migration Guide](https://eslint.org/docs/latest/use/migrate-to-10.0.0) - -| # | Breaking Change | Impact on Charon | Action Required | -|---|---|---|---| -| 1 | **Node.js ≥ v20.19, v22.13, or v24** required | None — already on Node 24.14.0 | None | -| 2 | **`eslint:recommended` updated** — 3 new rules: `no-unassigned-vars`, `no-useless-assignment`, `preserve-caught-error` | May flag new violations in codebase | Fix flagged code or disable rules | -| 3 | **New config file lookup** — searches from linted file, not cwd | Flat config already used; minor risk for monorepo patterns | Verify root config is found correctly | -| 4 | **Old `.eslintrc` format completely removed** | None — already using flat config | None | -| 5 | **JSX references now tracked** — fixes `no-unused-vars` for JSX components | Positive — fewer false positives | May surface new true positives | -| 6 | **`eslint-env` comments reported as errors** | Search codebase for `/* eslint-env */` | Remove if found | -| 7 | **Jiti ≥ v2.2.0 required** | Check transitive dep version | May need explicit install | -| 8 | **Removed deprecated `context` members** — `context.getScope()`, `context.getAncestors()`, etc. | Affects **plugins**, not our config directly | All 18 plugins must be compatible | -| 9 | **Removed deprecated `SourceCode` methods** | Same — plugin concern | Plugin compat verification | -| 10 | **Program AST node range spans entire source** | Unlikely to affect us | None | - -**Critical Plugin Gate:** The `eslint-plugin-react-hooks` compatibility with ESLint v10 must be verified. The `lefthook.yml` at line ~98 explicitly states: _"NOTE: ESLint pinned at v9.x.x — do not upgrade until react-hooks plugin supports v10."_ - -### 3.2 TypeScript 6.0 Breaking Changes - -**Source:** [TypeScript 6.0 Beta Announcement](https://devblogs.microsoft.com/typescript/announcing-typescript-6-0-beta/) and [6.0 Deprecation List](https://github.com/microsoft/TypeScript/issues/54500) - -#### Default Value Changes - -| Setting | Old Default | New Default | Charon Current | Action | -|---|---|---|---|---| -| `strict` | `false` | **`true`** | `true` (explicit) | None — already set | -| `module` | `commonjs` | **`esnext`** | `ESNext` (explicit) | None — already set | -| `target` | `es5` | **`es2025`** (floating) | `ES2022` (explicit) | None — already set | -| `types` | `["*"]` (all @types) | **`[]`** (none) | **Not set** | **ACTION: Add `"types": []`** | -| `rootDir` | inferred | **`.`** (tsconfig dir) | Not set | Verify — no emit, `noEmit: true` | -| `noUncheckedSideEffectImports` | `false` | **`true`** | Not set | Verify no side-effect import issues | -| `libReplacement` | `true` | **`false`** | Not set | None — improves perf | - -#### Deprecations (with `ignoreDeprecations: "6.0"` escape hatch) - -| Deprecation | Charon Uses? | Impact | -|---|---|---| -| `target: es5` | No (`ES2022`) | None | -| `--outFile` | No | None | -| `--downlevelIteration` | No | None | -| `--moduleResolution node/node10` | No (`bundler`) | None | -| `--moduleResolution classic` | No | None | -| `--baseUrl` | No | None | -| `module: amd/umd/systemjs` | No (`ESNext`) | None | -| `esModuleInterop: false` | Not explicitly set | None | -| `allowSyntheticDefaultImports: false` | Not set (`true` in tsconfig.node) | None | -| `alwaysStrict: false` | Not set (`strict: true` covers) | None | -| Legacy `module` keyword for namespaces | No | None | -| `asserts` keyword on imports | No | None | -| `no-default-lib` directives | No | None | - -#### New Features Available - -| Feature | Relevance | -|---|---| -| `import defer` syntax | Future use — deferred module evaluation | -| `--module node20` | Not needed — using bundler | -| `es2025` target/lib | Can update `target` from `ES2022` to `ES2025` | -| Temporal types | Available via `esnext` lib | -| `dom.iterable` included in `dom` | Can simplify `lib` array | -| `--stableTypeOrdering` | Useful for TS 7.0 migration prep | -| Expandable hovers | Editor UX improvement | -| `Map.getOrInsert` / `getOrInsertComputed` | Available via `esnext` lib | -| `RegExp.escape` | Available via `es2025` lib | -| `#/` subpath imports | Available for future module aliasing | - -#### lib.d.ts Changes — ArrayBuffer/Buffer Breaking Change - -TypeScript 5.9 introduced a behavioral change where `ArrayBuffer` is no longer a supertype of several `TypedArray` types. This may cause errors like: - -``` -error TS2345: Argument of type 'ArrayBufferLike' is not assignable to parameter of type 'BufferSource'. -error TS2322: Type 'Buffer' is not assignable to type 'Uint8Array'. +```go +FlagSlackServiceEnabled = "feature.notifications.service.slack.enabled" ``` -**Mitigation:** Ensure `@types/node` is at latest version. This is a 5.9 → 6.0 carryover that must be verified. +Add it below `FlagTelegramServiceEnabled` in the `const` block. -### 3.3 Vite 8 Breaking Changes +### 2.2 `backend/internal/services/notification_service.go` -**Source:** [Vite 8 Beta Announcement](https://vite.dev/blog/announcing-vite8-beta) and [Migration from v7 Guide](https://main.vite.dev/guide/migration) +#### 2.2.1 `isSupportedNotificationProviderType()` -**Version:** `8.0.0-beta.18` (dist-tag: `beta`, announced Dec 3, 2025) +Add `"slack"` to the switch: -#### Core Architecture Change: Rolldown Replaces Rollup + esbuild - -Vite 8's defining change is replacing **two bundlers** (esbuild for dev transforms, Rollup for production builds) with a single Rust-based toolchain: - -| Component | Vite 7 | Vite 8 | Impact on Charon | -|---|---|---|---| -| **Bundler** | Rollup | **Rolldown** (`1.0.0-rc.8`) | `rollupOptions` → `rolldownOptions` | -| **JS Transforms** | esbuild | **Oxc** (`@oxc-project/runtime@0.115.0`) | `esbuild` config key deprecated | -| **JS Minification** | esbuild | **Oxc Minifier** | Different minification assumptions | -| **CSS Minification** | esbuild | **Lightning CSS** (`^1.31.1`) | Slightly different output, bundle size may change | -| **Dep Optimization** | esbuild | **Rolldown** | `optimizeDeps.esbuildOptions` deprecated | - -#### Breaking Changes Impacting Charon - -| # | Breaking Change | Impact on Charon | Action Required | -|---|---|---|---| -| 1 | **Node.js `^20.19.0 \|\| >=22.12.0`** required | None — already on Node 24.14.0 | None | -| 2 | **`build.rollupOptions` deprecated** → `build.rolldownOptions` | **HIGH** — `vite.config.ts` uses `rollupOptions` | Rename config key | -| 3 | **`output.manualChunks` object form removed**, function form deprecated | **HIGH** — config sets `manualChunks: undefined` | Remove or migrate to `codeSplitting` | -| 4 | **`output.inlineDynamicImports`** — supported in Rolldown but **deprecated** in favor of `codeSplitting: false` ([rolldown docs](https://rolldown.rs/reference/OutputOptions.inlineDynamicImports)) | **HIGH** — config uses `inlineDynamicImports: true` as temporary workaround | Migrate to `codeSplitting: false`; `inlineDynamicImports` works as fallback | -| 5 | **Default browser targets updated** (Chrome 107→111, Firefox 104→114, Safari 16.0→16.4) | Low — Charon doesn't set explicit `build.target` | None — new defaults are fine | -| 6 | **esbuild no longer a direct dependency** | Low — Charon doesn't use esbuild config | None | -| 7 | **Oxc Minifier** replaces esbuild minifier | Low — different assumptions about source code | Test build output; verify no minification breakage | -| 8 | **Lightning CSS** for CSS minification | Low — may produce slightly different CSS output | Verify CSS output visually | -| 9 | **Consistent CommonJS interop** — `default` import behavior changes for CJS modules | Medium — could affect CJS dependencies (axios, etc.) | Test all runtime imports | -| 10 | **Module resolution format sniffing removed** — `browser`/`module` field heuristic gone | Low — modern packages use `exports` field | Verify no resolution regressions | -| 11 | **`@vitejs/plugin-react` 5.x does NOT support Vite 8** — requires `6.0.0-beta.0` | **HIGH** — must upgrade plugin-react | Upgrade to `@vitejs/plugin-react@6.0.0-beta.0` | -| 12 | **Plugin-react 6.0 uses `@rolldown/pluginutils`** instead of Rollup utils | Low — internal plugin change | None — handled by plugin upgrade | - -#### New Features Available - -| Feature | Relevance to Charon | -|---|---| -| Built-in tsconfig `paths` support (`resolve.tsconfigPaths: true`) | Could replace manual alias config if needed | -| `emitDecoratorMetadata` support | Not needed — Charon doesn't use decorators | -| Performance: 10–30× faster production builds | Direct benefit — faster Docker builds and CI | -| Full Bundle Mode (upcoming) | Future — 3× faster dev server startup | -| Module-level persistent cache (upcoming) | Future — faster rebuilds | - -#### Dockerfile Impact: Rollup Native Skip Flags - -The current Dockerfile sets: - -```dockerfile -ENV npm_config_rollup_skip_nodejs_native=1 \ - ROLLUP_SKIP_NODEJS_NATIVE=1 +```go +case "discord", "email", "gotify", "webhook", "telegram", "slack": + return true ``` -These env vars are **Rollup-specific** for cross-platform builds. With Vite 8, Rollup is replaced by Rolldown, which uses its own native bindings (`@rolldown/binding-linux-x64-musl` for Alpine). These env vars become no-ops but do not cause harm. Rolldown's native bindings are installed per-platform by npm's `optionalDependencies` mechanism — the same mechanism that works for the `$BUILDPLATFORM` Docker flag. +#### 2.2.2 `isDispatchEnabled()` -**Action:** Remove the Rollup skip flags from Dockerfile and verify cross-platform builds still work. Rolldown includes `@rolldown/binding-linux-x64-musl` which is exactly what Alpine requires. +Add slack case: ---- - -## 4. Compatibility Matrix - -### ESLint v10 Plugin Compatibility Verification Matrix - -Each plugin must be verified before the ESLint v10 upgrade. The agent performing PR-2 must run these checks: - -```bash -# For each plugin, check peer dependency support -npm info eslint-plugin-react-hooks peerDependencies -npm info eslint-plugin-react-compiler peerDependencies -npm info eslint-plugin-jsx-a11y peerDependencies -npm info eslint-plugin-import-x peerDependencies -npm info eslint-plugin-security peerDependencies -npm info eslint-plugin-sonarjs peerDependencies -npm info eslint-plugin-unicorn peerDependencies -npm info eslint-plugin-promise peerDependencies -npm info eslint-plugin-unused-imports peerDependencies -npm info eslint-plugin-no-unsanitized peerDependencies -npm info eslint-plugin-testing-library peerDependencies -npm info eslint-plugin-react-refresh peerDependencies -npm info @vitest/eslint-plugin peerDependencies -npm info typescript-eslint peerDependencies -npm info @eslint/css peerDependencies -npm info @eslint/json peerDependencies -npm info @eslint/markdown peerDependencies +```go +case "slack": + return s.getFeatureFlagValue(notifications.FlagSlackServiceEnabled, true) ``` -**Decision Gate:** If `eslint-plugin-react-hooks` does NOT support ESLint v10 in its `peerDependencies`, the ESLint v10 upgrade is **BLOCKED**. Do not use `--legacy-peer-deps` or `--force` as a workaround. +Default enabled (`true`) to match the Gotify/Telegram/Webhook pattern. -### TypeScript 6.0 Ecosystem Compatibility +#### 2.2.3 `supportsJSONTemplates()` -| Tool | TS 6.0 Compat | Notes | -|---|---|---| -| `typescript-eslint@8.57.0` | Likely — tracks TS closely | Verify with `npm install` | -| `vite@7.3.1` | Yes — Vite uses esbuild/swc, not tsc directly | Type-check is separate | -| `vitest@4.0.18` | Yes — same reasoning | Type-check is separate | -| `@vitejs/plugin-react@5.1.4` | Yes | No TS compiler dependency | -| `react@19.2.4` / `@types/react` | Yes | Ensure `@types/react` latest | -| `@tanstack/react-query@5.90.21` | Likely — popular library | TanStack already preparing for TS 6 | -| `knip@5.86.0` | Verify | Uses TS programmatic API | +`"slack"` is **already listed** in this function (approx line 109). No change needed. -### Node.js Compatibility +#### 2.2.4 Slack Webhook URL Validation -| Tool | Min Node.js | Charon Node.js | Status | -|---|---|---|---| -| ESLint v10 | 20.19 / 22.13 / 24+ | 24.14.0 | Compatible | -| TypeScript 6.0 | TBD (likely same as 5.9) | 24.14.0 | Compatible | -| Vite 7 | 20.19 / 22.12+ | 24.14.0 | Compatible | -| Vite 8 | 20.19 / 22.12+ | 24.14.0 | Compatible | +Add a new function and regex near the existing `discordWebhookRegex`: -### Vite 8 Ecosystem Compatibility Matrix +```go +var slackWebhookRegex = regexp.MustCompile(`^https://hooks\.slack\.com/services/T[A-Za-z0-9_-]+/B[A-Za-z0-9_-]+/[A-Za-z0-9_-]+$`) -All Vite-related packages must be updated together. Stable releases do **not** support Vite 8. - -| Package | Current Version | Vite 8 Compatible? | Required Version | Override Needed? | -|---|---|---|---|---| -| `vite` | `^7.3.1` | — | `8.0.0-beta.18` | No — direct install | -| `@vitejs/plugin-react` | `^5.1.4` | **No** (5.x peer: `vite: ^4.2.0 \|\| ^5.0.0 \|\| ^6.0.0 \|\| ^7.0.0`) | `6.0.0-beta.0` (peer: `vite: ^8.0.0` — verified via `npm info`) | No — direct install | -| `vitest` | `^4.0.18` | **No** (deps: `^6.0.0 \|\| ^7.0.0`) | `4.1.0-beta.6` (deps: `^6.0.0 \|\| ^7.0.0 \|\| ^8.0.0-0`) | No — 4.1.0-beta.6 dep range includes Vite 8 | -| `@vitest/coverage-istanbul` | `^4.0.18` | **No** (peer: `vitest: 4.0.18`) | `4.1.0-beta.6` | No — matches vitest beta | -| `@vitest/coverage-v8` | `^4.0.18` | **No** (peer: `vitest: 4.0.18`) | `4.1.0-beta.6` | No — matches vitest beta | -| `@vitest/ui` | `^4.0.18` | **No** (peer: `vitest: 4.0.18`) | `4.1.0-beta.6` | No — matches vitest beta | -| `@vitest/eslint-plugin` | `^1.6.10` | Yes (peer: `vitest: *`) | Keep current | No | -| `@bgotink/playwright-coverage` | `^0.3.2` | Yes (no Vite peer dep) | Keep current | No | -| `@playwright/test` | `^1.58.2` | Yes (no Vite peer dep) | Keep current | No | - -**Key constraints:** - -- `vitest@4.0.18` has `vite` in its **dependencies** (not peer deps) pinned to `^6.0.0 || ^7.0.0` — this will refuse Vite 8 unless overridden -- `vitest@4.1.0-beta.6` extends this to `^6.0.0 || ^7.0.0 || ^8.0.0-0` — supports Vite 8 beta -- `@vitejs/plugin-react@6.0.0-beta.0` peers on `vite: ^8.0.0` (verified via `npm info`). New optional peer deps: `@rolldown/plugin-babel` and `babel-plugin-react-compiler` (both optional — not required) -- All `@vitest/*` packages at `4.1.0-beta.6` must be installed together (strict peer version matching: `vitest: 4.1.0-beta.6`) -- Since `vitest@4.1.0-beta.6` already includes `^8.0.0-0` in its `vite` dependency range, and all `@vitest/*` packages peer to exact `vitest: 4.1.0-beta.6`, **no npm overrides are needed** when all packages are installed in lockstep at their beta versions - ---- - -## 5. `.npmrc` Configuration - -**No `.npmrc` file currently exists in the project.** No changes needed for these upgrades. - -If plugin compatibility issues arise during ESLint v10 upgrade, **do NOT create an `.npmrc` with `legacy-peer-deps=true`**. Instead, wait for plugin updates or use granular `overrides` in `package.json`: - -```jsonc -// package.json — ONLY if a specific plugin ships a fix before updating peerDeps -{ - "overrides": { - "eslint-plugin-EXAMPLE": { - "eslint": "^10.0.0" +func validateSlackWebhookURL(rawURL string) error { + if !slackWebhookRegex.MatchString(rawURL) { + return fmt.Errorf("invalid Slack webhook URL: must match https://hooks.slack.com/services/T.../B.../xxx") } - } + return nil } ``` ---- +**Validation rules:** +- Must be HTTPS +- Host must be `hooks.slack.com` +- Path must match `/services/T/B/` pattern +- No IP addresses, no query parameters +- Test hook: add `var validateSlackProviderURLFunc = validateSlackWebhookURL` for testability -## 6. Dockerfile Changes +#### 2.2.5 `sendJSONPayload()` — Dispatch path -**No Dockerfile changes required** for ESLint v10 or TypeScript 6.0. +**Step A.** Extend the provider routing condition (approx line 465) to include `"slack"`: -**Vite 8 requires Dockerfile changes** — the Rollup native skip flags become irrelevant: - -```diff - # Set environment to bypass native binary requirement for cross-arch builds -- ENV npm_config_rollup_skip_nodejs_native=1 \ -- ROLLUP_SKIP_NODEJS_NATIVE=1 -+ # Vite 8 uses Rolldown (Rust native bindings, auto-resolved per platform) -+ # No skip flags needed — Rolldown's optionalDependencies handle cross-platform +```go +if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" || providerType == "slack" { ``` -Current Dockerfile state (frontend-builder stage): - -```dockerfile -FROM --platform=$BUILDPLATFORM node:24.14.0-alpine AS frontend-builder -# ... -ENV npm_config_rollup_skip_nodejs_native=1 \ - ROLLUP_SKIP_NODEJS_NATIVE=1 -RUN npm ci -COPY frontend/ ./ -RUN npm run build -``` - -- Node.js 24.14.0 meets Vite 8's requirement (`^20.19.0 || >=22.12.0`) -- `npm ci` will install Rolldown's `@rolldown/binding-linux-x64-musl` automatically on Alpine -- `--platform=$BUILDPLATFORM` ensures native bindings match the build machine architecture -- The `VITE_APP_VERSION` env var and build output (`dist/`) remain unchanged -- No new environment variables or build args needed - -**Future (Vite 8):** If Vite 8 requires a higher Node.js, upgrade the base image at that time. - ---- - -## 7. Config File Changes - -### 7.1 TypeScript 6.0 — `frontend/tsconfig.json` - -```diff - { - "compilerOptions": { - "target": "ES2022", -+ // Consider upgrading to "ES2025" (TS 6.0 new target) - "useDefineForClassFields": true, -- "lib": ["ES2022", "DOM", "DOM.Iterable"], -+ "lib": ["ES2022", "DOM"], -+ // DOM.Iterable is now included in DOM as of TS 6.0 - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, -+ -+ /* TS 6.0 — explicit types to override new default of [] */ -+ "types": [] - }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] - } -``` - -**Key changes:** - -1. **`"types": []`** — Explicitly set to `[]`. Charon uses `noEmit: true` and doesn't rely on global `@types` packages in the main tsconfig. All types come from explicit imports. -2. **`"lib"` simplification** — Remove `"DOM.Iterable"` since TS 6.0 includes it in `"DOM"` automatically. -3. **`"target"` consideration** — Can optionally upgrade from `ES2022` to `ES2025` to access `RegExp.escape` and other ES2025 types natively. Not required. - -### 7.2 TypeScript 6.0 — `frontend/tsconfig.node.json` - -```diff - { - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, -- "strict": true -+ "strict": true, -+ "types": [] - }, - "include": ["vite.config.ts"] - } -``` - -**Note:** `allowSyntheticDefaultImports` is fine — TS 6.0 deprecates setting it to `false`, not `true`. Setting it to `true` remains valid. - -### 7.3 ESLint v10 — `frontend/package.json` Version Caps - -```diff - "devDependencies": { -- "eslint": "^9.39.3 <10.0.0", -+ "eslint": "^10.0.0", -- "@eslint/js": "^9.39.3 <10.0.0", -+ "@eslint/js": "^10.0.0", - // ... all other ESLint plugins may need version bumps - } -``` - -### 7.4 ESLint v10 — `frontend/eslint.config.js` - -Likely no structural changes needed since Charon already uses flat config. Potential changes: - -- Remove any `/* eslint-env */` comments found in source files -- Handle new `eslint:recommended` rules (`no-unassigned-vars`, `no-useless-assignment`, `preserve-caught-error`) -- Verify `tseslint.config()` wrapper compatibility - -### 7.5 ESLint v10 — `lefthook.yml` - -```diff -+ # NOTE: ESLint v10 is supported — plugin compatibility verified on [DATE] -- # NOTE: ESLint pinned at v9.x.x — do not upgrade until react-hooks plugin supports v10. -``` - -### 7.6 TypeScript 6.0 — `package.json` (Root + Frontend) - -```diff - "devDependencies": { -- "typescript": "^5.9.3", -+ "typescript": "^6.0.0", - } -``` - ---- - -## 8. Phase-by-Phase Implementation Plan - -### Phase 1: Pre-Upgrade Verification (Both PRs) - -**Owner:** Frontend_Dev agent (or whoever picks up the PR) - -1. **Snapshot current state:** - - ```bash - cd /projects/Charon && npm run lint 2>&1 | tee /tmp/eslint-v9-baseline.log - cd /projects/Charon/frontend && npx tsc --noEmit 2>&1 | tee /tmp/tsc-v5-baseline.log - ``` - -2. **Verify ESLint plugin compatibility (PR-2 gate):** - - ```bash - for plugin in eslint-plugin-react-hooks eslint-plugin-react-compiler \ - eslint-plugin-jsx-a11y eslint-plugin-import-x eslint-plugin-security \ - eslint-plugin-sonarjs eslint-plugin-unicorn eslint-plugin-promise \ - eslint-plugin-unused-imports eslint-plugin-no-unsanitized \ - eslint-plugin-testing-library eslint-plugin-react-refresh \ - @vitest/eslint-plugin typescript-eslint @eslint/css @eslint/json @eslint/markdown; do - echo "=== $plugin ===" && npm info "$plugin" peerDependencies 2>/dev/null - done - ``` - -3. **Search for `eslint-env` comments:** - - ```bash - grep -r "eslint-env" frontend/src/ --include="*.ts" --include="*.tsx" --include="*.js" - ``` - -### Phase 2: TypeScript 6.0 Upgrade (PR-1) - -**Scope:** TypeScript version bump + tsconfig adjustments - -1. Update `typescript` version in both `package.json` files: - - Root: `^5.9.3` → `^6.0.0` - - Frontend: `^5.9.3` → `^6.0.0` - -2. Apply tsconfig changes (Section 7.1 and 7.2 above): - - Add `"types": []` to `tsconfig.json` and `tsconfig.node.json` - - Remove `"DOM.Iterable"` from `lib` array (now included in `"DOM"`) - -3. Run `npm install` to update lock file - -4. Run type-check and fix any new errors: - - ```bash - cd frontend && npx tsc --noEmit - ``` - -5. Common expected issues: - - Missing types from `@types/*` packages (solved by `"types": []` since we don't use globals) - - `ArrayBuffer`/`Buffer` type narrowing (from TS 5.9 lib.d.ts changes) - - Type argument inference changes (may need explicit type annotations) - -6. Run full test suite: - - ```bash - cd frontend && npx vitest run - ``` - -7. Run Playwright E2E tests to verify build works: - - ```bash - # The Dockerfile builds with npm ci && npm run build - # Verify: cd frontend && npx vite build - ``` - -### Phase 3: ESLint v10 Upgrade (PR-2) - -**Prerequisite:** Phase 1 plugin verification passes. `eslint-plugin-react-hooks` must declare ESLint v10 support. - -1. Remove version cap and update ESLint packages: - - ```bash - cd frontend - npm install -D eslint@^10.0.0 @eslint/js@^10.0.0 - ``` - -2. Update any plugins that need version bumps for ESLint v10 compat - -3. Run ESLint and compare against baseline: - - ```bash - cd /projects/Charon && npm run lint 2>&1 | tee /tmp/eslint-v10-output.log - diff /tmp/eslint-v9-baseline.log /tmp/eslint-v10-output.log - ``` - -4. Address new violations from updated `eslint:recommended`: - - `no-unassigned-vars` — variables declared but never assigned - - `no-useless-assignment` — assignments that are immediately overwritten - - `preserve-caught-error` — catch clause variables that are declared but unused - -5. Remove any `/* eslint-env */` comments found in Phase 1 - -6. Update `lefthook.yml` — remove the ESLint v9 pin note - -7. Run full test suite to confirm no regressions - -### Phase 4: Integration Testing - -1. **Full lint + type-check:** - - ```bash - cd /projects/Charon && npm run lint && cd frontend && npx tsc --noEmit - ``` - -2. **Frontend build:** - - ```bash - cd frontend && npx vite build - ``` - -3. **Unit tests:** - - ```bash - cd frontend && npx vitest run - ``` - -4. **Playwright E2E tests (all browsers):** - - ```bash - npx playwright test --project=chromium - npx playwright test --project=firefox - npx playwright test --project=webkit - ``` - -5. **Docker build verification:** - - ```bash - docker build -t charon:upgrade-test . - ``` - -### Phase 5: Vite 8 Upgrade (PR-3 — stacked commit on same branch) - -**Prerequisites:** PR-1 (TypeScript 6.0) and PR-2 (ESLint v10) already committed on branch. - -**Scope:** Vite `^7.3.1` → `8.0.0-beta.18`, plugin-react `^5.1.4` → `6.0.0-beta.0`, vitest `^4.0.18` → `4.1.0-beta.6`, vite.config.ts migration, Dockerfile cleanup. - -#### Step 1: Install Vite 8 and ecosystem packages - -```bash -cd /projects/Charon/frontend - -# Core Vite upgrade -npm install -D vite@8.0.0-beta.18 - -# Plugin-react upgrade (6.x required for Vite 8) -npm install -D @vitejs/plugin-react@6.0.0-beta.0 - -# Vitest + coverage upgrades (4.1.0-beta.6 supports Vite 8) -npm install -D vitest@4.1.0-beta.6 \ - @vitest/coverage-istanbul@4.1.0-beta.6 \ - @vitest/coverage-v8@4.1.0-beta.6 \ - @vitest/ui@4.1.0-beta.6 -``` - -#### Step 2: Update root `package.json` (direct version bump only — no overrides) - -The root `package.json` only has `vite` as a direct devDependency (used by Playwright). It does **not** need overrides — just a version bump: - -```bash -cd /projects/Charon -npm install -D vite@8.0.0-beta.18 -``` - -#### Step 3: Verify peer dep resolution (overrides likely NOT needed) - -With all packages at their Vite 8-compatible versions, overrides should not be necessary: - -- `vitest@4.1.0-beta.6` depends on `vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0` — already includes Vite 8 -- `@vitejs/plugin-react@6.0.0-beta.0` peers on `vite: ^8.0.0` — matches -- All `@vitest/*@4.1.0-beta.6` peer on `vitest: 4.1.0-beta.6` — matches when installed in lockstep - -Run `npm install` and check for peer dep warnings. **Only add overrides in `frontend/package.json`** (following the established pattern from TS 6.0 and ESLint v10 phases) if specific transitive packages fail to resolve: - -```jsonc -// frontend/package.json — ONLY if npm install reports unresolved peer deps -{ - "overrides": { - // ... existing TS and ESLint overrides ... - // Add scoped overrides ONLY for the specific package that fails, e.g.: - // "some-transitive-package": { "vite": "8.0.0-beta.18" } - } +**Step B.** Add Slack-specific dispatch logic inside the block, after the Telegram block: + +```go +if providerType == "slack" { + decryptedWebhookURL := p.Token + if strings.TrimSpace(decryptedWebhookURL) == "" { + return fmt.Errorf("slack webhook URL is not configured") + } + if err := validateSlackProviderURLFunc(decryptedWebhookURL); err != nil { + return err + } + dispatchURL = decryptedWebhookURL } ``` -**Do NOT add a top-level `"vite": "8.0.0-beta.18"` override** — this forces every transitive Vite consumer to resolve to the beta, which is overly broad. If a broad override is truly needed after testing, add it with a comment explaining which transitive package requires it. +**Step C.** Replace the existing `case "slack":` block entirely with: -#### Step 4: Migrate `vite.config.ts` - -```diff - import react from '@vitejs/plugin-react' - import { defineConfig } from 'vite' - - export default defineConfig({ - plugins: [react()], - server: { - port: 5173, - proxy: { - '/api': { - target: 'http://localhost:8080', - changeOrigin: true +```go +case "slack": + if _, hasText := jsonPayload["text"]; !hasText { + if _, hasBlocks := jsonPayload["blocks"]; !hasBlocks { + if messageValue, hasMessage := jsonPayload["message"]; hasMessage { + jsonPayload["text"] = messageValue + normalizedBody, marshalErr := json.Marshal(jsonPayload) + if marshalErr != nil { + return fmt.Errorf("failed to normalize slack payload: %w", marshalErr) + } + body.Reset() + if _, writeErr := body.Write(normalizedBody); writeErr != nil { + return fmt.Errorf("failed to write normalized slack payload: %w", writeErr) + } + } else { + return fmt.Errorf("slack payload requires 'text' or 'blocks' field") + } } - } - }, - build: { - outDir: 'dist', - sourcemap: true, -- // TEMPORARY: Disable code splitting to diagnose React initialization issue -- // If this works, the problem is module loading order in async chunks - chunkSizeWarningLimit: 2000, -- rollupOptions: { -- output: { -- // Disable code splitting - bundle everything into one file -- manualChunks: undefined, -- inlineDynamicImports: true -- } -- } -+ rolldownOptions: { -+ output: { -+ // Disable code splitting — single bundle for React init stability -+ // codeSplitting: false is the Rolldown-native approach -+ // (inlineDynamicImports is deprecated in Rolldown) -+ codeSplitting: false -+ } -+ } } - }) ``` -**Key changes:** -1. `rollupOptions` → `rolldownOptions` (Rollup config key deprecated) -2. `manualChunks: undefined` removed (object form no longer supported; was already a no-op since `undefined`) -3. `inlineDynamicImports: true` replaced with `codeSplitting: false` — the Rolldown-native equivalent. Rolldown supports `inlineDynamicImports` but marks it as [deprecated](https://rolldown.rs/reference/OutputOptions.inlineDynamicImports) in favor of `codeSplitting: false`. -4. The TEMPORARY comment is preserved in intent — this workaround may still be needed +#### 2.2.6 `CreateProvider()` — Token field handling -**Fallback if `codeSplitting: false` behaves differently than expected:** +Update the token-clearing logic: -```ts -build: { - rolldownOptions: { - output: { - // Deprecated but still functional in Rolldown 1.0.0-rc.8 - inlineDynamicImports: true - } - } +```go +if provider.Type != "gotify" && provider.Type != "telegram" && provider.Type != "slack" { + provider.Token = "" } ``` -#### Step 5: Update Dockerfile +#### 2.2.7 `UpdateProvider()` — Token preservation -Remove the now-irrelevant Rollup native skip flags: +Update the token-preservation logic: -```diff -- ENV npm_config_rollup_skip_nodejs_native=1 \ -- ROLLUP_SKIP_NODEJS_NATIVE=1 -+ # Vite 8: Rolldown native bindings auto-resolved per platform via optionalDependencies +```go +if provider.Type == "gotify" || provider.Type == "telegram" || provider.Type == "slack" { + if strings.TrimSpace(provider.Token) == "" { + provider.Token = existing.Token + } +} else { + provider.Token = "" +} ``` -#### Step 6: Run `npm install` to regenerate lock file +### 2.3 `backend/internal/api/handlers/notification_provider_handler.go` -```bash -cd /projects/Charon && npm install -cd /projects/Charon/frontend && npm install +#### 2.3.1 `Create()` — Type whitelist + +Add `"slack"` to the type validation: + +```go +if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && + providerType != "email" && providerType != "telegram" && providerType != "slack" { ``` -#### Step 7: Verify builds and tests +#### 2.3.2 `Update()` — Type whitelist -```bash -# 1. Frontend build (most critical — tests Rolldown bundling) -cd /projects/Charon/frontend && npx vite build +Same addition: -# 2. Type-check (should be unaffected) -cd /projects/Charon/frontend && npx tsc --noEmit - -# 3. Lint (should be unaffected) -cd /projects/Charon && npm run lint - -# 4. Unit tests -cd /projects/Charon/frontend && npx vitest run - -# 5. Docker build (tests Rolldown on Alpine/musl) -docker build -t charon:vite8-test . - -# 6. Playwright E2E (tests the built app end-to-end) -cd /projects/Charon && npx playwright test --project=firefox - -# 7. CJS interop smoke test (verify axios, react-hot-toast, react-hook-form) -# Run the app and manually verify pages that use CJS dependencies render correctly -# See Step 9 for detailed CJS interop verification checklist +```go +if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && + providerType != "email" && providerType != "telegram" && providerType != "slack" { ``` -#### Step 8: Verify build output +#### 2.3.3 `Update()` — Token preservation on empty update -```bash -# Compare build output size and structure -ls -la frontend/dist/assets/ -# Should still produce index-*.js, index-*.css -# With codeSplitting: false, should be a single JS bundle +Add `"slack"` to the token-keep condition: + +```go +if (providerType == "gotify" || providerType == "telegram" || providerType == "slack") && + strings.TrimSpace(req.Token) == "" { + req.Token = existing.Token +} ``` -#### Step 9: Verify CJS interop (Vite 8 behavior change) +#### 2.3.4 `Test()` — Token write-only guard -Vite 8's consistent CJS interop may affect imports from CJS packages like `axios` and `react-hot-toast`. **Explicitly verify these packages work at runtime:** +Add a Slack guard alongside the existing Gotify check: -```bash -# After Docker build or vite build + preview: -# 1. Verify axios API calls work (CJS package with __esModule flag) -# - Navigate to any page that makes API calls (e.g., Dashboard) -# - Check browser console for "default is not a function" errors -# 2. Verify react-hot-toast renders (CJS package) -# - Trigger a toast notification (e.g., save settings) -# - Check browser console for import errors -# 3. Verify react-hook-form works (CJS interop) -# - Open any form page, submit a form +```go +if providerType == "slack" && strings.TrimSpace(req.Token) != "" { + respondSanitizedProviderError(c, http.StatusBadRequest, "TOKEN_WRITE_ONLY", "validation", + "Slack webhook URL is accepted only on provider create/update") + return +} ``` -If any runtime errors appear (e.g., `default is not a function`), use the temporary escape hatch: +#### 2.3.5 `classifyProviderTestFailure()` — Slack-specific errors -```ts -// vite.config.ts — ONLY if CJS interop breaks -export default defineConfig({ - legacy: { - inconsistentCjsInterop: true - } -}) +Slack returns plain-text error strings (e.g., `"invalid_payload"`), not JSON. Add classification after the existing status code matching block: + +```go +if strings.Contains(errText, "invalid_payload") || + strings.Contains(errText, "missing_text_or_fallback") { + return "PROVIDER_TEST_VALIDATION_FAILED", "validation", + "Slack rejected the payload. Ensure your template includes a 'text' or 'blocks' field" +} +if strings.Contains(errText, "no_service") { + return "PROVIDER_TEST_AUTH_REJECTED", "dispatch", + "Slack webhook is revoked or the app is disabled. Create a new webhook" +} ``` -#### Step 10: Update `ARCHITECTURE.md` +#### 2.3.6 `Test()` — URL empty guard for Slack -Update the Frontend technology stack table and directory structure to reflect current versions: +The `Test()` handler rejects providers where `URL` is empty. For Slack, `URL` holds the optional channel display name — the dispatch target is the webhook URL stored in `Token`. Add Slack exemption: -```diff - ### Frontend - | Component | Technology | Version | Purpose | -- | **Build Tool** | Vite | 6.1.9 | Fast bundler and dev server | -+ | **Build Tool** | Vite | 8.0.0-beta.18 | Fast bundler and dev server | -- | **CSS Framework** | Tailwind CSS | 3.x | Utility-first CSS | -+ | **CSS Framework** | Tailwind CSS | 4.2.1 | Utility-first CSS | -- | **Unit Testing** | Vitest | 2.x | Fast unit test runner | -+ | **Unit Testing** | Vitest | 4.1.0-beta.6 | Fast unit test runner | -- | **E2E Testing** | Playwright | 1.50.x | Browser automation | -+ | **E2E Testing** | Playwright | 1.58.2 | Browser automation | +```go +if providerType != "slack" && strings.TrimSpace(provider.URL) == "" { + respondSanitizedProviderError(c, http.StatusBadRequest, "PROVIDER_CONFIG_MISSING", ...) + return +} ``` -Also fix the directory structure reference: +#### 2.3.7 `isProviderValidationError()` — Slack validation errors -```diff -- │ └── vite.config.js # Vite configuration -+ │ └── vite.config.ts # Vite configuration +The function checks for specific error message strings to return 400 instead of 500. Add Slack: + +```go +strings.Contains(errMsg, "invalid Slack webhook URL") ``` ---- +Without this, malformed Slack webhook URLs return HTTP 500 instead of 400. -## 9. Rollback Strategy +### 2.4 `backend/internal/services/notification_service_test.go` -### TypeScript 6.0 Rollback (PR-1) +Add the following test functions: -1. Revert `package.json` changes (both root and frontend): - - ```diff - - "typescript": "^6.0.0" - + "typescript": "^5.9.3" - ``` - -2. Revert `tsconfig.json` changes (remove `"types": []`, restore `"DOM.Iterable"`) -3. Run `npm install` to restore lock file -4. Verify: `cd frontend && npx tsc --noEmit && npx vitest run` - -**Risk:** Low — TypeScript version is a devDependency only. No runtime impact. `git revert` of the PR commit is sufficient. - -### ESLint v10 Rollback (PR-2) - -1. Revert `package.json` changes: - - ```diff - - "eslint": "^10.0.0" - + "eslint": "^9.39.3 <10.0.0" - - "@eslint/js": "^10.0.0" - + "@eslint/js": "^9.39.3 <10.0.0" - ``` - -2. Revert any plugin version bumps -3. Revert `lefthook.yml` comment change -4. Run `npm install` to restore lock file -5. Verify: `cd /projects/Charon && npm run lint` - -**Risk:** Low — ESLint is a devDependency only. Code changes (fixing new rule violations) are harmless to keep even if ESLint is rolled back. - -### Vite 8 Rollback (PR-3 commit) - -1. Revert `vite` version in both `package.json` files: - - ```diff - - "vite": "8.0.0-beta.18" - + "vite": "^7.3.1" - ``` - -2. Revert ecosystem packages in `frontend/package.json`: - - ```diff - - "@vitejs/plugin-react": "6.0.0-beta.0" - + "@vitejs/plugin-react": "^5.1.4" - - "vitest": "4.1.0-beta.6" - + "vitest": "^4.0.18" - - "@vitest/coverage-istanbul": "4.1.0-beta.6" - + "@vitest/coverage-istanbul": "^4.0.18" - - "@vitest/coverage-v8": "4.1.0-beta.6" - + "@vitest/coverage-v8": "^4.0.18" - - "@vitest/ui": "4.1.0-beta.6" - + "@vitest/ui": "^4.0.18" - ``` - -3. Revert `vite.config.ts`: `rolldownOptions` → `rollupOptions`, restore `manualChunks: undefined` - -4. Revert Dockerfile: restore `ROLLUP_SKIP_NODEJS_NATIVE=1` env vars - -5. Remove Vite 8 overrides from `frontend/package.json` - -6. Run `npm install` to restore lock file - -7. Verify: `cd frontend && npx vite build && npx vitest run` - -**Risk:** Medium — Vite 8 is a pre-release beta. More likely to need rollback than stable upgrades. Since this is a stacked commit on the same branch, `git revert HEAD` cleanly removes only the Vite 8 changes while preserving TS 6.0 and ESLint v10. - ---- - -## 10. Testing Strategy - -### Automated Test Coverage - -| Test Layer | Tool | What It Validates | -|---|---|---| -| Type checking | `tsc --noEmit` | TS 6.0 compatibility, tsconfig changes | -| Linting | `eslint` | ESLint v10 config + plugin compat | -| Unit tests | `vitest run` | No runtime regressions from TS changes | -| E2E tests | Playwright (Chromium, Firefox, WebKit) | Full app build + functionality | -| Docker build | `docker build` | Dockerfile still works with new deps | -| Pre-commit hooks | `lefthook` | All hooks pass with new versions | - -### Specific Test Scenarios for TS 6.0 - -1. **Build output verification:** - - ```bash - cd frontend && npx vite build - # Verify dist/ output is correct, no new warnings - ``` - -2. **Type-check with `--stableTypeOrdering`** (prep for TS 7.0): - - ```bash - cd frontend && npx tsc --noEmit --stableTypeOrdering - # Note any differences — these will be real in TS 7.0 - ``` - -3. **Verify no `@types` resolution issues:** - - ```bash - # With types: [], ensure no global type errors appear - cd frontend && npx tsc --noEmit 2>&1 | grep "Cannot find" - ``` - -### Specific Test Scenarios for ESLint v10 - -1. **Verify all 18 plugins load without errors:** - - ```bash - cd /projects/Charon && npx eslint --print-config frontend/src/App.tsx | head -20 - ``` - -2. **Count new violations vs baseline:** - - ```bash - npx eslint frontend/src/ --format json 2>/dev/null | jq '.[] | .errorCount' | paste -sd+ | bc - ``` - -3. **Verify config lookup works correctly in monorepo:** - - ```bash - # Lint a file from the root — should find root eslint.config.js - npx eslint frontend/src/App.tsx - ``` - ---- - -## 11. Commit Slicing Strategy - -### Decision: 3 Stacked Commits on Single Branch - -**Trigger reasons:** - -- Cross-domain changes (TS and ESLint are independent tools) -- Risk isolation (if one breaks, the other can still merge) -- Review size (each PR is focused and reviewable) -- Plugin compatibility gate (ESLint v10 may be blocked) - -### PR-1: TypeScript 6.0 Upgrade - -| Attribute | Detail | +| Test Function | Purpose | |---|---| -| **Scope** | TypeScript ^5.9.3 → ^6.0.0, tsconfig changes, fix type errors | -| **Files** | `package.json` (root), `frontend/package.json`, `package-lock.json`, `frontend/tsconfig.json`, `frontend/tsconfig.node.json`, possibly source files with type fixes | -| **Dependencies** | None — can start immediately | -| **Validation Gate** | `tsc --noEmit` passes, `vitest run` passes, `vite build` succeeds, Docker build succeeds | -| **Estimated Complexity** | Medium — mostly defaults are already correct, `types: []` is the main change | -| **Rollback** | `git revert` + `npm install` | +| `TestSlackWebhookURLValidation` | Table-driven: valid/invalid URL patterns for `validateSlackWebhookURL` | +| `TestSlackWebhookURLValidation_RejectsHTTP` | Rejects `http://hooks.slack.com/...` | +| `TestSlackWebhookURLValidation_RejectsIPAddress` | Rejects `https://192.168.1.1/services/...` | +| `TestSlackWebhookURLValidation_RejectsWrongHost` | Rejects `https://evil.com/services/...` | +| `TestSlackWebhookURLValidation_RejectsQueryParams` | Rejects URLs with `?token=...` | +| `TestNotificationService_CreateProvider_Slack` | Creates Slack provider, verifies token stored, URL is channel name | +| `TestNotificationService_CreateProvider_Slack_ClearsTokenField` | Verifies non-Slack types don't keep token | +| `TestNotificationService_UpdateProvider_Slack_PreservesToken` | Updates name without clearing webhook URL | +| `TestNotificationService_TestProvider_Slack` | Tests dispatch through mock HTTP server | +| `TestNotificationService_SendExternal_Slack` | Event filtering + dispatch via goroutine; mock webhook server | +| `TestNotificationService_Slack_PayloadNormalizesMessageToText` | Minimal template `message` → `text` normalization | +| `TestNotificationService_Slack_PayloadRequiresTextOrBlocks` | Custom template without `text`/`blocks`/`message` fails | +| `TestFlagSlackServiceEnabled_ConstantValue` | `notifications.FlagSlackServiceEnabled == "feature.notifications.service.slack.enabled"` | +| `TestNotificationService_Slack_IsDispatchEnabled` | Feature flag true/false gating | +| `TestNotificationService_Slack_TokenNotExposedInList` | `ListProviders` redaction: HasToken=true, Token="" | -### PR-2: ESLint v10 Upgrade +### 2.5 No Changes Required -| Attribute | Detail | +| File | Reason | +|------|--------| +| `backend/internal/models/notification_provider.go` | Existing `Token`, `URL`, `HasToken` fields sufficient | +| `backend/internal/notifications/http_wrapper.go` | Slack webhooks are standard HTTPS POST | +| `backend/internal/api/routes/routes.go` | No new model to auto-migrate | +| `Dockerfile` | No new dependencies | +| `.gitignore` | No new artifacts | +| `codecov.yml` | No new paths to exclude | +| `.dockerignore` | No new paths | + +--- + +## 3. Frontend Changes (React/TypeScript) + +### 3.1 `frontend/src/api/notifications.ts` + +#### 3.1.1 `SUPPORTED_NOTIFICATION_PROVIDER_TYPES` + +Add `'slack'`: + +```typescript +export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = [ + 'discord', 'gotify', 'webhook', 'email', 'telegram', 'slack' +] as const; +``` + +#### 3.1.2 `sanitizeProviderForWriteAction()` + +Add `'slack'` to the token-preserving types: + +```typescript +if (type !== 'gotify' && type !== 'telegram' && type !== 'slack') { + delete payload.token; + return payload; +} +``` + +### 3.2 `frontend/src/pages/Notifications.tsx` + +#### 3.2.1 `normalizeProviderPayloadForSubmit()` + +Add `'slack'` to the token-preserving types: + +```typescript +if (type === 'gotify' || type === 'telegram' || type === 'slack') { +``` + +#### 3.2.2 Provider type `
{isEmail && (

@@ -220,11 +228,11 @@ const ProviderForm: FC<{ )} - {(isGotify || isTelegram) && ( + {(isGotify || isTelegram || isSlack) && (

diff --git a/frontend/src/pages/__tests__/Notifications.test.tsx b/frontend/src/pages/__tests__/Notifications.test.tsx index 231430c3..327039b6 100644 --- a/frontend/src/pages/__tests__/Notifications.test.tsx +++ b/frontend/src/pages/__tests__/Notifications.test.tsx @@ -16,7 +16,7 @@ vi.mock('react-i18next', () => ({ })) vi.mock('../../api/notifications', () => ({ - SUPPORTED_NOTIFICATION_PROVIDER_TYPES: ['discord', 'gotify', 'webhook', 'email', 'telegram'], + SUPPORTED_NOTIFICATION_PROVIDER_TYPES: ['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack'], getProviders: vi.fn(), createProvider: vi.fn(), updateProvider: vi.fn(), @@ -148,8 +148,8 @@ describe('Notifications', () => { const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement const options = Array.from(typeSelect.options) - expect(options).toHaveLength(5) - expect(options.map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook', 'email', 'telegram']) + expect(options).toHaveLength(6) + expect(options.map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack']) expect(typeSelect.disabled).toBe(false) }) @@ -428,8 +428,8 @@ describe('Notifications', () => { const legacyProvider: NotificationProvider = { ...baseProvider, id: 'legacy-provider', - name: 'Legacy Slack', - type: 'slack', + name: 'Legacy Pushover', + type: 'pushover', enabled: false, } @@ -611,4 +611,62 @@ describe('Notifications', () => { expect(screen.getByTestId('provider-config')).toBeInTheDocument() }) + + it('shows token field when slack type selected', async () => { + const user = userEvent.setup() + renderWithQueryClient() + + await user.click(await screen.findByTestId('add-provider-btn')) + await user.selectOptions(screen.getByTestId('provider-type'), 'slack') + + expect(screen.getByTestId('provider-gotify-token')).toBeInTheDocument() + }) + + it('hides token field when switching from slack to discord', async () => { + const user = userEvent.setup() + renderWithQueryClient() + + await user.click(await screen.findByTestId('add-provider-btn')) + await user.selectOptions(screen.getByTestId('provider-type'), 'slack') + expect(screen.getByTestId('provider-gotify-token')).toBeInTheDocument() + + await user.selectOptions(screen.getByTestId('provider-type'), 'discord') + expect(screen.queryByTestId('provider-gotify-token')).toBeNull() + }) + + it('submits slack provider with token as webhook URL', async () => { + const user = userEvent.setup() + renderWithQueryClient() + + await user.click(await screen.findByTestId('add-provider-btn')) + await user.selectOptions(screen.getByTestId('provider-type'), 'slack') + await user.type(screen.getByTestId('provider-name'), 'Slack Alerts') + await user.type(screen.getByTestId('provider-gotify-token'), 'https://hooks.slack.com/services/T00/B00/xxx') + await user.click(screen.getByTestId('provider-save-btn')) + + await waitFor(() => { + expect(notificationsApi.createProvider).toHaveBeenCalled() + }) + + const payload = vi.mocked(notificationsApi.createProvider).mock.calls[0][0] + expect(payload.type).toBe('slack') + expect(payload.token).toBe('https://hooks.slack.com/services/T00/B00/xxx') + }) + + it('does not require URL for slack', async () => { + const user = userEvent.setup() + renderWithQueryClient() + + await user.click(await screen.findByTestId('add-provider-btn')) + await user.selectOptions(screen.getByTestId('provider-type'), 'slack') + await user.type(screen.getByTestId('provider-name'), 'Slack No URL') + await user.type(screen.getByTestId('provider-gotify-token'), 'https://hooks.slack.com/services/T00/B00/xxx') + await user.click(screen.getByTestId('provider-save-btn')) + + await waitFor(() => { + expect(notificationsApi.createProvider).toHaveBeenCalled() + }) + + expect(screen.queryByTestId('provider-url-error')).toBeNull() + }) }) diff --git a/tests/settings/notifications-payload.spec.ts b/tests/settings/notifications-payload.spec.ts index 3f254789..d5f6c32a 100644 --- a/tests/settings/notifications-payload.spec.ts +++ b/tests/settings/notifications-payload.spec.ts @@ -107,6 +107,11 @@ test.describe('Notifications Payload Matrix', () => { name: `telegram-matrix-${Date.now()}`, url: '987654321', }, + { + type: 'slack', + name: `slack-matrix-${Date.now()}`, + url: '#slack-alerts', + }, ] as const; for (const scenario of scenarios) { @@ -125,12 +130,16 @@ test.describe('Notifications Payload Matrix', () => { await page.getByTestId('provider-gotify-token').fill('bot123456789:ABCdefGHI'); } + if (scenario.type === 'slack') { + await page.getByTestId('provider-gotify-token').fill('https://hooks.slack.com/services/T00000000/B00000000/xxxxxxxxxxxxxxxxxxxx'); + } + await page.getByTestId('provider-save-btn').click(); }); } await test.step('Verify payload contract per provider type', async () => { - expect(capturedCreatePayloads).toHaveLength(4); + expect(capturedCreatePayloads).toHaveLength(5); const discordPayload = capturedCreatePayloads.find((payload) => payload.type === 'discord'); expect(discordPayload).toBeTruthy(); @@ -152,6 +161,12 @@ test.describe('Notifications Payload Matrix', () => { expect(telegramPayload?.token).toBe('bot123456789:ABCdefGHI'); expect(telegramPayload?.gotify_token).toBeUndefined(); expect(telegramPayload?.url).toBe('987654321'); + + const slackPayload = capturedCreatePayloads.find((payload) => payload.type === 'slack'); + expect(slackPayload).toBeTruthy(); + expect(slackPayload?.token).toBe('https://hooks.slack.com/services/T00000000/B00000000/xxxxxxxxxxxxxxxxxxxx'); + expect(slackPayload?.gotify_token).toBeUndefined(); + expect(slackPayload?.url).toBe('#slack-alerts'); }); }); @@ -324,7 +339,15 @@ test.describe('Notifications Payload Matrix', () => { await page.getByTestId('provider-name').fill(gotifyName); await page.getByTestId('provider-url').fill('https://gotify.example.com/message'); await page.getByTestId('provider-gotify-token').fill('super-secret-token'); + + const previewResponsePromise = page.waitForResponse( + (response) => + /\/api\/v1\/notifications\/providers\/preview$/.test(response.url()) + && response.request().method() === 'POST' + ); await page.getByTestId('provider-preview-btn').click(); + const previewResponse = await previewResponsePromise; + capturedPreviewPayload = (await previewResponse.request().postDataJSON()) as Record; }); await test.step('Save provider', async () => { @@ -334,8 +357,16 @@ test.describe('Notifications Payload Matrix', () => { await test.step('Send test from saved provider row', async () => { const providerRow = page.getByTestId('provider-row-gotify-transform-id'); await expect(providerRow).toBeVisible({ timeout: 5000 }); + + const testResponsePromise = page.waitForResponse( + (response) => + /\/api\/v1\/notifications\/providers\/test$/.test(response.url()) + && response.request().method() === 'POST' + ); const sendTestButton = providerRow.getByRole('button', { name: /send test/i }); await sendTestButton.click(); + const testResponse = await testResponsePromise; + capturedTestPayload = (await testResponse.request().postDataJSON()) as Record; }); await test.step('Assert token is not sent on preview/test payloads', async () => { diff --git a/tests/settings/notifications.spec.ts b/tests/settings/notifications.spec.ts index 1224827c..959bb28b 100644 --- a/tests/settings/notifications.spec.ts +++ b/tests/settings/notifications.spec.ts @@ -141,7 +141,7 @@ test.describe('Notification Providers', () => { contentType: 'application/json', body: JSON.stringify([ { id: '1', name: 'Discord Alert', type: 'discord', url: 'https://discord.com/api/webhooks/test', enabled: true }, - { id: '2', name: 'Slack Notify', type: 'slack', url: 'https://hooks.example.com/services/test', enabled: true }, + { id: '2', name: 'Pushover Notify', type: 'pushover', url: 'https://hooks.example.com/services/test', enabled: true }, { id: '3', name: 'Generic Hook', type: 'generic', url: 'https://webhook.test.local', enabled: false }, ]), }); @@ -188,7 +188,7 @@ test.describe('Notification Providers', () => { body: JSON.stringify([ { id: '1', name: 'Discord One', type: 'discord', url: 'https://discord.com/api/webhooks/1', enabled: true }, { id: '2', name: 'Discord Two', type: 'discord', url: 'https://discord.com/api/webhooks/2', enabled: true }, - { id: '3', name: 'Slack Notify', type: 'slack', url: 'https://hooks.example.com/test', enabled: true }, + { id: '3', name: 'Pushover Notify', type: 'pushover', url: 'https://hooks.example.com/test', enabled: true }, ]), }); } else { @@ -206,7 +206,7 @@ test.describe('Notification Providers', () => { // Check that providers are visible - look for provider names await expect(page.getByText('Discord One')).toBeVisible(); await expect(page.getByText('Discord Two')).toBeVisible(); - await expect(page.getByText('Slack Notify')).toBeVisible(); + await expect(page.getByText('Pushover Notify')).toBeVisible(); }); await test.step('Verify legacy provider row renders explicit deprecated messaging', async () => { @@ -294,8 +294,8 @@ test.describe('Notification Providers', () => { await test.step('Verify provider type select contains supported options', async () => { const providerTypeSelect = page.getByTestId('provider-type'); - await expect(providerTypeSelect.locator('option')).toHaveCount(5); - await expect(providerTypeSelect.locator('option')).toHaveText(['Discord', 'Gotify', 'Generic Webhook', 'Email', 'Telegram']); + await expect(providerTypeSelect.locator('option')).toHaveCount(6); + await expect(providerTypeSelect.locator('option')).toHaveText(['Discord', 'Gotify', 'Generic Webhook', 'Email', 'Telegram', 'Slack']); await expect(providerTypeSelect).toBeEnabled(); }); }); diff --git a/tests/settings/slack-notification-provider.spec.ts b/tests/settings/slack-notification-provider.spec.ts new file mode 100644 index 00000000..b8d71223 --- /dev/null +++ b/tests/settings/slack-notification-provider.spec.ts @@ -0,0 +1,516 @@ +/** + * Slack Notification Provider E2E Tests + * + * Tests the Slack notification provider type. + * Covers form rendering, CRUD operations, payload contracts, + * webhook URL security, and validation behavior specific to the Slack provider type. + */ + +import { test, expect, loginUser } from '../fixtures/auth-fixtures'; +import { waitForLoadingComplete } from '../utils/wait-helpers'; + +function generateProviderName(prefix: string = 'slack-test'): string { + return `${prefix}-${Date.now()}`; +} + +test.describe('Slack Notification Provider', () => { + test.beforeEach(async ({ page, adminUser }) => { + await loginUser(page, adminUser); + await waitForLoadingComplete(page); + await page.goto('/settings/notifications'); + await waitForLoadingComplete(page); + }); + + test.describe('Form Rendering', () => { + test('should show webhook URL field and channel name when slack type selected', async ({ page }) => { + await test.step('Open Add Provider form', async () => { + await page.getByRole('button', { name: /add.*provider/i }).click(); + await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 }); + }); + + await test.step('Select slack provider type', async () => { + await page.getByTestId('provider-type').selectOption('slack'); + }); + + await test.step('Verify webhook URL (token) field is visible', async () => { + await expect(page.getByTestId('provider-gotify-token')).toBeVisible(); + }); + + await test.step('Verify webhook URL field label shows Webhook URL', async () => { + const tokenLabel = page.getByText(/webhook url/i); + await expect(tokenLabel.first()).toBeVisible(); + }); + + await test.step('Verify channel name placeholder', async () => { + const urlInput = page.getByTestId('provider-url'); + await expect(urlInput).toHaveAttribute('placeholder', '#general'); + }); + + await test.step('Verify Channel Name label replaces URL label', async () => { + const channelLabel = page.getByText(/channel name/i); + await expect(channelLabel.first()).toBeVisible(); + }); + + await test.step('Verify JSON template section is shown for slack', async () => { + await expect(page.getByTestId('provider-config')).toBeVisible(); + }); + + await test.step('Verify save button is accessible', async () => { + const saveButton = page.getByTestId('provider-save-btn'); + await expect(saveButton).toBeVisible(); + await expect(saveButton).toBeEnabled(); + }); + }); + + test('should toggle form fields when switching between slack and discord types', async ({ page }) => { + await test.step('Open Add Provider form', async () => { + await page.getByRole('button', { name: /add.*provider/i }).click(); + await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 }); + }); + + await test.step('Verify discord is default without token field', async () => { + await expect(page.getByTestId('provider-type')).toHaveValue('discord'); + await expect(page.getByTestId('provider-gotify-token')).toHaveCount(0); + }); + + await test.step('Switch to slack and verify token field appears', async () => { + await page.getByTestId('provider-type').selectOption('slack'); + await expect(page.getByTestId('provider-gotify-token')).toBeVisible(); + }); + + await test.step('Switch back to discord and verify token field hidden', async () => { + await page.getByTestId('provider-type').selectOption('discord'); + await expect(page.getByTestId('provider-gotify-token')).toHaveCount(0); + }); + }); + + test('should show JSON template section for slack', async ({ page }) => { + await test.step('Open Add Provider form and select slack', async () => { + await page.getByRole('button', { name: /add.*provider/i }).click(); + await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 }); + await page.getByTestId('provider-type').selectOption('slack'); + }); + + await test.step('Verify JSON template config section is visible', async () => { + await expect(page.getByTestId('provider-config')).toBeVisible(); + }); + }); + }); + + test.describe('CRUD Operations', () => { + test('should create slack notification provider', async ({ page }) => { + const providerName = generateProviderName('slack-create'); + let capturedPayload: Record | null = null; + + await test.step('Mock create endpoint to capture payload', async () => { + const createdProviders: Array> = []; + + await page.route('**/api/v1/notifications/providers', async (route, request) => { + if (request.method() === 'POST') { + const payload = (await request.postDataJSON()) as Record; + capturedPayload = payload; + const created = { id: 'slack-provider-1', ...payload }; + createdProviders.push(created); + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify(created), + }); + return; + } + + if (request.method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(createdProviders), + }); + return; + } + + await route.continue(); + }); + }); + + await test.step('Open form and select slack type', async () => { + await page.getByRole('button', { name: /add.*provider/i }).click(); + await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 }); + await page.getByTestId('provider-type').selectOption('slack'); + }); + + await test.step('Fill slack provider form', async () => { + await page.getByTestId('provider-name').fill(providerName); + await page.getByTestId('provider-url').fill('#alerts'); + await page.getByTestId('provider-gotify-token').fill( + 'https://hooks.slack.com/services/T00000000/B00000000/xxxxxxxxxxxxxxxxxxxx' + ); + }); + + await test.step('Configure event notifications', async () => { + await page.getByTestId('notify-proxy-hosts').check(); + await page.getByTestId('notify-certs').check(); + }); + + await test.step('Save provider', async () => { + await Promise.all([ + page.waitForResponse( + (resp) => + /\/api\/v1\/notifications\/providers/.test(resp.url()) && + resp.request().method() === 'POST' && + resp.status() === 201 + ), + page.getByTestId('provider-save-btn').click(), + ]); + }); + + await test.step('Verify provider appears in list', async () => { + const providerInList = page.getByText(providerName); + await expect(providerInList.first()).toBeVisible({ timeout: 10000 }); + }); + + await test.step('Verify outgoing payload contract', async () => { + expect(capturedPayload).toBeTruthy(); + expect(capturedPayload?.type).toBe('slack'); + expect(capturedPayload?.name).toBe(providerName); + expect(capturedPayload?.url).toBe('#alerts'); + expect(capturedPayload?.token).toBe( + 'https://hooks.slack.com/services/T00000000/B00000000/xxxxxxxxxxxxxxxxxxxx' + ); + expect(capturedPayload?.gotify_token).toBeUndefined(); + }); + }); + + test('should edit slack notification provider and preserve webhook URL', async ({ page }) => { + let updatedPayload: Record | null = null; + + await test.step('Mock existing slack provider', async () => { + let providers = [ + { + id: 'slack-edit-id', + name: 'Slack Alerts', + type: 'slack', + url: '#alerts', + has_token: true, + enabled: true, + notify_proxy_hosts: true, + notify_certs: true, + notify_uptime: false, + }, + ]; + + await page.route('**/api/v1/notifications/providers', async (route, request) => { + if (request.method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(providers), + }); + } else { + await route.continue(); + } + }); + + await page.route('**/api/v1/notifications/providers/*', async (route, request) => { + if (request.method() === 'PUT') { + updatedPayload = (await request.postDataJSON()) as Record; + providers = providers.map((p) => + p.id === 'slack-edit-id' ? { ...p, ...updatedPayload } : p + ); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + } else { + await route.continue(); + } + }); + }); + + await test.step('Reload to get mocked provider', async () => { + await page.reload(); + await waitForLoadingComplete(page); + }); + + await test.step('Verify slack provider is displayed', async () => { + await expect(page.getByText('Slack Alerts')).toBeVisible({ timeout: 5000 }); + }); + + await test.step('Click edit on slack provider', async () => { + const providerRow = page.getByTestId('provider-row-slack-edit-id'); + const editButton = providerRow.getByRole('button', { name: /edit/i }); + await expect(editButton).toBeVisible({ timeout: 5000 }); + await editButton.click(); + await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 }); + }); + + await test.step('Verify form loads with slack type', async () => { + await expect(page.getByTestId('provider-type')).toHaveValue('slack'); + }); + + await test.step('Verify stored token indicator is shown', async () => { + await expect(page.getByTestId('gotify-token-stored-indicator')).toBeVisible(); + }); + + await test.step('Update name without changing webhook URL', async () => { + const nameInput = page.getByTestId('provider-name'); + await nameInput.clear(); + await nameInput.fill('Slack Alerts v2'); + }); + + await test.step('Save changes', async () => { + await Promise.all([ + page.waitForResponse( + (resp) => + /\/api\/v1\/notifications\/providers\/slack-edit-id/.test(resp.url()) && + resp.request().method() === 'PUT' && + resp.status() === 200 + ), + page.waitForResponse( + (resp) => + /\/api\/v1\/notifications\/providers/.test(resp.url()) && + resp.request().method() === 'GET' && + resp.status() === 200 + ), + page.getByTestId('provider-save-btn').click(), + ]); + }); + + await test.step('Verify update payload preserves webhook URL omission', async () => { + expect(updatedPayload).toBeTruthy(); + expect(updatedPayload?.type).toBe('slack'); + expect(updatedPayload?.name).toBe('Slack Alerts v2'); + expect(updatedPayload?.token).toBeUndefined(); + expect(updatedPayload?.gotify_token).toBeUndefined(); + }); + }); + + test('should test slack notification provider', async ({ page }) => { + let testCalled = false; + + await test.step('Mock existing slack provider and test endpoint', async () => { + await page.route('**/api/v1/notifications/providers', async (route, request) => { + if (request.method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 'slack-test-id', + name: 'Slack Test Provider', + type: 'slack', + url: '#alerts', + has_token: true, + enabled: true, + notify_proxy_hosts: true, + notify_certs: true, + notify_uptime: false, + }, + ]), + }); + } else { + await route.continue(); + } + }); + + await page.route('**/api/v1/notifications/providers/test', async (route, request) => { + if (request.method() === 'POST') { + testCalled = true; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + } else { + await route.continue(); + } + }); + }); + + await test.step('Reload to get mocked provider', async () => { + await page.reload(); + await waitForLoadingComplete(page); + }); + + await test.step('Click Send Test on the provider', async () => { + const providerRow = page.getByTestId('provider-row-slack-test-id'); + const sendTestButton = providerRow.getByRole('button', { name: /send test/i }); + await expect(sendTestButton).toBeVisible({ timeout: 5000 }); + await expect(sendTestButton).toBeEnabled(); + await Promise.all([ + page.waitForResponse( + (resp) => + resp.url().includes('/api/v1/notifications/providers/test') && + resp.status() === 200 + ), + sendTestButton.click(), + ]); + }); + + await test.step('Verify test was called', async () => { + expect(testCalled).toBe(true); + }); + }); + + test('should delete slack notification provider', async ({ page }) => { + await test.step('Mock existing slack provider', async () => { + await page.route('**/api/v1/notifications/providers', async (route, request) => { + if (request.method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 'slack-delete-id', + name: 'Slack To Delete', + type: 'slack', + url: '#alerts', + enabled: true, + }, + ]), + }); + } else { + await route.continue(); + } + }); + + await page.route('**/api/v1/notifications/providers/*', async (route, request) => { + if (request.method() === 'DELETE') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + } else { + await route.continue(); + } + }); + }); + + await test.step('Reload to get mocked provider', async () => { + await page.reload(); + await waitForLoadingComplete(page); + }); + + await test.step('Verify slack provider is displayed', async () => { + await expect(page.getByText('Slack To Delete')).toBeVisible({ timeout: 10000 }); + }); + + await test.step('Delete provider', async () => { + page.on('dialog', async (dialog) => { + expect(dialog.type()).toBe('confirm'); + await dialog.accept(); + }); + + const deleteButton = page.getByRole('button', { name: /delete/i }) + .or(page.locator('button').filter({ has: page.locator('svg.lucide-trash2, svg[class*="trash"]') })); + await Promise.all([ + page.waitForResponse( + (resp) => + resp.url().includes('/api/v1/notifications/providers/slack-delete-id') && + resp.status() === 200 + ), + deleteButton.first().click(), + ]); + }); + + await test.step('Verify deletion feedback', async () => { + const successIndicator = page.locator('[data-testid="toast-success"]') + .or(page.getByRole('status').filter({ hasText: /deleted|removed/i })) + .or(page.getByText(/no.*providers/i)); + await expect(successIndicator.first()).toBeVisible({ timeout: 5000 }); + }); + }); + }); + + test.describe('Security', () => { + test('GET response should NOT expose webhook URL', async ({ page }) => { + let apiResponseBody: Array> | null = null; + + let resolveRouteBody: (data: Array>) => void; + const routeBodyPromise = new Promise>>((resolve) => { + resolveRouteBody = resolve; + }); + + await test.step('Mock provider list with has_token flag', async () => { + await page.route('**/api/v1/notifications/providers', async (route, request) => { + if (request.method() === 'GET') { + const body = [ + { + id: 'slack-sec-id', + name: 'Slack Secure', + type: 'slack', + url: '#alerts', + has_token: true, + enabled: true, + notify_proxy_hosts: true, + notify_certs: true, + notify_uptime: false, + }, + ]; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); + resolveRouteBody!(body); + } else { + await route.continue(); + } + }); + }); + + await test.step('Navigate to trigger GET', async () => { + await page.reload(); + apiResponseBody = await routeBodyPromise; + await waitForLoadingComplete(page); + }); + + await test.step('Verify webhook URL is not in API response', async () => { + expect(apiResponseBody).toBeTruthy(); + const provider = apiResponseBody![0]; + expect(provider.token).toBeUndefined(); + expect(provider.gotify_token).toBeUndefined(); + const responseStr = JSON.stringify(provider); + expect(responseStr).not.toContain('hooks.slack.com'); + expect(responseStr).not.toContain('/services/'); + }); + }); + + test('webhook URL should NOT be present in URL field', async ({ page }) => { + await test.step('Mock provider with clean URL field', async () => { + await page.route('**/api/v1/notifications/providers', async (route, request) => { + if (request.method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 'slack-url-sec-id', + name: 'Slack URL Check', + type: 'slack', + url: '#alerts', + has_token: true, + enabled: true, + }, + ]), + }); + } else { + await route.continue(); + } + }); + }); + + await test.step('Reload and verify URL field does not contain webhook URL', async () => { + await page.reload(); + await waitForLoadingComplete(page); + await expect(page.getByText('Slack URL Check')).toBeVisible({ timeout: 5000 }); + + const providerRow = page.getByTestId('provider-row-slack-url-sec-id'); + const urlText = await providerRow.textContent(); + expect(urlText).not.toContain('hooks.slack.com'); + expect(urlText).not.toContain('/services/'); + }); + }); + }); +}); diff --git a/tests/settings/telegram-notification-provider.spec.ts b/tests/settings/telegram-notification-provider.spec.ts index 7cfdb07e..ecde7c8f 100644 --- a/tests/settings/telegram-notification-provider.spec.ts +++ b/tests/settings/telegram-notification-provider.spec.ts @@ -409,6 +409,11 @@ test.describe('Telegram Notification Provider', () => { test('GET response should NOT expose bot token', async ({ page }) => { let apiResponseBody: Array> | null = null; + let resolveRouteBody: (data: Array>) => void; + const routeBodyPromise = new Promise>>((resolve) => { + resolveRouteBody = resolve; + }); + await test.step('Mock provider list with has_token flag', async () => { await page.route('**/api/v1/notifications/providers', async (route, request) => { if (request.method() === 'GET') { @@ -430,6 +435,7 @@ test.describe('Telegram Notification Provider', () => { contentType: 'application/json', body: JSON.stringify(body), }); + resolveRouteBody!(body); } else { await route.continue(); } @@ -437,21 +443,8 @@ test.describe('Telegram Notification Provider', () => { }); await test.step('Navigate to trigger GET', async () => { - // Register the response listener BEFORE reload to eliminate the race - // condition where Firefox processes the network response before the - // route callback assignment becomes visible to the test assertion. - // waitForLoadingComplete alone is insufficient because the spinner can - // disappear before the providers API response has been intercepted. - const responsePromise = page.waitForResponse( - (resp) => - resp.url().includes('/api/v1/notifications/providers') && - resp.request().method() === 'GET' && - resp.status() === 200, - { timeout: 15000 } - ); await page.reload(); - const response = await responsePromise; - apiResponseBody = (await response.json()) as Array>; + apiResponseBody = await routeBodyPromise; await waitForLoadingComplete(page); });