source_monitor 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/sm-configuration-setting/reference/settings-catalog.md +1 -0
  3. data/.claude/skills/sm-configure/SKILL.md +8 -1
  4. data/.claude/skills/sm-configure/reference/configuration-reference.md +11 -0
  5. data/.claude/skills/sm-event-handler/SKILL.md +1 -1
  6. data/.claude/skills/sm-event-handler/reference/events-api.md +1 -1
  7. data/.claude/skills/sm-host-setup/SKILL.md +13 -3
  8. data/.claude/skills/sm-host-setup/reference/initializer-template.md +11 -0
  9. data/.claude/skills/sm-host-setup/reference/setup-checklist.md +9 -1
  10. data/.claude/skills/sm-upgrade/reference/version-history.md +12 -0
  11. data/CHANGELOG.md +19 -0
  12. data/Gemfile.lock +1 -1
  13. data/README.md +3 -3
  14. data/VERSION +1 -1
  15. data/app/assets/builds/source_monitor/application.css +4 -0
  16. data/app/controllers/source_monitor/application_controller.rb +73 -14
  17. data/app/controllers/source_monitor/bulk_scrape_enablements_controller.rb +1 -1
  18. data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +3 -1
  19. data/app/controllers/source_monitor/import_sessions_controller.rb +118 -72
  20. data/app/controllers/source_monitor/sources_controller.rb +4 -18
  21. data/app/models/source_monitor/source.rb +1 -1
  22. data/app/views/layouts/source_monitor/application.html.erb +6 -0
  23. data/docs/configuration.md +18 -1
  24. data/docs/deployment.md +1 -1
  25. data/docs/goals/engine-hardening/.goalbuddy-board/app.js +543 -0
  26. data/docs/goals/engine-hardening/.goalbuddy-board/goalbuddy-mark.png +0 -0
  27. data/docs/goals/engine-hardening/.goalbuddy-board/index.html +111 -0
  28. data/docs/goals/engine-hardening/.goalbuddy-board/styles.css +991 -0
  29. data/docs/goals/engine-hardening/goal.md +97 -0
  30. data/docs/goals/engine-hardening/notes/T001-spec-validation.md +37 -0
  31. data/docs/goals/engine-hardening/state.yaml +324 -0
  32. data/docs/setup.md +3 -3
  33. data/docs/upgrade.md +41 -0
  34. data/lib/generators/source_monitor/install/templates/source_monitor.rb.tt +10 -0
  35. data/lib/source_monitor/analytics/scrape_recommendations.rb +21 -2
  36. data/lib/source_monitor/configuration/authentication_settings.rb +5 -1
  37. data/lib/source_monitor/fetching/feed_fetcher/failure_outcome.rb +85 -0
  38. data/lib/source_monitor/fetching/feed_fetcher/success_outcome.rb +85 -0
  39. data/lib/source_monitor/fetching/feed_fetcher.rb +27 -88
  40. data/lib/source_monitor/fetching/fetch_runner.rb +12 -5
  41. data/lib/source_monitor/import_sessions/wizard.rb +612 -0
  42. data/lib/source_monitor/items/batch_item_creator.rb +7 -6
  43. data/lib/source_monitor/items/item_creator.rb +7 -14
  44. data/lib/source_monitor/items/normalized_entry.rb +61 -0
  45. data/lib/source_monitor/security/authentication.rb +10 -0
  46. data/lib/source_monitor/version.rb +1 -1
  47. data/lib/source_monitor.rb +2 -0
  48. data/source_monitor.gemspec +7 -2
  49. metadata +12 -68
  50. data/.claude/agent-memory/vbw-vbw-debugger/MEMORY.md +0 -15
  51. data/.claude/agent-memory/vbw-vbw-dev/MEMORY.md +0 -34
  52. data/.claude/agent-memory/vbw-vbw-lead/MEMORY.md +0 -49
  53. data/.claude/agents/rails-concern.md +0 -464
  54. data/.claude/agents/rails-controller.md +0 -424
  55. data/.claude/agents/rails-hotwire.md +0 -446
  56. data/.claude/agents/rails-implement.md +0 -374
  57. data/.claude/agents/rails-job.md +0 -334
  58. data/.claude/agents/rails-lint.md +0 -294
  59. data/.claude/agents/rails-mailer.md +0 -371
  60. data/.claude/agents/rails-migration.md +0 -449
  61. data/.claude/agents/rails-model.md +0 -420
  62. data/.claude/agents/rails-policy.md +0 -443
  63. data/.claude/agents/rails-presenter.md +0 -427
  64. data/.claude/agents/rails-query.md +0 -412
  65. data/.claude/agents/rails-review.md +0 -490
  66. data/.claude/agents/rails-service.md +0 -458
  67. data/.claude/agents/rails-state-records.md +0 -465
  68. data/.claude/agents/rails-tdd.md +0 -314
  69. data/.claude/agents/rails-test.md +0 -441
  70. data/.claude/agents/rails-view-component.md +0 -418
  71. data/.claude/commands/rails-audit.md +0 -77
  72. data/.claude/commands/release.md +0 -366
  73. data/.claude/hooks/block-secrets.sh +0 -52
  74. data/.claude/settings.json +0 -85
  75. data/.claude/skills/action-cable-patterns/SKILL.md +0 -296
  76. data/.claude/skills/action-mailer-patterns/SKILL.md +0 -295
  77. data/.claude/skills/active-storage-setup/SKILL.md +0 -311
  78. data/.claude/skills/api-versioning/SKILL.md +0 -294
  79. data/.claude/skills/authentication-flow/SKILL.md +0 -335
  80. data/.claude/skills/authentication-flow/reference/current.md +0 -248
  81. data/.claude/skills/authentication-flow/reference/passwordless.md +0 -253
  82. data/.claude/skills/authentication-flow/reference/sessions.md +0 -201
  83. data/.claude/skills/authorization-pundit/SKILL.md +0 -462
  84. data/.claude/skills/caching-strategies/SKILL.md +0 -350
  85. data/.claude/skills/database-migrations/SKILL.md +0 -354
  86. data/.claude/skills/form-object-patterns/SKILL.md +0 -399
  87. data/.claude/skills/hotwire-patterns/SKILL.md +0 -247
  88. data/.claude/skills/hotwire-patterns/reference/stimulus.md +0 -307
  89. data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +0 -112
  90. data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +0 -158
  91. data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +0 -218
  92. data/.claude/skills/i18n-patterns/SKILL.md +0 -320
  93. data/.claude/skills/install/SKILL.md +0 -367
  94. data/.claude/skills/performance-optimization/SKILL.md +0 -311
  95. data/.claude/skills/rails-architecture/SKILL.md +0 -259
  96. data/.claude/skills/rails-architecture/reference/error-handling.md +0 -333
  97. data/.claude/skills/rails-architecture/reference/event-tracking.md +0 -142
  98. data/.claude/skills/rails-architecture/reference/layer-interactions.md +0 -417
  99. data/.claude/skills/rails-architecture/reference/multi-tenancy.md +0 -152
  100. data/.claude/skills/rails-architecture/reference/query-patterns.md +0 -342
  101. data/.claude/skills/rails-architecture/reference/service-patterns.md +0 -286
  102. data/.claude/skills/rails-architecture/reference/state-records.md +0 -250
  103. data/.claude/skills/rails-architecture/reference/testing-strategy.md +0 -326
  104. data/.claude/skills/rails-concern/SKILL.md +0 -399
  105. data/.claude/skills/rails-controller/SKILL.md +0 -336
  106. data/.claude/skills/rails-model-generator/SKILL.md +0 -321
  107. data/.claude/skills/rails-model-generator/reference/validations.md +0 -298
  108. data/.claude/skills/rails-presenter/SKILL.md +0 -274
  109. data/.claude/skills/rails-query-object/SKILL.md +0 -289
  110. data/.claude/skills/rails-service-object/SKILL.md +0 -349
  111. data/.claude/skills/solid-queue-setup/SKILL.md +0 -307
  112. data/.claude/skills/tdd-cycle/SKILL.md +0 -359
  113. data/.claude/skills/viewcomponent-patterns/SKILL.md +0 -333
  114. data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +0 -187
  115. data/app/controllers/source_monitor/import_sessions/health_check_management.rb +0 -112
  116. data/app/controllers/source_monitor/import_sessions/opml_parser.rb +0 -130
@@ -1,15 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "nokogiri"
4
- require "uri"
5
- require "source_monitor/import_sessions/entry_normalizer"
3
+ require "source_monitor/import_sessions/wizard"
6
4
  require "source_monitor/sources/params"
7
5
 
8
6
  module SourceMonitor
9
7
  class ImportSessionsController < ApplicationController
10
- include SourceMonitor::ImportSessions::OpmlParser
11
- include SourceMonitor::ImportSessions::EntryAnnotation
12
- include SourceMonitor::ImportSessions::HealthCheckManagement
13
8
  include SourceMonitor::ImportSessions::BulkConfiguration
14
9
 
15
10
  STEP_HANDLERS = {
@@ -86,92 +81,60 @@ module SourceMonitor
86
81
  def persist_step!
87
82
  return if @import_session.current_step == @current_step
88
83
 
89
- deactivate_health_checks! if @current_step != "health_check"
84
+ import_session_wizard.deactivate_health_checks if @current_step != "health_check"
90
85
  @import_session.update_column(:current_step, @current_step)
91
86
  end
92
87
 
93
88
  def handle_health_check_step
94
- @selected_source_ids = health_check_selection_from_params
95
- @import_session.update!(selected_source_ids: @selected_source_ids)
96
- if advancing_from_health_check? && @selected_source_ids.blank?
97
- @selection_error = "Select at least one source to continue."
98
- prepare_health_check_context
89
+ result = import_session_wizard.handle_health_check
90
+ @selected_source_ids = result.selected_source_ids
91
+
92
+ if result.blocked?
93
+ @selection_error = result.selection_error
94
+ apply_health_check_context(result.health_check_context)
99
95
  render :show, status: :unprocessable_entity
100
96
  return
101
97
  end
102
98
 
103
- @current_step = target_step
104
- deactivate_health_checks! if @current_step != "health_check"
105
- @import_session.update_column(:current_step, @current_step) if @import_session.current_step != @current_step
106
- prepare_health_check_context if @current_step == "health_check"
99
+ @current_step = result.current_step
100
+ apply_health_check_context(result.health_check_context) if @current_step == "health_check"
107
101
  redirect_to source_monitor.step_import_session_path(@import_session, step: @current_step), allow_other_host: false
108
102
  end
109
103
 
110
104
  def handle_upload_step
111
- @upload_errors = validate_upload!
105
+ result = import_session_wizard.handle_upload
106
+ @upload_errors = result.errors
112
107
  if @upload_errors.any?
113
108
  render :show, status: :unprocessable_entity
114
109
  return
115
110
  end
116
111
 
117
- parsed_entries = parse_opml_file(params[:opml_file])
118
- valid_entries = parsed_entries.select { |entry| entry[:status] == "valid" }
119
- if valid_entries.empty?
120
- @upload_errors = [ "We couldn't find any valid feeds in that OPML file. Check the file and try again." ]
121
- @import_session.update!(opml_file_metadata: build_file_metadata, parsed_sources: parsed_entries, current_step: "upload")
122
- render :show, status: :unprocessable_entity
123
- return
124
- end
125
-
126
- @import_session.update!(
127
- opml_file_metadata: build_file_metadata.merge("uploaded_at" => Time.current),
128
- parsed_sources: parsed_entries,
129
- current_step: target_step
130
- )
131
-
132
- @current_step = target_step
133
- prepare_preview_context(skip_default: true) if @current_step == "preview"
112
+ @current_step = result.current_step
113
+ apply_preview_context(result.preview_context) if @current_step == "preview"
134
114
 
135
115
  respond_to do |format|
136
116
  format.turbo_stream { render :show }
137
117
  format.html { redirect_to source_monitor.step_import_session_path(@import_session, step: @current_step) }
138
118
  end
139
- rescue UploadError => error
140
- @upload_errors = [ error.message ]
141
- render :show, status: :unprocessable_entity
142
119
  end
143
120
 
144
121
  def handle_preview_step
145
- @selected_source_ids = Array(@import_session.selected_source_ids).map(&:to_s)
146
-
147
- if params.dig(:import_session, :select_all).present?
148
- @selected_source_ids = selectable_entries.map { |entry| entry[:id] }
149
- @import_session.update_column(:selected_source_ids, @selected_source_ids)
150
- valid_ids = @selected_source_ids
151
- elsif params.dig(:import_session, :select_none).present?
152
- @selected_source_ids = []
153
- @import_session.update_column(:selected_source_ids, @selected_source_ids)
154
- valid_ids = []
155
- else
156
- @selected_source_ids = build_selection_from_params
157
- valid_ids = selectable_entries.index_by { |entry| entry[:id] }.slice(*@selected_source_ids).keys
158
- @import_session.update!(selected_source_ids: valid_ids)
159
- end
122
+ result = import_session_wizard.handle_preview
123
+ @selected_source_ids = result.selected_source_ids
160
124
 
161
- if advancing_from_preview? && valid_ids.empty?
162
- @selection_error = "Select at least one new source to continue."
163
- prepare_preview_context(skip_default: true)
125
+ if result.blocked?
126
+ @selection_error = result.selection_error
127
+ apply_preview_context(result.preview_context)
164
128
  render :show, status: :unprocessable_entity
165
129
  return
166
130
  end
167
131
 
168
- @current_step = target_step
169
- @import_session.update_column(:current_step, @current_step) if @import_session.current_step != @current_step
132
+ @current_step = result.current_step
170
133
 
171
134
  if @current_step == "health_check"
172
135
  prepare_health_check_context
173
136
  else
174
- prepare_preview_context(skip_default: true)
137
+ apply_preview_context(result.preview_context)
175
138
  end
176
139
 
177
140
  respond_to do |format|
@@ -200,31 +163,25 @@ module SourceMonitor
200
163
  end
201
164
 
202
165
  def handle_confirm_step
203
- @selected_source_ids = Array(@import_session.selected_source_ids).map(&:to_s)
204
- @selected_entries = annotated_entries(@selected_source_ids).select { |entry| @selected_source_ids.include?(entry[:id]) }
205
- if @selected_entries.empty?
206
- @selection_error = "Select at least one source to import."
207
- prepare_confirm_context
166
+ result = import_session_wizard.handle_confirm
167
+ apply_confirm_context(result)
168
+
169
+ if result.blocked?
170
+ @selection_error = result.selection_error
208
171
  render :show, status: :unprocessable_entity
209
172
  return
210
173
  end
211
- history = SourceMonitor::ImportHistory.create!(
212
- user_id: @import_session.user_id,
213
- bulk_settings: @import_session.bulk_settings
214
- )
215
- SourceMonitor::ImportOpmlJob.perform_later(@import_session.id, history.id)
216
- @import_session.update_column(:current_step, "confirm") if @import_session.current_step != "confirm"
217
- message = "Import started for #{@selected_entries.size} sources."
174
+
218
175
  respond_to do |format|
219
176
  format.turbo_stream do
220
177
  responder = SourceMonitor::TurboStreams::StreamResponder.new
221
- responder.toast(message:, level: :success)
178
+ responder.toast(message: result.message, level: :success)
222
179
  responder.redirect(source_monitor.sources_path)
223
180
  render turbo_stream: responder.render(view_context)
224
181
  end
225
182
 
226
183
  format.html do
227
- redirect_to source_monitor.sources_path, notice: message
184
+ redirect_to source_monitor.sources_path, notice: result.message
228
185
  end
229
186
  end
230
187
  end
@@ -302,6 +259,95 @@ module SourceMonitor
302
259
  end
303
260
  # :nocov:
304
261
 
262
+ def import_session_wizard
263
+ SourceMonitor::ImportSessions::Wizard.new(
264
+ import_session: @import_session,
265
+ params: params,
266
+ current_step: @current_step
267
+ )
268
+ end
269
+
270
+ def permitted_step(value)
271
+ step = value.to_s.presence
272
+ return unless step
273
+
274
+ ImportSession::STEP_ORDER.find { |candidate| candidate == step }
275
+ end
276
+
277
+ def target_step
278
+ permitted_step(import_session_state_params[:next_step]) || @current_step || ImportSession.default_step
279
+ end
280
+
281
+ def session_attributes
282
+ attrs = import_session_state_params.except(:next_step, :current_step, "next_step", "current_step")
283
+ attrs[:current_step] = target_step
284
+ attrs
285
+ end
286
+
287
+ def import_session_state_params
288
+ @import_session_state_params ||= begin
289
+ raw = params[:import_session] || params["import_session"] || {}
290
+ permitted = if raw.respond_to?(:permit)
291
+ raw.permit(
292
+ :current_step,
293
+ :next_step,
294
+ :select_all,
295
+ :select_none,
296
+ parsed_sources: [],
297
+ selected_source_ids: [],
298
+ bulk_settings: {},
299
+ opml_file_metadata: {}
300
+ )
301
+ else
302
+ raw.to_h
303
+ end
304
+
305
+ SourceMonitor::Security::ParameterSanitizer.sanitize(permitted.to_h).with_indifferent_access
306
+ end
307
+ end
308
+
309
+ def prepare_preview_context(skip_default: false)
310
+ context = if skip_default
311
+ import_session_wizard.preview_context
312
+ else
313
+ import_session_wizard.preview_context_with_default_selection
314
+ end
315
+
316
+ apply_preview_context(context)
317
+ end
318
+
319
+ def prepare_health_check_context
320
+ apply_health_check_context(import_session_wizard.health_check_context)
321
+ end
322
+
323
+ def prepare_confirm_context
324
+ apply_confirm_context(import_session_wizard.confirm_context)
325
+ end
326
+
327
+ def apply_preview_context(context)
328
+ @filter = context.filter
329
+ @page = context.page
330
+ @selected_source_ids = context.selected_source_ids
331
+ @preview_entries = context.preview_entries
332
+ @filtered_entries = context.filtered_entries
333
+ @paginated_entries = context.paginated_entries
334
+ @has_next_page = context.has_next_page
335
+ @has_previous_page = context.has_previous_page
336
+ end
337
+
338
+ def apply_health_check_context(context)
339
+ @selected_source_ids = context.selected_source_ids
340
+ @health_check_entries = context.health_check_entries
341
+ @health_check_target_ids = context.health_check_target_ids
342
+ @health_progress = context.health_progress
343
+ end
344
+
345
+ def apply_confirm_context(context)
346
+ @selected_source_ids = context.selected_source_ids
347
+ @selected_entries = context.selected_entries
348
+ @bulk_settings = context.bulk_settings
349
+ end
350
+
305
351
  def authorize_import_session!
306
352
  return if !SourceMonitor::Security::Authentication.authentication_configured?
307
353
 
@@ -52,8 +52,9 @@ module SourceMonitor
52
52
  @avg_feed_word_counts = word_counts[:feed]
53
53
  @avg_scraped_word_counts = word_counts[:scraped]
54
54
 
55
- @scrape_candidate_ids = compute_scrape_candidate_ids
56
- @total_scrape_candidate_count = Source.scrape_candidates.count
55
+ @scrape_recommendations = SourceMonitor::Analytics::ScrapeRecommendations.new
56
+ @scrape_candidate_ids = Set.new(@scrape_recommendations.candidate_ids_for(source_ids))
57
+ @total_scrape_candidate_count = @scrape_recommendations.candidates_count
57
58
 
58
59
  # Row partial preload requirements (V3): item_activity_rates,
59
60
  # avg_feed_word_counts, avg_scraped_word_counts are pre-computed above
@@ -188,23 +189,8 @@ module SourceMonitor
188
189
  def expand_scrape_recommendation_filter
189
190
  return unless @search_params["scraping_enabled_eq"] == "recommend"
190
191
 
191
- threshold = SourceMonitor.config.scraping.scrape_recommendation_threshold
192
192
  @search_params.delete("scraping_enabled_eq")
193
- @search_params["scraping_enabled_eq"] = "false"
194
- @search_params["active_eq"] = "true"
195
- @search_params["avg_feed_words_lt"] = threshold.to_s
196
- end
197
-
198
- def compute_scrape_candidate_ids
199
- threshold = SourceMonitor.config.scraping.scrape_recommendation_threshold
200
- return Set.new if threshold.nil? || threshold <= 0
201
-
202
- candidate_ids = @sources.select do |source|
203
- avg = @avg_feed_word_counts[source.id]
204
- avg.present? && avg < threshold && !source.scraping_enabled?
205
- end.map(&:id)
206
-
207
- Set.new(candidate_ids)
193
+ @search_params.merge!(SourceMonitor::Analytics::ScrapeRecommendations.new.filter_params)
208
194
  end
209
195
 
210
196
  def enqueue_unscraped_items(source)
@@ -66,7 +66,7 @@ module SourceMonitor
66
66
  end
67
67
 
68
68
  def scrape_candidates(threshold: SourceMonitor.config.scraping.scrape_recommendation_threshold)
69
- SourceMonitor::Queries::ScrapeCandidatesQuery.new(threshold:).call
69
+ SourceMonitor::Analytics::ScrapeRecommendations.new(threshold:).relation
70
70
  end
71
71
 
72
72
  # Bulk-enable scraping for sources that don't already have it enabled.
@@ -19,6 +19,12 @@
19
19
  <div id="source_monitor_notifications"
20
20
  data-notification-container-target="list"
21
21
  class="flex w-full flex-col gap-3">
22
+ <%# Request flashes render response-local here (never broadcast to all tabs). %>
23
+ <% source_monitor_flash_toasts.each do |toast| %>
24
+ <%= render "source_monitor/shared/toast",
25
+ message: toast[:message],
26
+ level: toast[:level] %>
27
+ <% end %>
22
28
  </div>
23
29
  <div data-notification-container-target="badge"
24
30
  class="pointer-events-auto hidden">
@@ -137,6 +137,11 @@ Call `config.realtime.action_cable_config` if you need a full hash for environme
137
137
 
138
138
  ## Authentication Helpers
139
139
 
140
+ **Fail-closed by default.** SourceMonitor denies access to every engine route
141
+ (returning `403 Forbidden`) unless you configure an authentication or
142
+ authorization handler. This prevents the engine's create/update/delete/enqueue
143
+ routes from being public by accident.
144
+
140
145
  Protect the dashboard with host-specific auth in one place:
141
146
 
142
147
  ```ruby
@@ -148,7 +153,19 @@ config.authentication.current_user_method = :current_user
148
153
  config.authentication.user_signed_in_method = :user_signed_in?
149
154
  ```
150
155
 
151
- Handlers can be symbols (invoked on the controller) or callables. Return `false` or raise to deny access.
156
+ Handlers can be symbols (invoked on the controller) or callables. Return `false` or raise to deny access. As soon as either handler is configured, the handler decides access and the fail-closed guard no longer applies.
157
+
158
+ ### Open access opt-in (non-production)
159
+
160
+ For local demos or sandboxes where engine routes are deliberately public, you
161
+ can explicitly opt out of the fail-closed guard:
162
+
163
+ ```ruby
164
+ config.authentication.open_access = true # default: false
165
+ ```
166
+
167
+ This is intended for non-production/demo environments only. Configuring a
168
+ handler always takes precedence over this flag.
152
169
 
153
170
  ## Health Model
154
171
 
data/docs/deployment.md CHANGED
@@ -33,7 +33,7 @@ SourceMonitor assumes the standard Rails 8 process split:
33
33
 
34
34
  ## Security & Authentication
35
35
 
36
- - Lock down the engine routes with authentication hooks (`config.authentication.authenticate_with` / `authorize_with`).
36
+ - SourceMonitor is **fail-closed by default**: without a configured handler every engine route returns `403 Forbidden`. Lock down the routes with authentication hooks (`config.authentication.authenticate_with` / `authorize_with`). Only set `config.authentication.open_access = true` for non-production demos where public access is intentional.
37
37
  - Configure HTTPS for Action Cable if you expose Solid Cable over the public internet.
38
38
  - Store API keys for authenticated feeds in encrypted credentials and inject them via per-source custom headers.
39
39