relaygrid 0.1.1 → 0.2.1
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 +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +65 -6
- data/lib/relaygrid/client.rb +4 -0
- data/lib/relaygrid/delivery.rb +14 -5
- data/lib/relaygrid/errors.rb +7 -0
- data/lib/relaygrid/event.rb +81 -0
- data/lib/relaygrid/resources/deliveries.rb +4 -6
- data/lib/relaygrid/resources/webhook_endpoints.rb +108 -0
- data/lib/relaygrid/send_result.rb +2 -6
- data/lib/relaygrid/version.rb +1 -1
- data/lib/relaygrid/webhooks.rb +130 -0
- data/lib/relaygrid.rb +3 -0
- metadata +8 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dedae84912a79662c84820b7ae21ebc92ccba26dbc91f55efb1ab162978d7daf
|
|
4
|
+
data.tar.gz: 933028194cb05807da3598230e3fe8af928206e943ffa06179f86880996b95ea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b92a9ceba38031319514cd53472d938b9c3008b9e111c7c7549024ebdc7e8ec21843e4c183b36b9737f1ae2e2d7bbfb84cfcacb6d142c636ffb4ce40911c5bb4
|
|
7
|
+
data.tar.gz: 9433b073cf7047267b67d9c50a9ad5996ec7b205a9a3db89f4a0bbcb825750e165d8cc7640f6474cbea27efc1667a157f6360a4a1b4eb507b5a14d02b4c84e80
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.1] - 2026-08-21
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Gemspec metadata now points at the self-hosted GitLab repository
|
|
15
|
+
(`git.relaygrid.dev`): the homepage, source, and changelog URIs no longer
|
|
16
|
+
reference the old GitHub location. No code changes.
|
|
17
|
+
|
|
18
|
+
## [0.2.0] - 2026-08-06
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Inbound delivery-status webhooks. `RelayGrid::Webhooks.construct_event(body,
|
|
23
|
+
signature_header, secret)` verifies the `X-RelayGrid-Signature` HMAC against
|
|
24
|
+
the raw request body and returns a `RelayGrid::Event`; `verify_signature`
|
|
25
|
+
does the same check without building the event. The signed timestamp is
|
|
26
|
+
checked against a five-minute replay window by default (`tolerance:`), and
|
|
27
|
+
several `v1=` signatures in one header are accepted so a secret can be
|
|
28
|
+
rotated without dropping events.
|
|
29
|
+
- `RelayGrid::Event` — immutable, with `id` (stable across retries, so it is
|
|
30
|
+
what you deduplicate on), `type`, `created_at`, `data`, `raw`, and a
|
|
31
|
+
`delivery` in exactly the shape `deliveries.get` returns, so webhook-driven
|
|
32
|
+
and polling handlers can be shared. Events are at-least-once and unordered;
|
|
33
|
+
branch on `event.delivery.status`, not on arrival order.
|
|
34
|
+
- `RelayGrid::SignatureVerificationError`, raised for a bad signature, a
|
|
35
|
+
missing or malformed header, a timestamp outside the tolerance, or a body
|
|
36
|
+
that isn't JSON. Descends from `RelayGrid::Error` but deliberately not from
|
|
37
|
+
`APIError` — nothing failed on the wire.
|
|
38
|
+
- `client.webhook_endpoints` to manage the account's one endpoint: `list`,
|
|
39
|
+
`current`, `get`, `create`, `update`, `delete`, `rotate_secret`,
|
|
40
|
+
`reactivate`, `test`, and `attempts` for the dispatch log. The plaintext
|
|
41
|
+
signing secret is returned by `create` and `rotate_secret` only.
|
|
42
|
+
- `Delivery#retrying?` and `Delivery#terminal?`, plus the `retrying` status
|
|
43
|
+
constant and `TERMINAL_STATUSES`.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- **`wait_for_deliveries` now returns as soon as every delivery is terminal,
|
|
48
|
+
successful or not.** The server reports an in-flight retry as `retrying`, so
|
|
49
|
+
`failed` finally means final — 0.1.x kept polling through a `failed` row and
|
|
50
|
+
raised `TimeoutWaitingForDeliveries` on a genuinely failed delivery. Code
|
|
51
|
+
that treated the return value as "everything succeeded" must now check
|
|
52
|
+
`settled.all?(&:success?)` itself.
|
|
53
|
+
|
|
10
54
|
## [0.1.1] - 2026-07-30
|
|
11
55
|
|
|
12
56
|
### 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?`, `
|
|
153
|
-
plus `success?` (delivered or opened)
|
|
154
|
-
`Delivery` — the
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
data/lib/relaygrid/client.rb
CHANGED
|
@@ -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)
|
data/lib/relaygrid/delivery.rb
CHANGED
|
@@ -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
|
data/lib/relaygrid/errors.rb
CHANGED
|
@@ -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
|
-
#
|
|
48
|
-
# still
|
|
49
|
-
#
|
|
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?(&:
|
|
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
|
|
47
|
-
#
|
|
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
|
data/lib/relaygrid/version.rb
CHANGED
|
@@ -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.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ali Zand
|
|
@@ -140,23 +140,26 @@ 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
|
-
homepage: https://
|
|
156
|
+
homepage: https://git.relaygrid.dev/relaygrid/relaygrid-gem
|
|
154
157
|
licenses:
|
|
155
158
|
- MIT
|
|
156
159
|
metadata:
|
|
157
|
-
homepage_uri: https://
|
|
158
|
-
source_code_uri: https://
|
|
159
|
-
changelog_uri: https://
|
|
160
|
+
homepage_uri: https://git.relaygrid.dev/relaygrid/relaygrid-gem
|
|
161
|
+
source_code_uri: https://git.relaygrid.dev/relaygrid/relaygrid-gem
|
|
162
|
+
changelog_uri: https://git.relaygrid.dev/relaygrid/relaygrid-gem/-/blob/master/CHANGELOG.md
|
|
160
163
|
rubygems_mfa_required: 'true'
|
|
161
164
|
rdoc_options: []
|
|
162
165
|
require_paths:
|