rails_webhook_outbox 0.2.0 → 0.4.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +218 -4
  3. data/Rakefile +6 -1
  4. data/UPGRADING.md +92 -0
  5. data/app/assets/stylesheets/rails_webhook_outbox/_01_base.css +53 -0
  6. data/app/assets/stylesheets/rails_webhook_outbox/_02_layout.css +88 -0
  7. data/app/assets/stylesheets/rails_webhook_outbox/_03_table.css +26 -0
  8. data/app/assets/stylesheets/rails_webhook_outbox/_04_badges.css +19 -0
  9. data/app/assets/stylesheets/rails_webhook_outbox/_05_forms.css +42 -0
  10. data/app/assets/stylesheets/rails_webhook_outbox/_06_filters.css +13 -0
  11. data/app/assets/stylesheets/rails_webhook_outbox/_07_timeline.css +31 -0
  12. data/app/controllers/rails_webhook_outbox/base_controller.rb +15 -0
  13. data/app/controllers/rails_webhook_outbox/deliveries_controller.rb +46 -0
  14. data/app/controllers/rails_webhook_outbox/events_controller.rb +13 -0
  15. data/app/controllers/rails_webhook_outbox/overview_controller.rb +7 -0
  16. data/app/controllers/rails_webhook_outbox/subscriptions_controller.rb +62 -0
  17. data/app/helpers/rails_webhook_outbox/application_helper.rb +29 -0
  18. data/app/javascript/rails_webhook_outbox/application.js +2 -0
  19. data/app/javascript/rails_webhook_outbox/controllers/application.js +4 -0
  20. data/app/javascript/rails_webhook_outbox/controllers/index.js +4 -0
  21. data/app/javascript/rails_webhook_outbox/controllers/secret_controller.js +24 -0
  22. data/app/jobs/rails_webhook_outbox/delivery_job.rb +62 -1
  23. data/app/models/rails_webhook_outbox/delivery.rb +28 -0
  24. data/app/models/rails_webhook_outbox/delivery_attempt.rb +13 -0
  25. data/app/models/rails_webhook_outbox/overview.rb +49 -0
  26. data/app/models/rails_webhook_outbox/subscription.rb +50 -2
  27. data/app/views/layouts/rails_webhook_outbox/application.html.erb +22 -0
  28. data/app/views/rails_webhook_outbox/deliveries/_detail.html.erb +47 -0
  29. data/app/views/rails_webhook_outbox/deliveries/_row.html.erb +7 -0
  30. data/app/views/rails_webhook_outbox/deliveries/_timeline.html.erb +41 -0
  31. data/app/views/rails_webhook_outbox/deliveries/index.html.erb +62 -0
  32. data/app/views/rails_webhook_outbox/deliveries/retry.turbo_stream.erb +2 -0
  33. data/app/views/rails_webhook_outbox/deliveries/show.html.erb +5 -0
  34. data/app/views/rails_webhook_outbox/events/index.html.erb +22 -0
  35. data/app/views/rails_webhook_outbox/overview/_recent_failures.html.erb +29 -0
  36. data/app/views/rails_webhook_outbox/overview/_stats.html.erb +19 -0
  37. data/app/views/rails_webhook_outbox/overview/show.html.erb +8 -0
  38. data/app/views/rails_webhook_outbox/subscriptions/_form.html.erb +49 -0
  39. data/app/views/rails_webhook_outbox/subscriptions/edit.html.erb +7 -0
  40. data/app/views/rails_webhook_outbox/subscriptions/index.html.erb +37 -0
  41. data/app/views/rails_webhook_outbox/subscriptions/new.html.erb +7 -0
  42. data/app/views/rails_webhook_outbox/subscriptions/show.html.erb +49 -0
  43. data/config/importmap.rb +6 -0
  44. data/config/routes.rb +17 -0
  45. data/db/migrate/20260624000001_create_webhook_outbox_subscriptions.rb +1 -1
  46. data/db/migrate/20260624000002_create_webhook_outbox_deliveries.rb +4 -4
  47. data/db/migrate/20260629000002_add_idempotency_key_to_webhook_outbox_deliveries.rb +2 -2
  48. data/db/migrate/20260701000001_add_secret_rotation_to_webhook_outbox_subscriptions.rb +6 -0
  49. data/db/migrate/20260701000002_add_consecutive_failures_to_webhook_outbox_subscriptions.rb +5 -0
  50. data/db/migrate/20261027000001_create_webhook_outbox_delivery_attempts.rb +16 -0
  51. data/lib/generators/rails_webhook_outbox/install/install_generator.rb +1 -19
  52. data/lib/rails_webhook_outbox/configuration.rb +14 -1
  53. data/lib/rails_webhook_outbox/engine.rb +16 -0
  54. data/lib/rails_webhook_outbox/secret_rotation_error.rb +8 -0
  55. data/lib/rails_webhook_outbox/sender.rb +2 -1
  56. data/lib/rails_webhook_outbox/signature.rb +2 -2
  57. data/lib/rails_webhook_outbox/version.rb +1 -1
  58. data/lib/rails_webhook_outbox.rb +1 -0
  59. data/lib/tasks/rails_webhook_outbox_tasks.rake +51 -4
  60. metadata +70 -3
  61. data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb.tt +0 -21
  62. data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb.tt +0 -13
@@ -0,0 +1,62 @@
1
+ <div class="rwo-header">
2
+ <h1>Deliveries</h1>
3
+ </div>
4
+
5
+ <%= form_with url: deliveries_path, method: :get, local: true, class: "rwo-filters" do %>
6
+ <div class="rwo-field">
7
+ <%= label_tag :status, "Status" %>
8
+ <%= select_tag :status,
9
+ options_for_select([["All", ""]] + RailsWebhookOutbox::Delivery.statuses.keys.map { |s| [s.capitalize, s] }, @status),
10
+ class: "rwo-select" %>
11
+ </div>
12
+
13
+ <div class="rwo-field">
14
+ <%= label_tag :event, "Event" %>
15
+ <%= select_tag :event,
16
+ options_for_select([["All", ""]] + RailsWebhookOutbox.config.events, @event),
17
+ class: "rwo-select" %>
18
+ </div>
19
+
20
+ <div class="rwo-field">
21
+ <%= label_tag :from, "From" %>
22
+ <%= date_field_tag :from, @from, class: "rwo-input" %>
23
+ </div>
24
+
25
+ <div class="rwo-field">
26
+ <%= label_tag :to, "To" %>
27
+ <%= date_field_tag :to, @to, class: "rwo-input" %>
28
+ </div>
29
+
30
+ <%= submit_tag "Filter", class: "rwo-btn" %>
31
+ <%= link_to "Reset", deliveries_path, class: "rwo-btn-reset" %>
32
+ <% end %>
33
+
34
+ <%= turbo_stream_from "rails_webhook_outbox_deliveries" %>
35
+
36
+ <% if @deliveries.empty? %>
37
+ <p class="rwo-empty">No deliveries found.</p>
38
+ <% else %>
39
+ <table class="rwo-table">
40
+ <thead>
41
+ <tr>
42
+ <th>Event</th>
43
+ <th>Subscription</th>
44
+ <th>Status</th>
45
+ <th>Attempts</th>
46
+ <th>Created</th>
47
+ </tr>
48
+ </thead>
49
+ <tbody>
50
+ <%= render partial: "row", collection: @deliveries, as: :delivery %>
51
+ </tbody>
52
+ </table>
53
+
54
+ <div class="rwo-pagination">
55
+ <% if @page > 1 %>
56
+ <%= link_to "← Previous", deliveries_path(request.query_parameters.merge(page: @page - 1)), class: "rwo-btn-small" %>
57
+ <% end %>
58
+ <% if @page * RailsWebhookOutbox::DeliveriesController::PER_PAGE < @total_count %>
59
+ <%= link_to "Next →", deliveries_path(request.query_parameters.merge(page: @page + 1)), class: "rwo-btn-small" %>
60
+ <% end %>
61
+ </div>
62
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <%= turbo_stream.replace dom_id(@delivery), partial: "rails_webhook_outbox/deliveries/row", locals: { delivery: @delivery } %>
2
+ <%= turbo_stream.replace dom_id(@delivery, :detail), partial: "rails_webhook_outbox/deliveries/detail", locals: { delivery: @delivery } %>
@@ -0,0 +1,5 @@
1
+ <%= link_to "← All Deliveries", deliveries_path, class: "rwo-back" %>
2
+
3
+ <%= turbo_stream_from @delivery %>
4
+
5
+ <%= render "detail", delivery: @delivery %>
@@ -0,0 +1,22 @@
1
+ <div class="rwo-header">
2
+ <h1>Registered Events</h1>
3
+ </div>
4
+
5
+ <% if @events.empty? %>
6
+ <p class="rwo-empty">No events registered. Set <code>config.events</code> in your initializer.</p>
7
+ <% else %>
8
+ <% @events.each do |event| %>
9
+ <div class="rwo-event">
10
+ <div class="rwo-header-row">
11
+ <h2 class="rwo-section-title"><%= event[:name] %></h2>
12
+ <span class="rwo-muted"><%= event[:subscriber_count] %> active subscriber<%= "s" unless event[:subscriber_count] == 1 %></span>
13
+ </div>
14
+
15
+ <% if event[:example] %>
16
+ <pre class="rwo-monospace rwo-code-block"><%= JSON.pretty_generate(event[:example]) %></pre>
17
+ <% else %>
18
+ <p class="rwo-muted">No deliveries yet for this event.</p>
19
+ <% end %>
20
+ </div>
21
+ <% end %>
22
+ <% end %>
@@ -0,0 +1,29 @@
1
+ <div id="<%= RailsWebhookOutbox::Overview::FAILURES_TARGET %>">
2
+ <h2 class="rwo-section-title">Recent Failures</h2>
3
+
4
+ <% failures = overview.recent_failures %>
5
+ <% if failures.empty? %>
6
+ <p class="rwo-empty">No failed deliveries.</p>
7
+ <% else %>
8
+ <table class="rwo-table">
9
+ <thead>
10
+ <tr>
11
+ <th>Event</th>
12
+ <th>Subscription</th>
13
+ <th>Attempts</th>
14
+ <th>Created</th>
15
+ </tr>
16
+ </thead>
17
+ <tbody>
18
+ <% failures.each do |delivery| %>
19
+ <tr>
20
+ <td><%= link_to delivery.event, delivery_path(delivery) %></td>
21
+ <td><%= delivery.subscription.url %></td>
22
+ <td class="rwo-muted"><%= delivery.attempts %></td>
23
+ <td class="rwo-muted"><%= delivery.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
24
+ </tr>
25
+ <% end %>
26
+ </tbody>
27
+ </table>
28
+ <% end %>
29
+ </div>
@@ -0,0 +1,19 @@
1
+ <div id="<%= RailsWebhookOutbox::Overview::STATS_TARGET %>" class="rwo-stats-grid">
2
+ <div class="rwo-stat">
3
+ <div class="rwo-stat-label">Subscriptions</div>
4
+ <div class="rwo-stat-value"><%= link_to overview.subscription_count, subscriptions_path %></div>
5
+ <div class="rwo-muted"><%= overview.active_subscription_count %> active</div>
6
+ </div>
7
+ <div class="rwo-stat">
8
+ <div class="rwo-stat-label">Delivered</div>
9
+ <div class="rwo-stat-value"><%= link_to overview.delivery_count(:delivered), deliveries_path(status: "delivered") %></div>
10
+ </div>
11
+ <div class="rwo-stat">
12
+ <div class="rwo-stat-label">Pending</div>
13
+ <div class="rwo-stat-value"><%= link_to overview.delivery_count(:pending), deliveries_path(status: "pending") %></div>
14
+ </div>
15
+ <div class="rwo-stat">
16
+ <div class="rwo-stat-label">Failed</div>
17
+ <div class="rwo-stat-value"><%= link_to overview.delivery_count(:failed), deliveries_path(status: "failed") %></div>
18
+ </div>
19
+ </div>
@@ -0,0 +1,8 @@
1
+ <div class="rwo-header">
2
+ <h1>Overview</h1>
3
+ </div>
4
+
5
+ <%= turbo_stream_from RailsWebhookOutbox::Overview::STREAM %>
6
+
7
+ <%= render "stats", overview: @overview %>
8
+ <%= render "recent_failures", overview: @overview %>
@@ -0,0 +1,49 @@
1
+ <%= form_with model: subscription, url: subscription.persisted? ? subscription_path(subscription) : subscriptions_path, local: true, class: "rwo-form" do |form| %>
2
+ <% if subscription.errors.any? %>
3
+ <div class="rwo-alert">
4
+ <ul>
5
+ <% subscription.errors.full_messages.each do |message| %>
6
+ <li><%= message %></li>
7
+ <% end %>
8
+ </ul>
9
+ </div>
10
+ <% end %>
11
+
12
+ <div class="rwo-field">
13
+ <%= form.label :url %>
14
+ <%= form.text_field :url, class: "rwo-input", placeholder: "https://example.com/webhooks" %>
15
+ </div>
16
+
17
+ <div class="rwo-field">
18
+ <%= form.label :events %>
19
+ <% if RailsWebhookOutbox.config.events.any? %>
20
+ <div class="rwo-checkbox-group">
21
+ <%= hidden_field_tag "subscription[events][]", "", id: nil %>
22
+ <% RailsWebhookOutbox.config.events.each do |event| %>
23
+ <label class="rwo-checkbox-label">
24
+ <%= check_box_tag "subscription[events][]", event, subscription.events.include?(event), id: nil %>
25
+ <%= event %>
26
+ </label>
27
+ <% end %>
28
+ </div>
29
+ <% else %>
30
+ <%= form.text_field :events, name: "subscription[events]", value: subscription.events.join(", "), class: "rwo-input", placeholder: "order.created, order.updated" %>
31
+ <% end %>
32
+ </div>
33
+
34
+ <div class="rwo-field">
35
+ <%= form.label :description %>
36
+ <%= form.text_field :description, class: "rwo-input" %>
37
+ </div>
38
+
39
+ <% if subscription.persisted? %>
40
+ <div class="rwo-field">
41
+ <label class="rwo-checkbox-label">
42
+ <%= form.check_box :active %>
43
+ Active
44
+ </label>
45
+ </div>
46
+ <% end %>
47
+
48
+ <%= form.submit subscription.persisted? ? "Update Subscription" : "Create Subscription", class: "rwo-btn" %>
49
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <%= link_to "← Back", subscription_path(@subscription), class: "rwo-back" %>
2
+
3
+ <div class="rwo-header">
4
+ <h1>Edit Subscription</h1>
5
+ </div>
6
+
7
+ <%= render "form", subscription: @subscription %>
@@ -0,0 +1,37 @@
1
+ <div class="rwo-header rwo-header-row">
2
+ <h1>Subscriptions</h1>
3
+ <%= link_to "New Subscription", new_subscription_path, class: "rwo-btn" %>
4
+ </div>
5
+
6
+ <% if @subscriptions.empty? %>
7
+ <p class="rwo-empty">No subscriptions yet.</p>
8
+ <% else %>
9
+ <table class="rwo-table">
10
+ <thead>
11
+ <tr>
12
+ <th>URL</th>
13
+ <th>Events</th>
14
+ <th>Status</th>
15
+ <th>Failures</th>
16
+ <th></th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <% @subscriptions.each do |subscription| %>
21
+ <tr>
22
+ <td><%= link_to subscription.url, subscription_path(subscription), class: "rwo-path" %></td>
23
+ <td class="rwo-muted"><%= subscription.events.join(", ") %></td>
24
+ <td><%= active_badge(subscription.active?) %></td>
25
+ <td class="rwo-muted"><%= subscription.consecutive_failures %></td>
26
+ <td>
27
+ <%= button_to(subscription.active? ? "Disable" : "Enable",
28
+ subscription_path(subscription),
29
+ method: :patch,
30
+ params: { subscription: { active: !subscription.active? } },
31
+ class: "rwo-btn-small") %>
32
+ </td>
33
+ </tr>
34
+ <% end %>
35
+ </tbody>
36
+ </table>
37
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <%= link_to "← All Subscriptions", subscriptions_path, class: "rwo-back" %>
2
+
3
+ <div class="rwo-header">
4
+ <h1>New Subscription</h1>
5
+ </div>
6
+
7
+ <%= render "form", subscription: @subscription %>
@@ -0,0 +1,49 @@
1
+ <%= link_to "← All Subscriptions", subscriptions_path, class: "rwo-back" %>
2
+
3
+ <div class="rwo-header rwo-header-row">
4
+ <h1><%= @subscription.url %></h1>
5
+ <%= link_to "Edit", edit_subscription_path(@subscription), class: "rwo-btn" %>
6
+ </div>
7
+
8
+ <% if params[:rotation_error] %>
9
+ <p class="rwo-alert">Secret rotation already in progress — the previous secret is still within its grace period.</p>
10
+ <% end %>
11
+
12
+ <div class="rwo-detail">
13
+ <div class="rwo-detail-grid">
14
+ <div class="rwo-detail-item">
15
+ <div class="rwo-detail-label">Status</div>
16
+ <div class="rwo-detail-value"><%= active_badge(@subscription.active?) %></div>
17
+ </div>
18
+ <div class="rwo-detail-item">
19
+ <div class="rwo-detail-label">Events</div>
20
+ <div class="rwo-detail-value"><%= @subscription.events.join(", ") %></div>
21
+ </div>
22
+ <div class="rwo-detail-item">
23
+ <div class="rwo-detail-label">Consecutive Failures</div>
24
+ <div class="rwo-detail-value"><%= @subscription.consecutive_failures %></div>
25
+ </div>
26
+ <div class="rwo-detail-item">
27
+ <div class="rwo-detail-label">Description</div>
28
+ <div class="rwo-detail-value"><%= @subscription.description.presence || "—" %></div>
29
+ </div>
30
+ </div>
31
+ </div>
32
+
33
+ <h2 class="rwo-section-title">Signing Secret</h2>
34
+
35
+ <div class="rwo-secret" data-controller="secret" data-secret-value-value="<%= @subscription.secret %>">
36
+ <span class="rwo-secret-display rwo-monospace" data-secret-target="display"><%= masked_secret(@subscription.secret) %></span>
37
+ <button type="button" class="rwo-btn-small" data-action="click->secret#reveal" data-secret-target="revealButton">Reveal</button>
38
+ <button type="button" class="rwo-btn-small" data-action="click->secret#copy" data-secret-target="copyButton">Copy</button>
39
+ <span class="rwo-muted" hidden data-secret-target="copiedHint">Copied!</span>
40
+ </div>
41
+
42
+ <% if @subscription.previous_secret_active? %>
43
+ <p class="rwo-muted">Previous secret valid until <%= @subscription.previous_secret_expires_at.strftime("%Y-%m-%d %H:%M:%S") %>.</p>
44
+ <% end %>
45
+
46
+ <%= button_to "Rotate Secret", rotate_secret_subscription_path(@subscription),
47
+ method: :patch,
48
+ class: "rwo-btn-danger",
49
+ form: { data: { turbo_confirm: "Rotate the signing secret? Subscribers must be updated within the grace period." } } %>
@@ -0,0 +1,6 @@
1
+ pin "@hotwired/turbo", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.23/dist/turbo.es2017-esm.js"
2
+ pin "@hotwired/stimulus", to: "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js"
3
+ pin "rails_webhook_outbox", to: "rails_webhook_outbox/application.js"
4
+ pin "rails_webhook_outbox/controllers", to: "rails_webhook_outbox/controllers/index.js"
5
+ pin "rails_webhook_outbox/controllers/application", to: "rails_webhook_outbox/controllers/application.js"
6
+ pin "rails_webhook_outbox/controllers/secret_controller", to: "rails_webhook_outbox/controllers/secret_controller.js"
data/config/routes.rb CHANGED
@@ -1,2 +1,19 @@
1
1
  RailsWebhookOutbox::Engine.routes.draw do
2
+ root to: "overview#show"
3
+
4
+ # Explicit :only keeps new/edit routes on Rails 7.2 in api_only host apps,
5
+ # where resources would otherwise omit them.
6
+ resources :subscriptions, only: [:index, :show, :new, :create, :edit, :update, :destroy] do
7
+ member do
8
+ patch :rotate_secret
9
+ end
10
+ end
11
+
12
+ resources :deliveries, only: [:index, :show] do
13
+ member do
14
+ post :retry
15
+ end
16
+ end
17
+
18
+ resources :events, only: [:index]
2
19
  end
@@ -1,6 +1,6 @@
1
1
  class CreateWebhookOutboxSubscriptions < ActiveRecord::Migration[7.2]
2
2
  def change
3
- create_table :webhook_outbox_subscriptions do |t|
3
+ create_table :webhook_outbox_subscriptions, if_not_exists: true do |t|
4
4
  t.string :url, null: false
5
5
  t.string :secret, null: false
6
6
  t.json :events, null: false, default: []
@@ -1,6 +1,6 @@
1
1
  class CreateWebhookOutboxDeliveries < ActiveRecord::Migration[7.2]
2
2
  def change
3
- create_table :webhook_outbox_deliveries do |t|
3
+ create_table :webhook_outbox_deliveries, if_not_exists: true do |t|
4
4
  t.references :subscription, null: false, foreign_key: { to_table: :webhook_outbox_subscriptions }
5
5
  t.string :event, null: false
6
6
  t.json :payload, null: false
@@ -13,8 +13,8 @@ class CreateWebhookOutboxDeliveries < ActiveRecord::Migration[7.2]
13
13
  t.timestamps
14
14
  end
15
15
 
16
- add_index :webhook_outbox_deliveries, :status
17
- add_index :webhook_outbox_deliveries, :event
18
- add_index :webhook_outbox_deliveries, [:subscription_id, :created_at]
16
+ add_index :webhook_outbox_deliveries, :status, if_not_exists: true
17
+ add_index :webhook_outbox_deliveries, :event, if_not_exists: true
18
+ add_index :webhook_outbox_deliveries, [:subscription_id, :created_at], if_not_exists: true
19
19
  end
20
20
  end
@@ -1,6 +1,6 @@
1
1
  class AddIdempotencyKeyToWebhookOutboxDeliveries < ActiveRecord::Migration[7.2]
2
2
  def change
3
- add_column :webhook_outbox_deliveries, :idempotency_key, :string
4
- add_index :webhook_outbox_deliveries, :idempotency_key, unique: true
3
+ add_column :webhook_outbox_deliveries, :idempotency_key, :string, if_not_exists: true
4
+ add_index :webhook_outbox_deliveries, :idempotency_key, unique: true, if_not_exists: true
5
5
  end
6
6
  end
@@ -0,0 +1,6 @@
1
+ class AddSecretRotationToWebhookOutboxSubscriptions < ActiveRecord::Migration[7.2]
2
+ def change
3
+ add_column :webhook_outbox_subscriptions, :previous_secret, :string, if_not_exists: true
4
+ add_column :webhook_outbox_subscriptions, :previous_secret_expires_at, :datetime, if_not_exists: true
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ class AddConsecutiveFailuresToWebhookOutboxSubscriptions < ActiveRecord::Migration[7.2]
2
+ def change
3
+ add_column :webhook_outbox_subscriptions, :consecutive_failures, :integer, null: false, default: 0, if_not_exists: true
4
+ end
5
+ end
@@ -0,0 +1,16 @@
1
+ class CreateWebhookOutboxDeliveryAttempts < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :webhook_outbox_delivery_attempts, if_not_exists: true do |t|
4
+ t.references :delivery, null: false, index: false,
5
+ foreign_key: { to_table: :webhook_outbox_deliveries, on_delete: :cascade }
6
+ t.integer :attempt_number, null: false
7
+ t.integer :outcome, null: false
8
+ t.integer :response_code
9
+ t.text :response_body
10
+ t.integer :duration_ms
11
+ t.datetime :created_at, null: false
12
+ end
13
+
14
+ add_index :webhook_outbox_delivery_attempts, [:delivery_id, :attempt_number], if_not_exists: true
15
+ end
16
+ end
@@ -1,29 +1,11 @@
1
1
  require "rails/generators"
2
- require "rails/generators/active_record"
3
2
 
4
3
  module RailsWebhookOutbox
5
4
  module Generators
6
5
  class InstallGenerator < Rails::Generators::Base
7
- include Rails::Generators::Migration
8
-
9
6
  source_root File.expand_path("templates", __dir__)
10
7
 
11
- desc "Creates a RailsWebhookOutbox initializer and copies migrations to your application."
12
-
13
- def self.next_migration_number(dirname)
14
- ActiveRecord::Generators::Base.next_migration_number(dirname)
15
- end
16
-
17
- def copy_migrations
18
- migration_template(
19
- "create_webhook_outbox_subscriptions.rb.tt",
20
- "db/migrate/create_webhook_outbox_subscriptions.rb"
21
- )
22
- migration_template(
23
- "create_webhook_outbox_deliveries.rb.tt",
24
- "db/migrate/create_webhook_outbox_deliveries.rb"
25
- )
26
- end
8
+ desc "Creates a RailsWebhookOutbox initializer. The engine supplies its own migrations; run `rails db:migrate` afterwards."
27
9
 
28
10
  def create_initializer
29
11
  template "initializer.rb", "config/initializers/rails_webhook_outbox.rb"
@@ -5,7 +5,9 @@ module RailsWebhookOutbox
5
5
 
6
6
  attr_accessor :events, :signing_algorithm, :signing_header,
7
7
  :max_retries, :retry_backoff, :request_timeout,
8
- :delivery_job_queue, :max_payload_size, :test_mode
8
+ :delivery_job_queue, :max_payload_size, :test_mode,
9
+ :secret_rotation_grace_period, :circuit_breaker_threshold,
10
+ :dashboard_enabled
9
11
 
10
12
  def initialize
11
13
  @events = []
@@ -17,6 +19,9 @@ module RailsWebhookOutbox
17
19
  @delivery_job_queue = :webhooks
18
20
  @max_payload_size = 65_536
19
21
  @test_mode = false
22
+ @secret_rotation_grace_period = 24.hours
23
+ @circuit_breaker_threshold = 10
24
+ @dashboard_enabled = Rails.env.development?
20
25
  end
21
26
 
22
27
  def signing_algorithm=(value)
@@ -36,5 +41,13 @@ module RailsWebhookOutbox
36
41
 
37
42
  @retry_backoff = value
38
43
  end
44
+
45
+ def secret_rotation_grace_period=(value)
46
+ unless value.is_a?(Numeric) && value.positive?
47
+ raise ArgumentError, "secret_rotation_grace_period must be a positive duration"
48
+ end
49
+
50
+ @secret_rotation_grace_period = value
51
+ end
39
52
  end
40
53
  end
@@ -1,3 +1,6 @@
1
+ require "turbo-rails"
2
+ require "importmap-rails"
3
+
1
4
  module RailsWebhookOutbox
2
5
  class Engine < ::Rails::Engine
3
6
  isolate_namespace RailsWebhookOutbox
@@ -10,5 +13,18 @@ module RailsWebhookOutbox
10
13
  end
11
14
  end
12
15
  end
16
+
17
+ initializer "rails_webhook_outbox.assets" do |app|
18
+ if app.config.respond_to?(:assets)
19
+ app.config.assets.paths << RailsWebhookOutbox::Engine.root.join("app/javascript")
20
+ end
21
+ end
22
+
23
+ initializer "rails_webhook_outbox.importmap", before: "importmap" do |app|
24
+ if app.config.respond_to?(:importmap)
25
+ app.config.importmap.paths << RailsWebhookOutbox::Engine.root.join("config/importmap.rb")
26
+ app.config.importmap.cache_sweepers << RailsWebhookOutbox::Engine.root.join("app/javascript")
27
+ end
28
+ end
13
29
  end
14
30
  end
@@ -0,0 +1,8 @@
1
+ module RailsWebhookOutbox
2
+ class SecretRotationError < StandardError
3
+ def initialize
4
+ super("Cannot rotate secret: the previous secret is still within its grace period. " \
5
+ "Pass force: true to rotate anyway and immediately invalidate it.")
6
+ end
7
+ end
8
+ end
@@ -17,6 +17,7 @@ module RailsWebhookOutbox
17
17
  uri = URI.parse(@delivery.subscription.url)
18
18
  body = build_body
19
19
  request = build_request(uri, body)
20
+ Rails.logger.info { "[RailsWebhookOutbox] attempt event=#{@delivery.event} key=#{@delivery.idempotency_key} url=#{uri}" }
20
21
  response = execute(uri, request)
21
22
  raise DeliveryError.new(response) unless response.is_a?(Net::HTTPSuccess)
22
23
  response
@@ -35,7 +36,7 @@ module RailsWebhookOutbox
35
36
  def build_request(uri, body)
36
37
  req = Net::HTTP::Post.new(uri)
37
38
  req["Content-Type"] = "application/json"
38
- req["X-Webhook-Signature"] = Signature.header_value(body, @delivery.subscription.secret)
39
+ req["X-Webhook-Signature"] = Signature.header_value(body, @delivery.subscription.signing_secrets)
39
40
  req["X-Webhook-Event"] = @delivery.event
40
41
  req["X-Webhook-Delivery"] = @delivery.idempotency_key
41
42
  req["X-Webhook-Timestamp"] = Time.now.utc.to_i.to_s
@@ -6,9 +6,9 @@ module RailsWebhookOutbox
6
6
  OpenSSL::HMAC.hexdigest(algorithm.to_s.upcase, secret, payload)
7
7
  end
8
8
 
9
- def self.header_value(payload, secret)
9
+ def self.header_value(payload, secrets)
10
10
  algorithm = RailsWebhookOutbox.config.signing_algorithm
11
- "#{algorithm}=#{sign(payload, secret, algorithm)}"
11
+ Array(secrets).map { |secret| "#{algorithm}=#{sign(payload, secret, algorithm)}" }.join(",")
12
12
  end
13
13
  end
14
14
  end
@@ -1,3 +1,3 @@
1
1
  module RailsWebhookOutbox
2
- VERSION = "0.2.0"
2
+ VERSION = "0.4.0"
3
3
  end
@@ -3,6 +3,7 @@ require "rails_webhook_outbox/configuration"
3
3
  require "rails_webhook_outbox/signature"
4
4
  require "rails_webhook_outbox/delivery_error"
5
5
  require "rails_webhook_outbox/payload_size_error"
6
+ require "rails_webhook_outbox/secret_rotation_error"
6
7
  require "rails_webhook_outbox/testing"
7
8
  require "rails_webhook_outbox/sender"
8
9
  require "rails_webhook_outbox/dispatchable"
@@ -1,4 +1,51 @@
1
- # desc "Explaining what the task does"
2
- # task :rails_webhook_outbox do
3
- # # Task goes here
4
- # end
1
+ namespace :webhook_outbox do
2
+ desc "Re-enqueue failed deliveries for retry"
3
+ task retry_failed: :environment do
4
+ deliveries = RailsWebhookOutbox::Delivery.failed
5
+ count = deliveries.count
6
+
7
+ if count.zero?
8
+ puts "No failed deliveries to retry"
9
+ else
10
+ deliveries.find_each do |delivery|
11
+ delivery.update!(status: :pending, next_retry_at: nil)
12
+ RailsWebhookOutbox::DeliveryJob.perform_later(delivery)
13
+ end
14
+ puts "Re-enqueued #{count} failed #{"delivery".pluralize(count)} for retry"
15
+ end
16
+ end
17
+
18
+ desc "List all webhook subscriptions"
19
+ task list_subscriptions: :environment do
20
+ subscriptions = RailsWebhookOutbox::Subscription.order(:id)
21
+
22
+ if subscriptions.none?
23
+ puts "No subscriptions found"
24
+ else
25
+ subscriptions.find_each do |subscription|
26
+ puts format(
27
+ "#%d [%s] %s events=%s failures=%d",
28
+ subscription.id,
29
+ subscription.active? ? "active" : "inactive",
30
+ subscription.url,
31
+ subscription.events.join(","),
32
+ subscription.consecutive_failures
33
+ )
34
+ end
35
+ end
36
+ end
37
+
38
+ desc "Delete delivered and failed deliveries older than the given number of days"
39
+ task :cleanup, [:days] => :environment do |_task, args|
40
+ days = args[:days].to_i
41
+
42
+ if days <= 0
43
+ puts "Usage: rake webhook_outbox:cleanup[days] (days must be a positive integer)"
44
+ else
45
+ scope = RailsWebhookOutbox::Delivery.where(status: [:delivered, :failed]).where(created_at: ...days.days.ago)
46
+ count = scope.count
47
+ scope.delete_all
48
+ puts "Deleted #{count} #{"delivery".pluralize(count)} older than #{days} #{"day".pluralize(days)}"
49
+ end
50
+ end
51
+ end