Class: Angarium::Endpoint

Inherits:
ApplicationRecord show all
Defined in:
app/models/angarium/endpoint.rb

Constant Summary collapse

RESERVED_HEADERS =

Headers a user-supplied custom header may never set: the Standard Webhooks signature headers (which must not be spoofable) and transport/hop-by-hop headers whose override invites request smuggling or receiver confusion.

%w[
  webhook-id webhook-timestamp webhook-signature
  host content-length content-type transfer-encoding connection
].freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.generate_signing_secretObject



48
49
50
# File 'app/models/angarium/endpoint.rb', line 48

def self.generate_signing_secret
  "whsec_#{Base64.strict_encode64(SecureRandom.bytes(32))}"
end

Instance Method Details

#active_signing_secretsObject

Secrets a receiver may currently hold: always the active secret, plus the previous one while it's still within the rotation grace window. Deliveries sign with every returned secret so receivers can roll over with zero downtime.



60
61
62
63
64
65
66
67
# File 'app/models/angarium/endpoint.rb', line 60

def active_signing_secrets
  secrets = [signing_secret]
  if previous_signing_secret.present? && secret_rotated_at.present? &&
      secret_rotated_at > Angarium.config.signing_secret_grace_period.ago
    secrets << previous_signing_secret
  end
  secrets
end

#deactivate!(reason:) ⇒ Object

Move to a non-delivering state (no further deliveries) and fire the on_endpoint_deactivated callback once. reason maps to the target status:

:consecutive_failures -> disabled (auto-disable, only from `enabled`)
:gone                 -> gone     (receiver returned HTTP 410; overrides
                                  any non-gone status, so a racing
                                  auto-disable can't clobber a 410)


108
109
110
111
# File 'app/models/angarium/endpoint.rb', line 108

def deactivate!(reason:)
  target, from = (reason == :gone) ? [:gone, nil] : [:disabled, :enabled]
  transition_status!(target, from: from) { Angarium.notify(:on_endpoint_deactivated, self, reason) }
end

#enable!Object

(Re-)enable deliveries and clear the failure counter. Works from any state, including gone (an explicit operator override of a receiver's 410).



120
121
122
123
124
125
126
127
# File 'app/models/angarium/endpoint.rb', line 120

def enable!
  return false unless transition_status!(:enabled, consecutive_failures: 0)
  # Resume deliveries parked while this endpoint was paused (pending with no
  # scheduled attempt). Dispatch creates none while paused, so this only
  # re-enqueues the held ones.
  deliveries.where(state: "pending", next_attempt_at: nil).find_each { |d| DeliverJob.perform_later(d.id) }
  true
end

#pause!Object

Pause deliveries manually. Resumable via #enable!.



114
115
116
# File 'app/models/angarium/endpoint.rb', line 114

def pause!
  transition_status!(:paused)
end

#ping!(payload = {message: "Angarium ping"}, force: true) ⇒ Object

Deliver a synthetic angarium.ping event to this endpoint, bypassing subscription matching (a ping is always sent). By default a ping also ignores the endpoint's status, so you can test an endpoint that is paused, disabled, or not yet enabled; pass force: false to respect the status guard instead. Returns the created Angarium::Delivery, whose after_create_commit enqueues the DeliverJob; reload it to inspect the outcome.



144
145
146
147
# File 'app/models/angarium/endpoint.rb', line 144

def ping!(payload = {message: "Angarium ping"}, force: true)
  event = Angarium::Event.create!(name: "angarium.ping", payload: payload)
  deliveries.create!(event: event) { |d| d.force_send = force }
end

#record_delivery_failure!Object



90
91
92
93
94
95
96
97
98
99
100
# File 'app/models/angarium/endpoint.rb', line 90

def record_delivery_failure!
  # Atomic increment. Without it, concurrent deliveries to the same endpoint
  # each read a stale counter and lose increments (last write wins), so
  # auto-disable would undercount. Reload to read the true post-increment
  # count before deciding whether to disable.
  self.class.update_counters(id, consecutive_failures: 1)
  reload

  threshold = Angarium.config.auto_disable_endpoint_after
  deactivate!(reason: :consecutive_failures) if threshold && consecutive_failures >= threshold
end

#record_delivery_success!Object



82
83
84
85
86
87
88
# File 'app/models/angarium/endpoint.rb', line 82

def record_delivery_success!
  # Reset from the database, not this (possibly stale) in-memory copy: a
  # concurrent failed delivery may have raised the counter since we loaded.
  # The WHERE skips a write when it is already zero.
  self.class.where(id: id).where.not(consecutive_failures: 0).update_all(consecutive_failures: 0)
  self.consecutive_failures = 0
end

#rotate_secret!Object

Rotate the signing secret and persist. Returns the new plaintext secret. The previous secret stays valid for config.signing_secret_grace_period (deliveries are signed with both during that window), so receivers can roll over to the new secret with zero downtime.



73
74
75
76
77
78
79
80
# File 'app/models/angarium/endpoint.rb', line 73

def rotate_secret!
  update!(
    previous_signing_secret: signing_secret,
    signing_secret: self.class.generate_signing_secret,
    secret_rotated_at: Time.current
  )
  signing_secret
end

#subscribed_to?(event_name) ⇒ Boolean

Returns:

  • (Boolean)


52
53
54
# File 'app/models/angarium/endpoint.rb', line 52

def subscribed_to?(event_name)
  Array(subscribed_events).any? { |pattern| EventMatcher.match?(pattern, event_name) }
end

#verify!Object

Promote an unverified endpoint to enabled once it has proven it can receive webhooks. A no-op on any other status: a disabled/gone endpoint is revived with #enable!, not verified. Called automatically when a delivery to an unverified endpoint succeeds (a forced #ping!), or manually. Fires on_endpoint_verified exactly once, even under concurrent verification.



134
135
136
# File 'app/models/angarium/endpoint.rb', line 134

def verify!
  transition_status!(:enabled, from: :unverified) { Angarium.notify(:on_endpoint_verified, self) }
end