relaygrid 0.1.1 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d0cc0707a44f80cc25e92aad237601e2fc3ec3f8ea2bd455facb68180843196
4
- data.tar.gz: 237cd1b6377a7b4a8fc3ba57287da93990620efc3c6971af67277defcd50b886
3
+ metadata.gz: '08c77a1f6d4cf7d515c07899a1b7ec8e30a590921fff71bf0261cc2caa58fc86'
4
+ data.tar.gz: 0a4d7357d29d5fada986ac8bc2cf67b6a87c833a410f47a1d15590665bd06d4b
5
5
  SHA512:
6
- metadata.gz: 289569fcadc3c5d298ea4450a0ee00c33e21db743725ef9116aff25206b5c5fcf10ae3564434fb6a8a08af8b31c138e67a9cf79e64debcbef9d5616ba51e3672
7
- data.tar.gz: 30de0ca4b93b88a2740787d1ce97eba72bf17807fe9af59eb2df99041909a6d86916a76f64debc45a14af950c38dfb70627b9048898d06c410ec4580d7e4c37a
6
+ metadata.gz: fcc099fa99bc3f2312b16308b61ed94731284533fac178506a49dec418ee84bbdf118984f49f003c6076c931deb8eb2675c22f4d55e1c2966e58283125c19ef4
7
+ data.tar.gz: 9376cfbafd25399ecb4bc2aa8091bdbf95712d22d6713fca2db039823ddaedc790d111b1752b9bf360a4b9613a02cfe7c46df0ef03ce5178f15fe8239829b9e6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-08-06
11
+
12
+ ### Added
13
+
14
+ - Inbound delivery-status webhooks. `RelayGrid::Webhooks.construct_event(body,
15
+ signature_header, secret)` verifies the `X-RelayGrid-Signature` HMAC against
16
+ the raw request body and returns a `RelayGrid::Event`; `verify_signature`
17
+ does the same check without building the event. The signed timestamp is
18
+ checked against a five-minute replay window by default (`tolerance:`), and
19
+ several `v1=` signatures in one header are accepted so a secret can be
20
+ rotated without dropping events.
21
+ - `RelayGrid::Event` — immutable, with `id` (stable across retries, so it is
22
+ what you deduplicate on), `type`, `created_at`, `data`, `raw`, and a
23
+ `delivery` in exactly the shape `deliveries.get` returns, so webhook-driven
24
+ and polling handlers can be shared. Events are at-least-once and unordered;
25
+ branch on `event.delivery.status`, not on arrival order.
26
+ - `RelayGrid::SignatureVerificationError`, raised for a bad signature, a
27
+ missing or malformed header, a timestamp outside the tolerance, or a body
28
+ that isn't JSON. Descends from `RelayGrid::Error` but deliberately not from
29
+ `APIError` — nothing failed on the wire.
30
+ - `client.webhook_endpoints` to manage the account's one endpoint: `list`,
31
+ `current`, `get`, `create`, `update`, `delete`, `rotate_secret`,
32
+ `reactivate`, `test`, and `attempts` for the dispatch log. The plaintext
33
+ signing secret is returned by `create` and `rotate_secret` only.
34
+ - `Delivery#retrying?` and `Delivery#terminal?`, plus the `retrying` status
35
+ constant and `TERMINAL_STATUSES`.
36
+
37
+ ### Changed
38
+
39
+ - **`wait_for_deliveries` now returns as soon as every delivery is terminal,
40
+ successful or not.** The server reports an in-flight retry as `retrying`, so
41
+ `failed` finally means final — 0.1.x kept polling through a `failed` row and
42
+ raised `TimeoutWaitingForDeliveries` on a genuinely failed delivery. Code
43
+ that treated the return value as "everything succeeded" must now check
44
+ `settled.all?(&:success?)` itself.
45
+
10
46
  ## [0.1.1] - 2026-07-30
11
47
 
12
48
  ### Removed
data/README.md CHANGED
@@ -149,9 +149,10 @@ delivery.friendly_error_message # => nil, or human-readable guidance when failed
149
149
  RelayGrid.client.deliveries.get_all([201, 202]) # one batched request
150
150
  ```
151
151
 
152
- Predicates: `queued?`, `sent?`, `delivered?`, `failed?`, `bounced?`, `opened?`,
153
- plus `success?` (delivered or opened). `#refresh` re-fetches and returns a new
154
- `Delivery` — the objects are immutable.
152
+ Predicates: `queued?`, `sent?`, `delivered?`, `retrying?`, `failed?`, `bounced?`,
153
+ `opened?`, plus `success?` (delivered or opened) and `terminal?` (the server will
154
+ not move it on its own). `#refresh` re-fetches and returns a new `Delivery` — the
155
+ objects are immutable.
155
156
 
156
157
  ### Waiting for deliveries to settle
157
158
 
@@ -160,14 +161,72 @@ settled = result.wait_for_deliveries(timeout: 30, interval: 2)
160
161
  settled.all?(&:success?) # => true
161
162
  ```
162
163
 
164
+ It returns once every delivery is **terminal** — successful or not — so check
165
+ `success?` yourself rather than treating a return as a win. A delivery the server
166
+ is still retrying reads `retrying`, which is why `failed` means final.
167
+
163
168
  > **This blocks the calling thread.** Use it in a background job, a rake task, or
164
169
  > a script — never inside a web request, where it would pin a request thread for
165
170
  > up to `timeout` seconds. In a request, return `delivery_ids` and let the
166
171
  > browser poll, or subscribe over the websocket.
167
172
 
168
- `failed` is deliberately **not** treated as final inside the window: delivery
169
- jobs retry, so a failed delivery can still flip to `sent` and then `delivered`.
170
- The helper waits rather than reporting a transient failure as permanent.
173
+ ## Webhooks
174
+
175
+ Registering an endpoint is what replaces polling for delivery status: RelayGrid
176
+ posts `delivery.*` events to your app as they happen.
177
+
178
+ ```ruby
179
+ endpoint = RelayGrid.client.webhook_endpoints.create(url: "https://app.example.com/relaygrid/webhooks")
180
+ endpoint["secret"] # => "whsec_..." — returned here and by rotate_secret only, so store it now
181
+ ```
182
+
183
+ An account has one endpoint. `current` returns it (or `nil`), `update(id, url:)`
184
+ moves it, `test(id)` queues a signed `ping`, and `attempts(id)` is the dispatch
185
+ log — response codes, errors, durations — for answering "why didn't my webhook
186
+ arrive?". `rotate_secret(id)` issues a new secret and invalidates the old one
187
+ immediately, so deploy the new one to your receiver first.
188
+
189
+ ### Verifying
190
+
191
+ ```ruby
192
+ class RelayGridWebhooksController < ApplicationController
193
+ skip_before_action :verify_authenticity_token
194
+
195
+ def create
196
+ event = RelayGrid::Webhooks.construct_event(
197
+ request.raw_post,
198
+ request.headers["X-RelayGrid-Signature"],
199
+ ENV.fetch("RELAYGRID_WEBHOOK_SECRET")
200
+ )
201
+
202
+ case event.type
203
+ when "delivery.delivered" then mark_delivered(event.delivery.id)
204
+ when "delivery.failed" then alert(event.delivery.friendly_error_message)
205
+ end
206
+
207
+ head :ok
208
+ rescue RelayGrid::SignatureVerificationError
209
+ head :bad_request
210
+ end
211
+ end
212
+ ```
213
+
214
+ Verify **before** you parse — it is the raw bytes that were signed, so
215
+ re-serializing the JSON first will not verify. The HMAC proves the body came
216
+ from RelayGrid unaltered, and the timestamp signed alongside it bounds how long
217
+ a captured request stays replayable (five minutes; change it with `tolerance:`,
218
+ or pass `nil` to disable the window).
219
+
220
+ Two properties of the transport shape your handler:
221
+
222
+ - **At-least-once.** The same `event.id` can arrive twice — a receiver that
223
+ times out after doing its work still gets retried. Key side-effects off `id`.
224
+ - **Unordered.** `delivery.delivered` can arrive before `delivery.sent`. Branch
225
+ on `event.delivery.status`, which is the state at emission time, not on the
226
+ order events show up in.
227
+
228
+ `event.delivery` is a `Delivery`, in exactly the shape `deliveries.get` returns,
229
+ so webhook-driven and polling code can share handlers. It is `nil` for a `ping`.
171
230
 
172
231
  ## Receiving
173
232
 
@@ -67,6 +67,10 @@ module RelayGrid
67
67
  resource(:message_templates) { Resources::MessageTemplates.new(self) }
68
68
  end
69
69
 
70
+ def webhook_endpoints
71
+ resource(:webhook_endpoints) { Resources::WebhookEndpoints.new(self) }
72
+ end
73
+
70
74
  # Send a notification -- the one call the whole gem exists for.
71
75
  # See Resources::Notifications#notify.
72
76
  def notify(**kwargs)
@@ -11,13 +11,16 @@ module RelayGrid
11
11
  QUEUED = "queued"
12
12
  SENT = "sent"
13
13
  DELIVERED = "delivered"
14
+ RETRYING = "retrying"
14
15
  FAILED = "failed"
15
16
  BOUNCED = "bounced"
16
17
  OPENED = "opened"
17
18
 
18
- # States the server will not move away from on its own.
19
19
  SUCCESS_STATUSES = [DELIVERED, OPENED].freeze
20
20
 
21
+ # States the server will not move away from on its own.
22
+ TERMINAL_STATUSES = (SUCCESS_STATUSES + [FAILED, BOUNCED]).freeze
23
+
21
24
  attr_reader :id, :message_id, :status, :channel_id, :channel_name, :channel_type,
22
25
  :error_message, :friendly_error_message,
23
26
  :sent_at, :delivered_at, :failed_at, :opened_at, :created_at, :updated_at,
@@ -60,6 +63,10 @@ module RelayGrid
60
63
  status == DELIVERED
61
64
  end
62
65
 
66
+ def retrying?
67
+ status == RETRYING
68
+ end
69
+
63
70
  def failed?
64
71
  status == FAILED
65
72
  end
@@ -73,14 +80,16 @@ module RelayGrid
73
80
  end
74
81
 
75
82
  # Reached a state the server considers final and successful.
76
- #
77
- # `failed` is deliberately *not* terminal: delivery jobs retry, so a failed
78
- # row can still flip to `sent`. This mirrors the dashboard's polling
79
- # semantics (see docs/DELIVERY_STATUS_PLAN.md in the API repo).
80
83
  def success?
81
84
  SUCCESS_STATUSES.include?(status)
82
85
  end
83
86
 
87
+ # Reached a state the server will not move away from on its own. A delivery
88
+ # being retried is `retrying`, not `failed`, so `failed` is final.
89
+ def terminal?
90
+ TERMINAL_STATUSES.include?(status)
91
+ end
92
+
84
93
  def push?
85
94
  channel_type == "push"
86
95
  end
@@ -91,6 +91,13 @@ module RelayGrid
91
91
  # 5xx -- a fault on the RelayGrid side. Safe to retry for reads.
92
92
  class ServerError < APIError; end
93
93
 
94
+ # An inbound webhook could not be trusted: the signature didn't match, the
95
+ # header was missing or malformed, or the timestamp fell outside the replay
96
+ # tolerance. Answer these with a 400 and drop the request -- never process the
97
+ # body. Deliberately not an APIError: nothing failed on the wire, the payload
98
+ # simply isn't ours.
99
+ class SignatureVerificationError < Error; end
100
+
94
101
  # `wait_for_deliveries` gave up before every delivery reached a terminal
95
102
  # state. Carries the deliveries as last seen so the caller can inspect them.
96
103
  class TimeoutWaitingForDeliveries < Error
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module RelayGrid
6
+ # A verified delivery-status webhook, as returned by
7
+ # RelayGrid::Webhooks.construct_event.
8
+ #
9
+ # case event.type
10
+ # when "delivery.delivered" then mark_delivered(event.delivery.id)
11
+ # when "delivery.failed" then alert(event.delivery.friendly_error_message)
12
+ # end
13
+ #
14
+ # Two properties of the transport shape how you should handle these:
15
+ #
16
+ # * **At-least-once.** The same `id` can arrive twice (a receiver that times
17
+ # out after processing still gets retried). Key your side-effects off `id`.
18
+ # * **Unordered.** `delivery.delivered` can arrive before `delivery.sent`.
19
+ # Branch on `event.delivery.status`, which is the state at emission time,
20
+ # rather than on the order events show up in.
21
+ class Event
22
+ # @return [String] "evt_..." -- stable across retries, so it is what you
23
+ # deduplicate on
24
+ attr_reader :id
25
+ # @return [String] e.g. "delivery.delivered"
26
+ attr_reader :type
27
+ # @return [Time, nil] when the event was emitted
28
+ attr_reader :created_at
29
+ # @return [Hash] the raw `data` object
30
+ attr_reader :data
31
+ # @return [Hash] the whole envelope as received
32
+ attr_reader :raw
33
+
34
+ def initialize(payload)
35
+ @raw = payload
36
+ @id = payload["id"]
37
+ @type = payload["type"]
38
+ @created_at = parse_time(payload["created_at"])
39
+ @data = payload["data"] || {}
40
+ # Built up front, not memoized: the instance is frozen below, matching
41
+ # Delivery -- an Event handed to another thread never changes underneath it.
42
+ @delivery = Delivery.new(@data["delivery"]) if @data["delivery"].is_a?(Hash)
43
+
44
+ freeze
45
+ end
46
+
47
+ # The delivery this event is about, in exactly the shape
48
+ # `client.deliveries.get` returns -- the server serializes both from one
49
+ # definition, so webhook-driven and polling code can share their handlers.
50
+ #
51
+ # @return [RelayGrid::Delivery, nil] nil for events with no delivery (ping)
52
+ attr_reader :delivery
53
+
54
+ # True for the test event the dashboard's "send test event" button emits.
55
+ def ping?
56
+ type == "ping"
57
+ end
58
+
59
+ def delivery_event?
60
+ type.to_s.start_with?("delivery.")
61
+ end
62
+
63
+ def to_h
64
+ raw
65
+ end
66
+
67
+ def inspect
68
+ "#<RelayGrid::Event id: #{id.inspect}, type: #{type.inspect}>"
69
+ end
70
+
71
+ private
72
+
73
+ def parse_time(value)
74
+ return nil if value.nil? || value.to_s.empty?
75
+
76
+ Time.parse(value.to_s)
77
+ rescue ArgumentError
78
+ nil
79
+ end
80
+ end
81
+ end
@@ -44,11 +44,9 @@ module RelayGrid
44
44
  # return the delivery ids and let the browser poll `#get_all`, or subscribe
45
45
  # to the realtime channel with `RelayGrid.websocket`.
46
46
  #
47
- # `failed` is not terminal here: delivery jobs retry, so a failed row can
48
- # still flip to `sent` and then `delivered` inside the window. Waiting out
49
- # the timeout on a genuinely failed delivery is the deliberate trade -- it
50
- # mirrors the dashboard, and reporting a transient failure as final is the
51
- # worse error.
47
+ # Returns as soon as every delivery is terminal, successful or not. A
48
+ # delivery the server is still retrying reads `retrying`, so `failed`
49
+ # here means final and there is nothing left to wait for.
52
50
  #
53
51
  # @param ids [Array<Integer>]
54
52
  # @param timeout [Numeric] seconds
@@ -65,7 +63,7 @@ module RelayGrid
65
63
 
66
64
  loop do
67
65
  deliveries = get_all(ids)
68
- return deliveries if deliveries.any? && deliveries.all?(&:success?)
66
+ return deliveries if deliveries.any? && deliveries.all?(&:terminal?)
69
67
 
70
68
  # Never sleep past the deadline: the caller's timeout is honoured to
71
69
  # within one request, not rounded up to the next whole interval.
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RelayGrid
4
+ module Resources
5
+ # Manage the HTTPS endpoint RelayGrid pushes delivery-status events to.
6
+ #
7
+ # Registering one is what replaces polling: instead of calling
8
+ # `wait_for_deliveries`, your app receives `delivery.*` events as they
9
+ # happen and verifies them with RelayGrid::Webhooks.construct_event.
10
+ #
11
+ # endpoint = client.webhook_endpoints.create(url: "https://app.example.com/relaygrid/webhooks")
12
+ # endpoint["secret"] # => "whsec_..." -- returned here and on rotate only
13
+ #
14
+ # Store that secret before you drop the response: list and show return only
15
+ # a preview of it.
16
+ #
17
+ # An account has **one** endpoint. `list` is still a collection for
18
+ # forward compatibility, but it holds at most one entry, and creating a
19
+ # second is a 409 -- change the URL with #update, or #delete and re-create.
20
+ class WebhookEndpoints
21
+ PATH = "/api/v1/webhook_endpoints"
22
+
23
+ def initialize(client)
24
+ @client = client
25
+ end
26
+
27
+ # @return [Array<Hash>] at most one entry
28
+ def list
29
+ Array(@client.get(PATH)["webhook_endpoints"])
30
+ end
31
+
32
+ # The account's endpoint, or nil when none is registered.
33
+ #
34
+ # @return [Hash, nil]
35
+ def current
36
+ list.first
37
+ end
38
+
39
+ # @param id [Integer]
40
+ # @return [Hash]
41
+ # @raise [RelayGrid::NotFoundError]
42
+ def get(id)
43
+ @client.get("#{PATH}/#{id}")["webhook_endpoint"]
44
+ end
45
+
46
+ # An endpoint receives every event type, including ones added later, so
47
+ # there is nothing to subscribe to -- switch on `event.type` in your
48
+ # receiver instead.
49
+ #
50
+ # @param url [String] an https URL on a publicly resolvable host
51
+ # @return [Hash] including the plaintext "secret"
52
+ # @raise [RelayGrid::ValidationError] the URL was rejected (not https, or
53
+ # pointing at a private/loopback address)
54
+ # @raise [RelayGrid::APIError] HTTP 409 -- this account already has its
55
+ # one endpoint
56
+ def create(url:)
57
+ @client.post(PATH, body: { webhook_endpoint: { url: url } })["webhook_endpoint"]
58
+ end
59
+
60
+ # @return [Hash]
61
+ def update(id, url: nil, active: nil)
62
+ attributes = {}
63
+ attributes[:url] = url unless url.nil?
64
+ attributes[:active] = active unless active.nil?
65
+
66
+ @client.patch("#{PATH}/#{id}", body: { webhook_endpoint: attributes })["webhook_endpoint"]
67
+ end
68
+
69
+ # @return [true]
70
+ def delete(id)
71
+ @client.delete("#{PATH}/#{id}")
72
+ true
73
+ end
74
+
75
+ # Issue a new signing secret, returned in full. The previous secret stops
76
+ # working at once, so deploy the new one to your receiver first.
77
+ #
78
+ # @return [Hash] including the plaintext "secret"
79
+ def rotate_secret(id)
80
+ @client.post("#{PATH}/#{id}/rotate_secret")["webhook_endpoint"]
81
+ end
82
+
83
+ # Clear the auto-disabled state after fixing a receiver RelayGrid gave up on.
84
+ #
85
+ # @return [Hash]
86
+ def reactivate(id)
87
+ @client.post("#{PATH}/#{id}/reactivate")["webhook_endpoint"]
88
+ end
89
+
90
+ # Queue a signed `ping` so you can confirm the URL and secret work before
91
+ # real traffic depends on them. Delivery is asynchronous -- check #attempts
92
+ # for the result.
93
+ #
94
+ # @return [String] the event id that was queued
95
+ def test(id)
96
+ @client.post("#{PATH}/#{id}/test")["event_id"]
97
+ end
98
+
99
+ # Recent dispatch attempts, newest first: response codes, errors and
100
+ # durations. The "why didn't my webhook arrive?" log.
101
+ #
102
+ # @return [Array<Hash>]
103
+ def attempts(id)
104
+ Array(@client.get("#{PATH}/#{id}/attempts")["attempts"])
105
+ end
106
+ end
107
+ end
108
+ end
@@ -43,12 +43,8 @@ module RelayGrid
43
43
  deliveries.find { |delivery| delivery.channel_type == channel_type.to_s }
44
44
  end
45
45
 
46
- # Poll until every delivery settles into a success state, or `timeout`
47
- # seconds elapse.
48
- #
49
- # `failed` is not treated as terminal inside the window: delivery jobs
50
- # retry, so a failed row can still flip to `sent` and then `delivered`.
51
- # That mirrors the dashboard's semantics.
46
+ # Poll until every delivery reaches a terminal state, or `timeout` seconds
47
+ # elapse. A delivery the server is still retrying reads `retrying`.
52
48
  #
53
49
  # @param timeout [Numeric] seconds to keep polling
54
50
  # @param interval [Numeric] seconds between polls
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RelayGrid
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "json"
5
+
6
+ module RelayGrid
7
+ # Verification for delivery-status webhooks -- the piece you drop into your
8
+ # receiving controller.
9
+ #
10
+ # class RelayGridWebhooksController < ApplicationController
11
+ # skip_before_action :verify_authenticity_token
12
+ #
13
+ # def create
14
+ # event = RelayGrid::Webhooks.construct_event(
15
+ # request.raw_post,
16
+ # request.headers["X-RelayGrid-Signature"],
17
+ # ENV.fetch("RELAYGRID_WEBHOOK_SECRET")
18
+ # )
19
+ #
20
+ # MyDeliveryTracker.handle(event)
21
+ # head :ok
22
+ # rescue RelayGrid::SignatureVerificationError
23
+ # head :bad_request
24
+ # end
25
+ # end
26
+ #
27
+ # Two things are checked, and both matter. The HMAC proves the body came from
28
+ # RelayGrid and was not altered; the timestamp -- which is signed *with* the
29
+ # body, not merely sent beside it -- bounds how long a captured request stays
30
+ # replayable. Verify before you parse: it is the raw bytes that were signed,
31
+ # so re-serializing the JSON first will not verify.
32
+ module Webhooks
33
+ SIGNATURE_VERSION = "v1"
34
+ DIGEST = "SHA256"
35
+
36
+ # How far a webhook's timestamp may be from your clock. Five minutes
37
+ # matches Stripe's default: wide enough for ordinary clock skew and a slow
38
+ # retry, narrow enough that a captured request stops working quickly.
39
+ DEFAULT_TOLERANCE = 300
40
+
41
+ module_function
42
+
43
+ # @param payload [String] the raw request body, exactly as received
44
+ # @param signature_header [String] the X-RelayGrid-Signature header
45
+ # @param secret [String] the endpoint's signing secret ("whsec_...")
46
+ # @param tolerance [Integer, nil] seconds; nil disables the replay window
47
+ # @return [RelayGrid::Event]
48
+ # @raise [RelayGrid::SignatureVerificationError] bad signature, missing or
49
+ # malformed header, or a timestamp outside the tolerance
50
+ def construct_event(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE)
51
+ verify_signature(payload, signature_header, secret, tolerance: tolerance)
52
+
53
+ data = begin
54
+ JSON.parse(payload.to_s)
55
+ rescue JSON::ParserError => e
56
+ raise SignatureVerificationError, "Webhook body is not valid JSON: #{e.message}"
57
+ end
58
+
59
+ Event.new(data)
60
+ end
61
+
62
+ # Same checks as construct_event, without building the event.
63
+ #
64
+ # @return [true]
65
+ # @raise [RelayGrid::SignatureVerificationError]
66
+ def verify_signature(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE)
67
+ raise SignatureVerificationError, "Webhook signing secret is missing" if secret.nil? || secret.empty?
68
+
69
+ timestamp, signatures = parse_header(signature_header)
70
+
71
+ if tolerance
72
+ age = Time.now.to_i - timestamp
73
+ if age.abs > tolerance
74
+ raise SignatureVerificationError,
75
+ "Webhook timestamp is outside the #{tolerance}s tolerance (off by #{age}s)"
76
+ end
77
+ end
78
+
79
+ expected = signature(payload, timestamp, secret)
80
+ unless signatures.any? { |candidate| secure_compare(candidate, expected) }
81
+ raise SignatureVerificationError, "Webhook signature does not match the expected value"
82
+ end
83
+
84
+ true
85
+ end
86
+
87
+ # The MAC of "<timestamp>.<body>", which is what the sender signed.
88
+ def signature(payload, timestamp, secret)
89
+ OpenSSL::HMAC.hexdigest(DIGEST, secret.to_s, "#{timestamp}.#{payload}")
90
+ end
91
+
92
+ # "t=1753722191,v1=abc..." -> [1753722191, ["abc..."]]
93
+ #
94
+ # Several v1 entries are accepted so RelayGrid can sign with an old and a
95
+ # new secret at once during a rotation.
96
+ def parse_header(signature_header)
97
+ raise SignatureVerificationError, "Webhook signature header is missing" if signature_header.nil?
98
+
99
+ parts = signature_header.to_s.split(",").map { |part| part.split("=", 2) }
100
+ pairs = parts.select { |pair| pair.length == 2 }
101
+
102
+ timestamp = pairs.find { |name, _| name.strip == "t" }&.last
103
+ signatures = pairs.select { |name, _| name.strip == SIGNATURE_VERSION }.map(&:last)
104
+
105
+ if timestamp.nil? || signatures.empty?
106
+ raise SignatureVerificationError,
107
+ "Webhook signature header is malformed: #{signature_header.inspect}"
108
+ end
109
+
110
+ [Integer(timestamp, 10), signatures]
111
+ rescue ArgumentError, TypeError
112
+ raise SignatureVerificationError,
113
+ "Webhook signature header carries a non-numeric timestamp: #{signature_header.inspect}"
114
+ end
115
+
116
+ # Constant-time comparison: a byte-by-byte `==` returns early on the first
117
+ # mismatch, leaking through its own timing how much of a guessed signature
118
+ # was correct. Written out rather than delegated so the gem doesn't depend
119
+ # on which OpenSSL binding version the host application happens to have.
120
+ def secure_compare(left, right)
121
+ left = left.to_s.b
122
+ right = right.to_s.b
123
+ return false unless left.bytesize == right.bytesize
124
+
125
+ result = 0
126
+ left.each_byte.with_index { |byte, index| result |= byte ^ right.getbyte(index) }
127
+ result.zero?
128
+ end
129
+ end
130
+ end
data/lib/relaygrid.rb CHANGED
@@ -4,8 +4,10 @@ require_relative "relaygrid/version"
4
4
  require_relative "relaygrid/errors"
5
5
  require_relative "relaygrid/configuration"
6
6
  require_relative "relaygrid/delivery"
7
+ require_relative "relaygrid/event"
7
8
  require_relative "relaygrid/send_result"
8
9
  require_relative "relaygrid/client"
10
+ require_relative "relaygrid/webhooks"
9
11
  require_relative "relaygrid/websocket_client"
10
12
  require_relative "relaygrid/resources/notifications"
11
13
  require_relative "relaygrid/resources/deliveries"
@@ -13,6 +15,7 @@ require_relative "relaygrid/resources/messages"
13
15
  require_relative "relaygrid/resources/message_templates"
14
16
  require_relative "relaygrid/resources/users"
15
17
  require_relative "relaygrid/resources/channel_tokens"
18
+ require_relative "relaygrid/resources/webhook_endpoints"
16
19
 
17
20
  # Ruby client for the RelayGrid / RelayGrid notification API.
18
21
  #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: relaygrid
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ali Zand
@@ -140,14 +140,17 @@ files:
140
140
  - lib/relaygrid/configuration.rb
141
141
  - lib/relaygrid/delivery.rb
142
142
  - lib/relaygrid/errors.rb
143
+ - lib/relaygrid/event.rb
143
144
  - lib/relaygrid/resources/channel_tokens.rb
144
145
  - lib/relaygrid/resources/deliveries.rb
145
146
  - lib/relaygrid/resources/message_templates.rb
146
147
  - lib/relaygrid/resources/messages.rb
147
148
  - lib/relaygrid/resources/notifications.rb
148
149
  - lib/relaygrid/resources/users.rb
150
+ - lib/relaygrid/resources/webhook_endpoints.rb
149
151
  - lib/relaygrid/send_result.rb
150
152
  - lib/relaygrid/version.rb
153
+ - lib/relaygrid/webhooks.rb
151
154
  - lib/relaygrid/websocket_client.rb
152
155
  - sig/relaygrid.rbs
153
156
  homepage: https://github.com/alizand1992/relaygrid-gem