zeridion-flare 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4ac177cb1ba3e09ec4d70f0d88e50027da6e1b8dd4e0547a13dbbd3020d56889
4
+ data.tar.gz: 7763c74ddba147b9fb9d03d48b94aba0d201476d487d83b943262bfda877aa0e
5
+ SHA512:
6
+ metadata.gz: fc72570ecde04bc6f2d2715c0e277cde593d1177362383911233185da59549ad01eeee660ad487e7bff5f41999a19c7bbfb57dd0ad602a7f4ce98c0737fa50a2
7
+ data.tar.gz: 576a86cba0236c0536c20a653056fcb4b5e27e9c2c13216a4be1f7b471b7b640ed56d7527783213803bc17481877a5f3a2cffa8ce23846ab43ed4253d23b98dc
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zeridion
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,175 @@
1
+ # zeridion-flare (Ruby)
2
+
3
+ Ruby SDK for the [Zeridion Flare](https://docs.zeridion.com/flare) managed background-jobs API.
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/zeridion-flare.svg)](https://rubygems.org/gems/zeridion-flare)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ **[Full documentation at docs.zeridion.com/flare](https://docs.zeridion.com/flare)**
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ gem install zeridion-flare
14
+ ```
15
+
16
+ Or add to your `Gemfile`:
17
+
18
+ ```ruby
19
+ gem "zeridion-flare", "~> 0.2"
20
+ ```
21
+
22
+ Requires Ruby 3.2+. **Zero** runtime dependencies — uses stdlib `Net::HTTP`, `OpenSSL`, and `JSON` only. The opt-in worker runtime (`require "zeridion_flare/worker"`) ships in the same gem and adds no dependencies.
23
+
24
+ ## Quick start
25
+
26
+ ```ruby
27
+ require "zeridion_flare"
28
+
29
+ client = Zeridion::Flare::Client.new(api_key: "zf_live_sk_...")
30
+ # Or, with FLARE_API_KEY set in the environment:
31
+ # client = Zeridion::Flare::Client.new
32
+
33
+ job = client.create_job(
34
+ "job_type" => "SendWelcomeEmail",
35
+ "payload" => { "email" => "alice@example.com" },
36
+ "queue" => "default",
37
+ )
38
+
39
+ puts job["id"], job["state"] # "job_abc123", "pending"
40
+ ```
41
+
42
+ ## `Client.new(...)`
43
+
44
+ | Keyword | Default | Notes |
45
+ |------------------------|------------------------------|--------------------------------------------------------|
46
+ | `api_key:` | `ENV["FLARE_API_KEY"]` | Required — pass directly or via env var |
47
+ | `base_url:` | `https://api.zeridion.com` | Override for dev / staging environments |
48
+ | `max_retries:` | `3` | Set `0` to disable retries |
49
+ | `retry_base_delay_ms:` | `500` | Base for exponential schedule |
50
+ | `retry_max_delay_ms:` | `30_000` | Cap on a single backoff wait (and on `Retry-After`) |
51
+ | `timeout_seconds:` | `30` | Per-request read timeout |
52
+ | `transport:` | `NetHttpTransport.new` | Swap for tests or to plug in a logging proxy |
53
+
54
+ Every public method accepts optional `idempotency_key:` and `request_id:` keyword arguments, sent as the `Idempotency-Key` and `X-Request-Id` headers.
55
+
56
+ ```ruby
57
+ client.create_job(body, idempotency_key: "optional", request_id: "optional")
58
+ client.get_job(id) # nil if 404
59
+ client.list_jobs(state: "failed", limit: 25)
60
+ client.cancel_job(id) # nil if 409
61
+ client.retry_job(id) # nil if 409
62
+ client.register_worker(body) # raises on non-2xx
63
+ client.poll_workers(body)
64
+ client.heartbeat(body) # {"status" => "ok" | "cancel"}
65
+ client.ack_worker(body)
66
+ ```
67
+
68
+ ## Automatic retries
69
+
70
+ The SDK auto-retries HTTP `429` / `502` / `503` / `504` responses and transient network errors with full-jitter exponential backoff capped at `retry_max_delay_ms`. The `Retry-After` header is honored when present.
71
+
72
+ See the [stable error-code registry](https://docs.zeridion.com/flare/api/errors) for every `error.code` string the API can return.
73
+
74
+ ## Error handling
75
+
76
+ All API errors inherit from `Zeridion::Flare::FlareError`:
77
+
78
+ ```ruby
79
+ begin
80
+ client.create_job("job_type" => "MyJob")
81
+ rescue Zeridion::Flare::RateLimitError => e
82
+ sleep(e.retry_after.to_i) # honor X-RateLimit-Reset
83
+ rescue Zeridion::Flare::AuthError
84
+ # 401 — invalid API key
85
+ rescue Zeridion::Flare::ConflictError => e
86
+ case e.code
87
+ when "idempotency_key_reuse"
88
+ # Same Idempotency-Key reused with a different body
89
+ end
90
+ rescue Zeridion::Flare::FlareError => e
91
+ warn "Error #{e.status_code}: #{e.code} — #{e.message} (req #{e.request_id})"
92
+ end
93
+ ```
94
+
95
+ Each error exposes `#status_code`, `#code`, `#request_id`, and `#message`. `RateLimitError` additionally exposes `#limit`, `#remaining`, `#retry_after`.
96
+
97
+ ## Verifying webhook signatures
98
+
99
+ If you've configured outbound webhooks via the `/flare/v1/webhooks` API, verify the `X-Zeridion-Signature` header on each incoming delivery:
100
+
101
+ ```ruby
102
+ require "zeridion_flare"
103
+
104
+ post "/hooks/zeridion" do
105
+ payload = request.body.read
106
+ header = request.env["HTTP_X_ZERIDION_SIGNATURE"].to_s
107
+
108
+ unless Zeridion::Flare::Webhook.verify(payload, header, ENV["ZERIDION_WEBHOOK_SECRET"], tolerance_seconds: 300)
109
+ halt 400, "invalid signature"
110
+ end
111
+ # ... process the event ...
112
+ 200
113
+ end
114
+ ```
115
+
116
+ `Webhook.verify` is HMAC-SHA256 over `<unix_timestamp>.<raw_body>`, constant-time-compared (via `OpenSSL.secure_compare`) against every `v1=` value in the header (supports secret rotation). The optional `tolerance_seconds:` parameter rejects replays older than that many seconds.
117
+
118
+ ## Worker runtime
119
+
120
+ Beyond the thin client, the gem ships an opt-in **background-worker runtime**.
121
+ Define job classes and let it register, long-poll, dispatch, heartbeat with
122
+ progress, honor server cancellation, and drain gracefully on shutdown — instead
123
+ of hand-writing the poll/ack loop.
124
+
125
+ ```ruby
126
+ require "zeridion_flare/worker"
127
+
128
+ class SendWelcomeEmail
129
+ include Zeridion::Flare::Job
130
+ flare_options queue: "email", max_attempts: 5, timeout: 120
131
+
132
+ def perform(payload, ctx)
133
+ return if ctx.cancelled?
134
+ return if AlreadySent.exists?(ctx.job_id) # at-least-once → idempotent
135
+ Mailer.welcome(payload["email"]).deliver_now
136
+ ctx.report_progress(1.0)
137
+ end
138
+ end
139
+
140
+ # Recurring (cron) jobs are payloadless and run on a schedule:
141
+ class NightlyCleanup
142
+ include Zeridion::Flare::RecurringJob
143
+ flare_options cron: "0 3 * * *", queue: "maintenance", timezone: "UTC"
144
+
145
+ def perform(ctx)
146
+ PurgeExpired.run
147
+ end
148
+ end
149
+
150
+ worker = Zeridion::Flare::Worker::Worker.new(concurrency: 5)
151
+ worker.run # blocks; SIGTERM/SIGINT → graceful drain
152
+ ```
153
+
154
+ Including the `Job` / `RecurringJob` mixin registers the class automatically.
155
+ The worker reads the same `FLARE_API_KEY` fallback and base-URL default as the
156
+ thin client. Handlers run **at least once** — dedupe on `ctx.job_id` to stay
157
+ idempotent.
158
+
159
+ Full reference: [docs.zeridion.com/flare/sdks/ruby/worker](https://docs.zeridion.com/flare/sdks/ruby/worker).
160
+
161
+ ## Sample app
162
+
163
+ A runnable worker starter (one payload job + one recurring job + graceful drain,
164
+ plus an `enqueue.rb` feeder) lives at
165
+ [`samples/ruby-starter/`](../../samples/ruby-starter/).
166
+
167
+ ## Links
168
+
169
+ - Documentation: https://docs.zeridion.com/flare
170
+ - Dashboard: https://dashboard.zeridion.com/
171
+ - .NET SDK: ../csharp/README.md
172
+ - TypeScript SDK: ../typescript/README.md
173
+ - Python SDK: ../python/README.md
174
+ - Go SDK: ../go/README.md
175
+ - PHP SDK: ../php/README.md
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ require_relative "errors"
9
+ require_relative "version"
10
+
11
+ module Zeridion
12
+ module Flare
13
+ # Default HTTP transport backed by Ruby's stdlib Net::HTTP. Tests can swap
14
+ # in a custom object that responds to #send_request with the same
15
+ # signature to capture requests / queue responses without touching the
16
+ # network.
17
+ class NetHttpTransport
18
+ # @return [Hash{Symbol=>Object}] { status:, headers:, body: }
19
+ def send_request(method:, url:, headers:, body:, timeout:)
20
+ uri = URI.parse(url)
21
+ http = Net::HTTP.new(uri.host, uri.port)
22
+ http.use_ssl = (uri.scheme == "https")
23
+ http.open_timeout = 10
24
+ http.read_timeout = timeout
25
+
26
+ req = build_request(method, uri, headers, body)
27
+ resp = http.request(req)
28
+
29
+ parsed_headers = {}
30
+ resp.each_header { |k, v| parsed_headers[k.downcase] = v }
31
+ { status: resp.code.to_i, headers: parsed_headers, body: resp.body.to_s }
32
+ end
33
+
34
+ private
35
+
36
+ def build_request(method, uri, headers, body)
37
+ path = uri.request_uri
38
+ req_class = {
39
+ "GET" => Net::HTTP::Get,
40
+ "POST" => Net::HTTP::Post,
41
+ "PUT" => Net::HTTP::Put,
42
+ "PATCH" => Net::HTTP::Patch,
43
+ "DELETE" => Net::HTTP::Delete,
44
+ }.fetch(method.upcase) do
45
+ raise ArgumentError, "unsupported HTTP method: #{method}"
46
+ end
47
+ req = req_class.new(path)
48
+ headers.each { |k, v| req[k] = v }
49
+ req.body = body if body
50
+ req
51
+ end
52
+ end
53
+
54
+ # Synchronous Flare API client.
55
+ class Client
56
+ DEFAULT_BASE_URL = "https://api.zeridion.com"
57
+ API_PREFIX = "/flare/v1"
58
+ RETRYABLE_STATUSES = [429, 502, 503, 504].freeze
59
+
60
+ attr_reader :api_key, :base_url
61
+
62
+ # @param api_key [String, nil] API key. Falls back to FLARE_API_KEY env var.
63
+ # @param base_url [String] Base URL — default https://api.zeridion.com
64
+ # @param max_retries [Integer] Set 0 to disable retries.
65
+ # @param retry_base_delay_ms [Integer] Base for exponential schedule.
66
+ # @param retry_max_delay_ms [Integer] Cap on a single backoff wait.
67
+ # @param timeout_seconds [Numeric] Per-request read timeout.
68
+ # @param transport [Object] Object responding to #send_request; default NetHttpTransport.
69
+ # @param max_response_bytes [Integer] Hard cap on response body bytes
70
+ # the client will read. Protects against a misbehaving or malicious
71
+ # server sending an unbounded body that would otherwise blow Ruby
72
+ # memory. Defaults to 10 MiB; set 0 to disable.
73
+ def initialize(
74
+ api_key: nil,
75
+ base_url: DEFAULT_BASE_URL,
76
+ max_retries: 3,
77
+ retry_base_delay_ms: 500,
78
+ retry_max_delay_ms: 30_000,
79
+ timeout_seconds: 30,
80
+ max_response_bytes: 10 * 1024 * 1024,
81
+ transport: nil
82
+ )
83
+ api_key ||= ENV["FLARE_API_KEY"]
84
+ if api_key.nil? || api_key.to_s.empty?
85
+ raise ArgumentError,
86
+ "Client: api_key is required (pass directly or set the FLARE_API_KEY environment variable)"
87
+ end
88
+ @api_key = api_key
89
+ @base_url = base_url.chomp("/")
90
+ @max_retries = max_retries
91
+ @retry_base_delay_ms = retry_base_delay_ms
92
+ @retry_max_delay_ms = retry_max_delay_ms
93
+ @timeout_seconds = timeout_seconds
94
+ @max_response_bytes = max_response_bytes
95
+ @transport = transport || NetHttpTransport.new
96
+ end
97
+
98
+ # ── Jobs ────────────────────────────────────────────────────────────
99
+
100
+ def create_job(body, idempotency_key: nil, request_id: nil)
101
+ resp = request("POST", "/jobs", body: body, idempotency_key: idempotency_key, request_id: request_id)
102
+ decode(maybe_raise(resp))
103
+ end
104
+
105
+ def get_job(job_id, idempotency_key: nil, request_id: nil)
106
+ resp = request("GET", "/jobs/#{escape_path(job_id)}", idempotency_key: idempotency_key, request_id: request_id)
107
+ return nil if resp[:status] == 404
108
+
109
+ decode(maybe_raise(resp))
110
+ end
111
+
112
+ def list_jobs(
113
+ state: nil, queue: nil, job_type: nil, created_after: nil,
114
+ created_before: nil, limit: nil, cursor: nil,
115
+ idempotency_key: nil, request_id: nil
116
+ )
117
+ params = {
118
+ "state" => state, "queue" => queue, "job_type" => job_type,
119
+ "created_after" => created_after, "created_before" => created_before,
120
+ "limit" => limit, "cursor" => cursor,
121
+ }.reject { |_, v| v.nil? }
122
+ path = "/jobs"
123
+ path += "?#{URI.encode_www_form(params)}" unless params.empty?
124
+ resp = request("GET", path, idempotency_key: idempotency_key, request_id: request_id)
125
+ decode(maybe_raise(resp))
126
+ end
127
+
128
+ def cancel_job(job_id, idempotency_key: nil, request_id: nil)
129
+ resp = request("POST", "/jobs/#{escape_path(job_id)}/cancel",
130
+ idempotency_key: idempotency_key, request_id: request_id)
131
+ return nil if resp[:status] == 409
132
+
133
+ decode(maybe_raise(resp))
134
+ end
135
+
136
+ def retry_job(job_id, idempotency_key: nil, request_id: nil)
137
+ resp = request("POST", "/jobs/#{escape_path(job_id)}/retry",
138
+ idempotency_key: idempotency_key, request_id: request_id)
139
+ return nil if resp[:status] == 409
140
+
141
+ decode(maybe_raise(resp))
142
+ end
143
+
144
+ # ── Workers ─────────────────────────────────────────────────────────
145
+
146
+ # Announce this worker's identity, served queues + job types, and any
147
+ # recurring schedules. Best-effort on the server; STRICT here (raises on
148
+ # non-2xx) — the worker runtime is responsible for swallowing failures.
149
+ def register_worker(body, idempotency_key: nil, request_id: nil)
150
+ resp = request("POST", "/workers/register", body: body, idempotency_key: idempotency_key,
151
+ request_id: request_id)
152
+ decode(maybe_raise(resp))
153
+ end
154
+
155
+ def poll_workers(body, idempotency_key: nil, request_id: nil)
156
+ resp = request("POST", "/workers/poll", body: body, idempotency_key: idempotency_key, request_id: request_id)
157
+ decode(maybe_raise(resp))
158
+ end
159
+
160
+ # Send a single liveness/progress beat for one in-flight job. STRICT here
161
+ # (raises on non-2xx); the worker runtime swallows. Returns the decoded
162
+ # response Hash, e.g. {"status" => "ok"} or {"status" => "cancel"}.
163
+ def heartbeat(body, idempotency_key: nil, request_id: nil)
164
+ resp = request("POST", "/workers/heartbeat", body: body, idempotency_key: idempotency_key,
165
+ request_id: request_id)
166
+ decode(maybe_raise(resp))
167
+ end
168
+
169
+ def ack_worker(body, idempotency_key: nil, request_id: nil)
170
+ resp = request("POST", "/workers/ack", body: body, idempotency_key: idempotency_key, request_id: request_id)
171
+ decode(maybe_raise(resp))
172
+ end
173
+
174
+ # ── Internal ────────────────────────────────────────────────────────
175
+
176
+ private
177
+
178
+ def escape_path(segment)
179
+ URI.encode_www_form_component(segment)
180
+ end
181
+
182
+ def request(method, path, body: nil, idempotency_key: nil, request_id: nil)
183
+ url = "#{@base_url}#{API_PREFIX}#{path}"
184
+ headers = {
185
+ "Authorization" => "Bearer #{@api_key}",
186
+ "Accept" => "application/json",
187
+ "User-Agent" => "zeridion-flare-ruby/#{Zeridion::Flare::VERSION}",
188
+ }
189
+ body_str = nil
190
+ if body
191
+ body_str = JSON.generate(body)
192
+ headers["Content-Type"] = "application/json"
193
+ end
194
+ headers["Idempotency-Key"] = idempotency_key if idempotency_key
195
+ headers["X-Request-Id"] = request_id if request_id
196
+
197
+ last_exception = nil
198
+ attempt = 0
199
+ loop do
200
+ begin
201
+ resp = @transport.send_request(method: method, url: url, headers: headers, body: body_str,
202
+ timeout: @timeout_seconds)
203
+ rescue StandardError => e
204
+ raise e if attempt == @max_retries
205
+
206
+ last_exception = e
207
+ sleep(compute_backoff_seconds(attempt, nil))
208
+ attempt += 1
209
+ next
210
+ end
211
+
212
+ return resp unless RETRYABLE_STATUSES.include?(resp[:status]) && attempt < @max_retries
213
+
214
+ sleep(compute_backoff_seconds(attempt, resp[:headers]["retry-after"]))
215
+ attempt += 1
216
+ end
217
+ rescue StandardError
218
+ raise last_exception if last_exception
219
+
220
+ raise
221
+ end
222
+
223
+ def compute_backoff_seconds(attempt, retry_after_raw)
224
+ if retry_after_raw&.match?(/\A\d+\z/)
225
+ seconds = retry_after_raw.to_i
226
+ return [seconds, @retry_max_delay_ms / 1000.0].min if seconds >= 0
227
+ end
228
+ ceiling_ms = [@retry_base_delay_ms * (2**attempt), @retry_max_delay_ms].min
229
+ rand(0..ceiling_ms) / 1000.0
230
+ end
231
+
232
+ def maybe_raise(resp)
233
+ status = resp[:status]
234
+ return resp if status >= 200 && status < 300
235
+
236
+ code = "unknown"
237
+ message = "HTTP #{status}"
238
+ request_id = ""
239
+ if !resp[:body].empty?
240
+ begin
241
+ envelope = JSON.parse(resp[:body])
242
+ err = envelope.is_a?(Hash) ? envelope["error"] : nil
243
+ if err.is_a?(Hash)
244
+ code = err["code"] || code
245
+ message = err["message"] || message
246
+ request_id = err["request_id"] || request_id
247
+ end
248
+ rescue JSON::ParserError
249
+ # Non-JSON error body (e.g. plain-text 502 from a reverse proxy)
250
+ end
251
+ end
252
+
253
+ case status
254
+ when 401
255
+ raise AuthError.new(message, status_code: status, code: code, request_id: request_id)
256
+ when 402
257
+ raise QuotaError.new(message, status_code: status, code: code, request_id: request_id)
258
+ when 404
259
+ raise NotFoundError.new(message, status_code: status, code: code, request_id: request_id)
260
+ when 409
261
+ raise ConflictError.new(message, status_code: status, code: code, request_id: request_id)
262
+ when 429
263
+ limit = (resp[:headers]["x-ratelimit-limit"] || "0").to_i
264
+ remaining = (resp[:headers]["x-ratelimit-remaining"] || "0").to_i
265
+ reset_raw = resp[:headers]["x-ratelimit-reset"]
266
+ retry_after = reset_raw&.match?(/\A\d+\z/) ? reset_raw.to_i : nil
267
+ raise RateLimitError.new(message, status_code: status, code: code, request_id: request_id,
268
+ limit: limit, remaining: remaining, retry_after: retry_after)
269
+ else
270
+ raise FlareError.new(message, status_code: status, code: code, request_id: request_id)
271
+ end
272
+ end
273
+
274
+ def decode(resp)
275
+ return {} if resp[:body].empty?
276
+
277
+ # Cap the response body the parser sees so a misbehaving or malicious
278
+ # server can't blow Ruby memory with a multi-GiB payload. When
279
+ # @max_response_bytes is 0 the cap is disabled.
280
+ if @max_response_bytes.positive? && resp[:body].bytesize > @max_response_bytes
281
+ # FlareError#initialize requires status_code:/code:/request_id: keywords,
282
+ # so the bare `raise FlareError, msg` form would actually raise
283
+ # ArgumentError: missing keywords. Construct it explicitly with
284
+ # placeholders — the body-cap path has no upstream API call to
285
+ # attribute, so status_code is 0 and request_id is nil.
286
+ raise FlareError.new(
287
+ "flare: response body #{resp[:body].bytesize} bytes " \
288
+ "exceeds max_response_bytes=#{@max_response_bytes}",
289
+ status_code: 0,
290
+ code: "body_too_large",
291
+ request_id: nil,
292
+ )
293
+ end
294
+ JSON.parse(resp[:body])
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zeridion
4
+ module Flare
5
+ # Base exception for all Flare API errors. Every other exception in this
6
+ # SDK inherits from FlareError, so a single `rescue FlareError` covers them.
7
+ class FlareError < StandardError
8
+ attr_reader :status_code, :code, :request_id
9
+
10
+ def initialize(message, status_code:, code:, request_id:)
11
+ super(message)
12
+ @status_code = status_code
13
+ @code = code
14
+ @request_id = request_id
15
+ end
16
+ end
17
+
18
+ class AuthError < FlareError; end
19
+ class QuotaError < FlareError; end
20
+ class NotFoundError < FlareError; end
21
+ class ConflictError < FlareError; end
22
+
23
+ class RateLimitError < FlareError
24
+ attr_reader :limit, :remaining, :retry_after
25
+
26
+ def initialize(message, status_code:, code:, request_id:, limit:, remaining:, retry_after:)
27
+ super(message, status_code: status_code, code: code, request_id: request_id)
28
+ @limit = limit
29
+ @remaining = remaining
30
+ @retry_after = retry_after
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zeridion
4
+ module Flare
5
+ VERSION = "0.2.0"
6
+ end
7
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Zeridion
6
+ module Flare
7
+ # Verify outbound webhook signatures from Zeridion Flare.
8
+ #
9
+ # Each delivery is signed with HMAC-SHA256 over `<unix_timestamp>.<raw_body>`
10
+ # using the subscription secret. The signature is sent in the
11
+ # X-Zeridion-Signature header in the form `t=<unix_timestamp>,v1=<hex>`.
12
+ module Webhook
13
+ # @param payload [String] Raw request body. MUST be the exact bytes the
14
+ # server signed — do not re-encode parsed JSON.
15
+ # @param signature_header [String] Full value of the X-Zeridion-Signature header.
16
+ # @param secret [String] The subscription secret returned at creation.
17
+ # @param tolerance_seconds [Integer, nil] If set, reject signatures whose timestamp
18
+ # differs from the current time by more
19
+ # than this many seconds (replay protection;
20
+ # recommended: 300).
21
+ # @return [Boolean] true iff the signature is valid (and within tolerance, if set).
22
+ def self.verify(payload, signature_header, secret, tolerance_seconds: nil)
23
+ return false if signature_header.nil? || signature_header.empty?
24
+ return false if secret.nil? || secret.empty?
25
+
26
+ timestamp = nil
27
+ signatures = []
28
+ signature_header.split(",").each do |part|
29
+ trimmed = part.strip
30
+ eq = trimmed.index("=")
31
+ next if eq.nil? || eq.zero?
32
+
33
+ key = trimmed[0, eq]
34
+ value = trimmed[(eq + 1)..]
35
+ case key
36
+ when "t"
37
+ timestamp = Integer(value, 10) if value.match?(/\A\d+\z/)
38
+ when "v1"
39
+ signatures << value.downcase
40
+ end
41
+ end
42
+
43
+ return false if timestamp.nil? || signatures.empty?
44
+
45
+ if tolerance_seconds
46
+ now = Time.now.to_i
47
+ return false if (now - timestamp).abs > tolerance_seconds
48
+ end
49
+
50
+ signing_input = "#{timestamp}.#{payload}"
51
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signing_input)
52
+
53
+ signatures.any? { |provided| secure_compare(expected, provided) }
54
+ end
55
+
56
+ # Constant-time string comparison (defends against timing-attack
57
+ # signature-guessing). OpenSSL.secure_compare is available in Ruby 3.0+;
58
+ # we fall back to a hand-rolled loop for older runtimes if it's missing.
59
+ def self.secure_compare(a, b)
60
+ return false if a.bytesize != b.bytesize
61
+
62
+ if OpenSSL.respond_to?(:secure_compare)
63
+ OpenSSL.secure_compare(a, b)
64
+ else
65
+ l = a.unpack("C*")
66
+ r = 0
67
+ i = -1
68
+ b.each_byte { |v| r |= v ^ l[i += 1] }
69
+ r.zero?
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end