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



50
51
52
# File 'app/models/angarium/endpoint.rb', line 50

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.



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

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)


110
111
112
113
# File 'app/models/angarium/endpoint.rb', line 110

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



122
123
124
125
126
127
128
129
130
131
# File 'app/models/angarium/endpoint.rb', line 122

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



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

def pause!
  transition_status!(:paused)
end

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

Deliver a synthetic 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.



148
149
150
151
# File 'app/models/angarium/endpoint.rb', line 148

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

#record_delivery_failure!Object



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

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



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

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.



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

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)


54
55
56
# File 'app/models/angarium/endpoint.rb', line 54

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.



138
139
140
# File 'app/models/angarium/endpoint.rb', line 138

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