angarium 0.2.0 → 0.3.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: 98d1d438861a4ff090327c932205c0e6345c8f079562a99ca6ab344fff93da10
4
- data.tar.gz: 6b37af363e8a253db8f9e66feb549a56a2152fb4fc7536c76551f4daf61688de
3
+ metadata.gz: 29562789c5729a772541a473a348a0273921ea4e9a29e93a8a2de5ca58155610
4
+ data.tar.gz: 4297618824d834c783269cb16c7d05f2434bb1d7057e0617fc62742c89c2291b
5
5
  SHA512:
6
- metadata.gz: 1675398932024d506e7338faa7b3585df46caf3f6f80090347ee029bb608dd859333eff198259eee769e466d9fbc4bee91be9994e971055659e44dd1d2f87d65
7
- data.tar.gz: 44481ffed27e96af446720d475124536eb27e384ad2fe28c777bf4b9cadb9033bec49ec345dbb4abc1376deab3d9d7d24917cb283c4aa4d9f6742f34533239e8
6
+ metadata.gz: 4603bd4667fb265cae1eb433b3bb0077535cf7af2f313df2215a18d16f40f1464bf81ae4e5b2bf742470d5cdb5fb4e6720bc4e2f944a20a279729e7782e022f3
7
+ data.tar.gz: d6ba2088037d98660a7be54fcd87a051e923c6404e5d7bb1996512540b795a4f1678660cd6cd3170c6b6a1632b99c783aad9d70b3c7ec9002898ad84ee594d8b
data/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to this project are documented here. The format is based on
5
5
  follow [Semantic Versioning](https://semver.org). While on 0.x, a minor release
6
6
  may include breaking changes; patch releases stay backward compatible.
7
7
 
8
+ ## [0.3.0] - 2026-07-08
9
+
10
+ ### Bug Fixes
11
+
12
+ - Persist delivery attempts without stranding on receiver text
13
+ - Require the retry schedule to be due before claiming
14
+ - Treat an echoed boolean network control as a no-op
15
+ - Report swallowed callback errors to Rails.error
16
+ - Set JSON column defaults at the model layer for MySQL
17
+
18
+ ### Features
19
+
20
+ - Make the ping event name configurable
21
+
22
+ ### Ci
23
+
24
+ - Run the suite against PostgreSQL and MySQL
25
+
8
26
  ## [0.2.0] - 2026-07-07
9
27
 
10
28
  ### Bug Fixes
data/README.md CHANGED
@@ -351,9 +351,10 @@ redelivery is at-least-once-safe either way. Set it to `nil` to disable reaping.
351
351
 
352
352
  ### Pinging an endpoint
353
353
 
354
- Verify an endpoint end-to-end by delivering a synthetic `ping` event
355
- (subscription matching is bypassed, so a ping is always sent). Returns the
356
- `Angarium::Delivery`, so you can reload it to inspect the outcome:
354
+ Verify an endpoint end-to-end by delivering a synthetic `ping` event (the name
355
+ is `config.ping_event_name`, default `"ping"` change it if your app already
356
+ uses `"ping"`). Subscription matching is bypassed, so a ping is always sent.
357
+ Returns the `Angarium::Delivery`, so you can reload it to inspect the outcome:
357
358
 
358
359
  ```ruby
359
360
  delivery = endpoint.ping!
@@ -706,6 +707,7 @@ which documents every option inline. The delivery and retry settings:
706
707
  | `resolve_dns_with_hosts_file` | `true` | Also resolve endpoint hosts via the system hosts file (e.g. `/etc/hosts`), not DNS only. Set `false` to harden to DNS-only. |
707
708
  | `max_url_length` | `2048` | Maximum length of an endpoint URL. |
708
709
  | `max_subscribed_events` | `100` | Maximum number of event patterns an endpoint may subscribe to. |
710
+ | `ping_event_name` | `"ping"` | Event name for the synthetic event `endpoint.ping!` emits; change it to avoid colliding with your own events. |
709
711
  | `max_response_body_bytes` | `65_536` | Truncate the stored response body; `nil` stores it whole. |
710
712
  | `delivery_attempt_retention` | `nil` | Age past which `angarium:prune` deletes attempts; `nil` keeps all. |
711
713
  | `delivering_timeout` | `15.minutes` | Age after which `angarium:reap` requeues a stuck `delivering` delivery. |
data/SECURITY.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  ## Supported versions
4
4
 
5
- Angarium is pre-release. Security fixes are provided for the `0.2.x` series
5
+ Angarium is pre-release. Security fixes are provided for the `0.3.x` series
6
6
  during this phase. Once a stable line is released, this policy will be updated to
7
7
  list the versions that receive security updates.
8
8
 
9
9
  | Version | Supported |
10
10
  | ------- | --------- |
11
- | 0.2.x | ✅ |
11
+ | 0.3.x | ✅ |
12
12
 
13
13
  ## Reporting a vulnerability
14
14
 
@@ -107,8 +107,18 @@ module Angarium
107
107
  return unless body.key?(attr)
108
108
 
109
109
  current = (@endpoint || Angarium::Endpoint.new).public_send(attr)
110
- submitted = body[attr]
111
- submitted = Array(submitted).map(&:to_s) if attr == :allowed_networks
110
+ # Compare like-for-like against the typed attribute, so echoing the
111
+ # serialized endpoint back is a no-op regardless of wire representation:
112
+ # allowed_networks is an array of strings, while allow_private_network is a
113
+ # boolean a form/JSON client may send as "true"/"false"/"0"/"1". Without
114
+ # the cast, a stringly-typed "false" wouldn't == the boolean false and a
115
+ # semantic no-op would raise a spurious 422.
116
+ submitted =
117
+ if attr == :allowed_networks
118
+ Array(body[attr]).map(&:to_s)
119
+ else
120
+ ActiveModel::Type::Boolean.new.cast(body[attr])
121
+ end
112
122
 
113
123
  raise Angarium::Api::UnpermittedParameter, attr unless submitted == current
114
124
  end
@@ -90,7 +90,7 @@ module Angarium
90
90
  if addresses.any? { |ip| !AddressPolicy.ip_allowed?(ip, endpoint) }
91
91
  payload[:outcome] = :blocked
92
92
  payload[:error] = "blocked: destination address not permitted"
93
- attempt = delivery_attempts.create!(error: payload[:error])
93
+ attempt = record_attempt!(error: payload[:error])
94
94
  update!(state: "blocked", next_attempt_at: nil)
95
95
  endpoint.record_delivery_failure!
96
96
  return attempt
@@ -103,7 +103,7 @@ module Angarium
103
103
  if addresses.empty?
104
104
  payload[:outcome] = :unresolvable
105
105
  payload[:error] = "unresolvable host: #{destination_host}"
106
- attempt = delivery_attempts.create!(error: payload[:error])
106
+ attempt = record_attempt!(error: payload[:error])
107
107
  handle_failure!
108
108
  return attempt
109
109
  end
@@ -129,7 +129,7 @@ module Angarium
129
129
  addresses: addresses.map(&:to_s)
130
130
  )
131
131
 
132
- attempt = delivery_attempts.create!(
132
+ attempt = record_attempt!(
133
133
  response_code: result.code,
134
134
  response_body: result.body,
135
135
  error: result.error,
@@ -178,9 +178,18 @@ module Angarium
178
178
  # clean dirty baseline to persist the delivering -> succeeded/pending move).
179
179
  def claim_for_attempt!
180
180
  now = Time.current
181
- claimed = self.class.where(id: id, state: "pending").update_all(
182
- ["state = 'delivering', attempt_count = attempt_count + 1, last_attempt_at = ?, updated_at = ?", now, now]
183
- )
181
+ # Also require the schedule to be due. Without this, a duplicate or stale
182
+ # enqueue (an at-least-once adapter re-running a job whose retry is scheduled
183
+ # for later) would claim and attempt EARLIER than next_attempt_at, defeating
184
+ # the backoff the Retry-After defenses exist to protect. New, held, and
185
+ # redelivered rows carry next_attempt_at: nil and so are always due; a
186
+ # scheduled retry becomes claimable only once its time arrives; the reaper
187
+ # stamps next_attempt_at = now on requeue, so a reaped row is due immediately.
188
+ claimed = self.class.where(id: id, state: "pending")
189
+ .where("next_attempt_at IS NULL OR next_attempt_at <= ?", now)
190
+ .update_all(
191
+ ["state = 'delivering', attempt_count = attempt_count + 1, last_attempt_at = ?, updated_at = ?", now, now]
192
+ )
184
193
  return false if claimed.zero?
185
194
 
186
195
  reload
@@ -200,11 +209,36 @@ module Angarium
200
209
  # queued. Stop retrying and log why. Recover with redeliver! if the endpoint is
201
210
  # later re-enabled. Returns the logged attempt.
202
211
  def cancel!(reason:)
203
- attempt = delivery_attempts.create!(error: "canceled: endpoint #{reason}")
212
+ attempt = record_attempt!(error: "canceled: endpoint #{reason}")
204
213
  update!(state: "canceled", next_attempt_at: nil)
205
214
  attempt
206
215
  end
207
216
 
217
+ # Persist a DeliveryAttempt. A raise here lands after the row is already
218
+ # `delivering`, which would strand it for the reaper to redeliver forever (the
219
+ # exact failure F1 exists to prevent), so this must not raise on anything the
220
+ # receiver can influence. DeliveryAttempt#normalizes sanitizes the body and
221
+ # error at assignment (UTF-8 scrub, NUL strip, length cap), so the create!
222
+ # below should succeed; the rescue is a last-resort net for anything that slips
223
+ # through (a transient DB error, a MySQL limit we cannot reproduce on SQLite).
224
+ # We rescue broadly on purpose because any escape means a permanent loop.
225
+ def record_attempt!(attributes)
226
+ delivery_attempts.create!(attributes)
227
+ rescue => e
228
+ # The attempt's `error` is served to the webhook owner via the API, so we
229
+ # must not leak the internal exception there: store a generic marker for
230
+ # them, and surface the real cause internally so we can actually fix it.
231
+ # Reported to Rails.error because a delivery we can't record is a correctness
232
+ # problem, not a transient hiccup.
233
+ Rails.logger.error { "[Angarium] failed to persist delivery attempt for delivery ##{id}: #{e.class}: #{e.message}" }
234
+ Rails.error.report(e, handled: true, severity: :error, source: "angarium", context: {delivery_id: id})
235
+ delivery_attempts.create!(
236
+ response_code: attributes[:response_code],
237
+ duration: attributes[:duration],
238
+ error: "delivery attempt could not be recorded"
239
+ )
240
+ end
241
+
208
242
  def succeed!
209
243
  update!(state: "succeeded", next_attempt_at: nil)
210
244
  endpoint.record_delivery_success!
@@ -2,6 +2,28 @@ module Angarium
2
2
  class DeliveryAttempt < ApplicationRecord
3
3
  belongs_to :delivery, class_name: "Angarium::Delivery"
4
4
 
5
+ # response_body and error are partly receiver-controlled. Sanitize them on
6
+ # assignment (so every write path is covered, not just the delivery happy
7
+ # path) into something a text column always accepts — otherwise the INSERT
8
+ # raises, and mid-#deliver! that raise lands after the row is already
9
+ # `delivering`, stranding the delivery for the reaper to redeliver forever.
10
+ # For each: coerce to UTF-8 and scrub invalid bytes (an upstream byte cap can
11
+ # split a multibyte char; a receiver may return non-UTF-8 outright), then drop
12
+ # NUL — it is valid UTF-8 (so scrub keeps it) but PostgreSQL rejects it in text
13
+ # columns, surfacing via the pg driver as an ArgumentError, not even a
14
+ # StatementInvalid. `error` is additionally capped to its column limit (only
15
+ # MySQL sets one) since a client error message can overflow varchar(255).
16
+ normalizes :response_body, with: ->(value) { sanitize_text(value) }
17
+ normalizes :error, with: ->(value) {
18
+ text = sanitize_text(value)
19
+ limit = columns_hash["error"]&.limit
20
+ limit ? text.truncate(limit) : text
21
+ }
22
+
23
+ def self.sanitize_text(value)
24
+ value.dup.force_encoding(Encoding::UTF_8).scrub.delete("\u0000")
25
+ end
26
+
5
27
  # Delete delivery attempts older than the cutoff. `older_than` may be a
6
28
  # duration (e.g. 90.days) or an absolute Time. Returns the number deleted.
7
29
  # (A Time also responds to #ago but requires an argument, so branch on the
@@ -37,6 +37,12 @@ module Angarium
37
37
  # here keeps behavior correct on every supported Rails version.
38
38
  attribute :allow_private_network, :boolean, default: false
39
39
 
40
+ # Defaults at the model layer, not the column: both are JSON columns and MySQL
41
+ # forbids a DB default there. Procs yield a fresh object per record so the
42
+ # empty default is never shared/mutated across instances.
43
+ attribute :subscribed_events, default: -> { [] }
44
+ attribute :allowed_networks, default: -> { [] }
45
+
40
46
  before_validation :ensure_signing_secret, on: :create
41
47
 
42
48
  validates :name, presence: true, length: {maximum: 255}
@@ -139,14 +145,14 @@ module Angarium
139
145
  transition_status!(:enabled, from: :unverified) { Angarium.notify(:on_endpoint_verified, self) }
140
146
  end
141
147
 
142
- # Deliver a synthetic `ping` event to this endpoint, bypassing
143
- # subscription matching (a ping is always sent). By default a ping also
144
- # ignores the endpoint's status, so you can test an endpoint that is paused,
145
- # disabled, or not yet enabled; pass `force: false` to respect the status
146
- # guard instead. Returns the created Angarium::Delivery, whose
147
- # after_create_commit enqueues the DeliverJob; reload it to inspect the outcome.
148
+ # Deliver a synthetic ping event (name from config.ping_event_name, default
149
+ # "ping") to this endpoint, bypassing subscription matching (a ping is always
150
+ # sent). By default a ping also ignores the endpoint's status, so you can test
151
+ # an endpoint that is paused, disabled, or not yet enabled; pass `force: false`
152
+ # to respect the status guard instead. Returns the created Angarium::Delivery,
153
+ # whose after_create_commit enqueues the DeliverJob; reload it to inspect the outcome.
148
154
  def ping!(payload = {message: "ping"}, force: true)
149
- event = Angarium::Event.create!(name: "ping", payload: payload)
155
+ event = Angarium::Event.create!(name: Angarium.config.ping_event_name, payload: payload)
150
156
  deliveries.create!(event: event, forced: force)
151
157
  end
152
158
 
@@ -2,6 +2,10 @@ module Angarium
2
2
  class Event < ApplicationRecord
3
3
  has_many :deliveries, class_name: "Angarium::Delivery", dependent: :destroy
4
4
 
5
+ # Default at the model layer, not the column: the payload column is JSON and
6
+ # MySQL forbids a DB default there. A proc yields a fresh hash per record.
7
+ attribute :payload, default: -> { {} }
8
+
5
9
  validates :name, presence: true
6
10
  end
7
11
  end
@@ -8,9 +8,12 @@ class CreateAngariumEndpoints < ActiveRecord::Migration[7.1]
8
8
  t.text :url, null: false
9
9
  t.string :status, null: false, default: "enabled"
10
10
  t.text :signing_secret, null: false
11
- t.json :subscribed_events, null: false, default: []
11
+ # No DB-level default on the JSON columns: MySQL forbids defaults on
12
+ # JSON/TEXT/BLOB. The defaults are set on the model (Endpoint#subscribed_events
13
+ # / #allowed_networks) so the null: false constraints hold on every adapter.
14
+ t.json :subscribed_events, null: false
12
15
  t.boolean :allow_private_network, null: false, default: false
13
- t.json :allowed_networks, null: false, default: []
16
+ t.json :allowed_networks, null: false
14
17
  # Encrypted at rest (may hold a receiver credential), so it's nullable with
15
18
  # no DB default — an unencrypted default would fail to decrypt on read.
16
19
  t.json :custom_headers
@@ -2,7 +2,9 @@ class CreateAngariumEvents < ActiveRecord::Migration[7.1]
2
2
  def change
3
3
  create_table :angarium_events, id: Angarium.primary_key_type do |t|
4
4
  t.string :name, null: false
5
- t.json :payload, null: false, default: {}
5
+ # No DB-level default: MySQL forbids defaults on JSON/TEXT/BLOB columns. The
6
+ # default is set on the model (Event#payload) so null: false holds everywhere.
7
+ t.json :payload, null: false
6
8
  t.timestamps
7
9
  end
8
10
  end
@@ -25,6 +25,10 @@ module Angarium
25
25
  headers: {})
26
26
  end
27
27
 
28
+ # Cap the response we hold in memory. The body is receiver-controlled and
29
+ # may not be persistable text (non-UTF-8 bytes, a NUL, or a multibyte char
30
+ # this byte cap splits); DeliveryAttempt#normalizes sanitizes it at
31
+ # persistence time, so this stays a pure transport concern.
28
32
  max = Angarium.config.max_response_body_bytes
29
33
  body = response.body.to_s
30
34
  body = body.byteslice(0, max) if max
@@ -7,6 +7,7 @@ module Angarium
7
7
  :max_retry_after, :retry_jitter, :signing_secret_grace_period,
8
8
  :delivery_attempt_retention, :delivering_timeout, :dns_timeout,
9
9
  :resolve_dns_with_hosts_file, :max_url_length, :max_subscribed_events,
10
+ :ping_event_name,
10
11
  :on_delivery_exhausted, :on_endpoint_deactivated, :on_endpoint_verified,
11
12
  :parent_controller, :current_user, :policy_class
12
13
 
@@ -60,6 +61,10 @@ module Angarium
60
61
  # every dispatch). Both bound user-supplied input.
61
62
  @max_url_length = 2048
62
63
  @max_subscribed_events = 100
64
+ # Event name of the synthetic event emitted by Endpoint#ping!. Configurable
65
+ # so it won't collide with an application that already uses "ping" for its
66
+ # own events (and so pings can be routed/filtered distinctly).
67
+ @ping_event_name = "ping"
63
68
  @on_delivery_exhausted = nil # ->(delivery) { ... }
64
69
  @on_endpoint_deactivated = nil # ->(endpoint, reason) { ... } reason: :consecutive_failures | :gone
65
70
  @on_endpoint_verified = nil # ->(endpoint) { ... } fired when an unverified endpoint is verified
@@ -1,3 +1,3 @@
1
1
  module Angarium
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/angarium.rb CHANGED
@@ -38,7 +38,11 @@ module Angarium
38
38
 
39
39
  callback.call(*args)
40
40
  rescue => e
41
+ # A raising callback must never break the pipeline, but swallowing it with
42
+ # only a log hides a real bug from error tracking. Log and report it (handled,
43
+ # so delivery continues) so it surfaces where the operator will see it.
41
44
  Rails.logger.error { "[Angarium] #{callback_name} callback raised: #{e.class}: #{e.message}" }
45
+ Rails.error.report(e, handled: true, severity: :error, source: "angarium", context: {callback: callback_name})
42
46
  end
43
47
  end
44
48
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: angarium
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - TheDumbTechGuy
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-07 00:00:00.000000000 Z
11
+ date: 2026-07-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails