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



56
57
58
# File 'app/models/angarium/endpoint.rb', line 56

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.



68
69
70
71
72
73
74
75
# File 'app/models/angarium/endpoint.rb', line 68

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)


116
117
118
119
# File 'app/models/angarium/endpoint.rb', line 116

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).



128
129
130
131
132
133
134
135
136
137
# File 'app/models/angarium/endpoint.rb', line 128

def enable!
  return false unless transition_status!(:enabled, consecutive_failures: 0)
  # Resume deliveries parked while this endpoint was paused (pending with no
  # scheduled attempt). This predicate can also match a delivery that already
  # has an in-flight job (a forced ping, a just-redelivered row), but a stray
  # re-enqueue is harmless: the atomic pending->delivering claim in
  # Delivery#deliver! guarantees only one worker ever sends a given delivery.
  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!.



122
123
124
# File 'app/models/angarium/endpoint.rb', line 122

def pause!
  transition_status!(:paused)
end

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

Deliver a synthetic ping event (name from config.ping_event_name, default "ping") 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.



154
155
156
157
# File 'app/models/angarium/endpoint.rb', line 154

def ping!(payload = {message: "ping"}, force: true)
  event = Angarium::Event.create!(name: Angarium.config.ping_event_name, payload: payload)
  deliveries.create!(event: event, forced: force)
end

#record_delivery_failure!Object



98
99
100
101
102
103
104
105
106
107
108
# File 'app/models/angarium/endpoint.rb', line 98

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



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

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.



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

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)


60
61
62
# File 'app/models/angarium/endpoint.rb', line 60

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.



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

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