ankusa-sdk 0.5.0 → 0.6.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: 817a62bfe2b9d6f6f4dc513d32cb6a4e4091de4e79bd37ef9b5432a44eca6a60
4
- data.tar.gz: 74ac90d9dccf5b681519f6870221bcddc11a96d015d945cffc68200606e2c808
3
+ metadata.gz: 3dbf23058e4a0799746070c44c18c750554e2298a2ae1dbe149d25bc9f9f4b22
4
+ data.tar.gz: 3aeaae91dbb2d4530678e9da87a365aafc5df73ab684047066aefae45b802ce6
5
5
  SHA512:
6
- metadata.gz: ca0aa04bed9460cc20cc1db4b262ff711c65762b284b60486bdcf49825800e03e4173ca319553eaa3ab21f4151aa68fce7a5350e861bb3626d29e48028c02b91
7
- data.tar.gz: 290a4cd6343778e9236772c3394877302ce97180c751c80a80420127698bfede89a859c413ea919280870bf57f355f6019628740cf4427c76ad2a39e70e1457b
6
+ metadata.gz: 78397c0e96fc065f7a4d30d0bcf533b07d86dec71d9c185d55fdd415f0ee5b1373e83e9af43bcc70ddb89b5a71d48970bfd38954e5cc2afce0ddcf0e06a0fd90
7
+ data.tar.gz: 0d0cc364cd091f83daa7425bd1df37e7a41b1cadaefc00b00776a193dbef97a3d5ea527f15ba80a01a5f97bca77a37d7c6c5162bca9849f8cbd247ad1135a3b9
data/CHANGELOG.md CHANGED
@@ -6,6 +6,53 @@ this project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0] - 2026-10-28
10
+
11
+ ### Added
12
+
13
+ - `Message#traceparent` and `Message#tracestate`: the hook's W3C trace context
14
+ from the message, verbatim (`nil` when none); a non-string value is
15
+ `invalid_field`.
16
+
17
+ - `verification` on parsed headers, decoded messages and received deliveries:
18
+ how Ankusa verified the hook at ingest (`{status, flagged}`, `null` from a
19
+ node older than the field).
20
+ - `Ankusa.parse_claim_ref` and `ClaimCheckClient#redeem` accept signed,
21
+ expiring v2 refs
22
+ (`urn:ankusa:claim:v2:<tenant>:<claim_id>:<exp_unix>:<signature>`): the
23
+ parsed `path` carries `?exp=&sig=` and `redeem` requests it as is. The
24
+ gateway's `400 bad_signature` and `410 ref_expired` are a
25
+ `ClaimRejectedError`. The SDK never checks the signature or the expiry.
26
+ - `Ankusa.receive_delivery(headers, body, secrets: [], allow_unsigned: false,
27
+ tolerance_seconds: 300, now: nil)`, the receiver-side decision on one
28
+ HTTP-sink delivery. The sink signs every delivery unless configured
29
+ `allow_unsigned: true`, so a receiver is closed by default: with `secrets`
30
+ the delivery must verify (as `verify_signature`); with none and no
31
+ `allow_unsigned: true` it raises `InvalidSignatureError` (`invalid_secret`).
32
+ The signature is judged before the hook id. Returns a `ReceivedDelivery`
33
+ (`headers`, `verified`).
34
+ - `scope` on `AdminRejectedError` and `RoutesRejectedError`: the scope a `403
35
+ forbidden` says the credential lacks (control planes require a credential
36
+ since Ankusa 0.6).
37
+ - `Admin#list_audit(**params)`: `GET /v1/audit`, the audit trail (newest first,
38
+ paged by `cursor`/`limit`, filtered by `since`, `until`, `actor`, `action`,
39
+ `tenant`).
40
+ - **Delivery metadata (CON-4).** `HookHeaders#received_at` (when the edge accepted the hook, unix milliseconds, from `x-ankusa-received-at`) and `HookHeaders#body_sha256` (the lowercase hex SHA-256 of the body sent, from `x-ankusa-body-sha256`): nil when the header is absent, empty or malformed, never an error.
41
+
42
+ - **Wire-format evolution path (CON-2).** `Ankusa.decode_message` accepts every
43
+ `v` from `Ankusa::MIN_SUPPORTED_VERSION` to `Ankusa::MAX_SUPPORTED_VERSION`
44
+ (1 and 2) into the one `Message`, with `v` as the wire said it: a `v: 2`
45
+ message is a `v: 1` message plus additive fields, so the keys it does not know
46
+ are ignored. `Message#instance` and `Message#region`: the name of the Ankusa
47
+ instance that published the message and the region it is configured with (nil
48
+ when null or absent). `Ankusa.decode_lifecycle` and `Ankusa::LifecycleEvent`
49
+ decode the CloudEvents lifecycle events of the instance's lifecycle sinks.
50
+
51
+ - `Message#trusted?`, `HookHeaders#trusted?` and `Verification#trusted?`: true
52
+ only when the node verified the hook at ingest (status `"ok"`) and did not
53
+ flag it. A flagged, `"failed"` or `"skipped"` hook and one with no
54
+ `verification` are untrusted.
55
+
9
56
  ## [0.5.0] - 2026-10-14
10
57
 
11
58
  ### Added
@@ -74,6 +121,7 @@ this project follows [Semantic Versioning](https://semver.org/).
74
121
  family, which carries `.status` and `.body` instead), so a consumer needs
75
122
  exactly one bit to route a failure to dead-letter or retry.
76
123
 
77
- [Unreleased]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.5.0...HEAD
124
+ [Unreleased]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.6.0...HEAD
125
+ [0.6.0]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.5.0...sdk-ruby-v0.6.0
78
126
  [0.5.0]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.3.0...sdk-ruby-v0.5.0
79
127
  [0.3.0]: https://github.com/jamescarr/ankusa/releases/tag/sdk-ruby-v0.3.0
data/README.md CHANGED
@@ -26,14 +26,6 @@ then, in code:
26
26
  require "ankusa/sdk"
27
27
  ```
28
28
 
29
- Before the first RubyGems release, depend on it as a path gem the same way the
30
- Elixir packages in this monorepo depend on `ankusa` core before their first Hex
31
- release:
32
-
33
- ```ruby
34
- gem "ankusa-sdk", path: "packages/sdk-ruby"
35
- ```
36
-
37
29
  Everything the SDK exposes lives under `Ankusa::`.
38
30
 
39
31
  ## Claim-check client
@@ -156,8 +148,54 @@ pass several during a rotation. `tolerance_seconds:` (default 300) bounds the
156
148
  `webhook-timestamp` window. Failures are `Ankusa::InvalidSignatureError` with
157
149
  `code` and `field`, never retryable.
158
150
 
151
+ ### Receiving a delivery (signed by default)
152
+
153
+ Ankusa's HTTP sink signs every delivery unless it is configured
154
+ `allow_unsigned: true`, so a receiver is closed by default.
155
+ `Ankusa.receive_delivery` is the one call that decides: with the sink's
156
+ `secrets:` it verifies as above; with none, and no `allow_unsigned: true`, it
157
+ refuses every delivery (`Ankusa::InvalidSignatureError`, code
158
+ `invalid_secret`) — otherwise any host that can reach your URL could inject
159
+ hooks. A configured secret is never weakened by `allow_unsigned:`. The
160
+ signature is judged before the hook id, so a delivery that fails it is a `401`
161
+ even when it also lacks `x-ankusa-id` (`Ankusa::MissingHookIdError`, `400`).
162
+
163
+ ```ruby
164
+ begin
165
+ received = Ankusa.receive_delivery(header_map(env), body, secrets: [ENV.fetch("ANKUSA_WHSEC")])
166
+ # received.headers is the HookHeaders; received.verified is false only for an
167
+ # unsigned delivery `allow_unsigned: true` let through.
168
+ rescue Ankusa::InvalidSignatureError => e
169
+ return [401, {"content-type" => "application/json"},
170
+ [JSON.generate(error: "invalid_signature", code: e.code)]]
171
+ rescue Ankusa::MissingHookIdError
172
+ return [400, {"content-type" => "application/json"},
173
+ [JSON.generate(error: "missing x-ankusa-id")]]
174
+ end
175
+ ```
176
+
177
+ `hook.verification` (also on `Ankusa.parse_headers` and `Ankusa.decode_message`
178
+ results) says how the *node* verified the hook at ingest, `nil` from a node
179
+ older than the field. `hook.verification&.flagged == true` means the node
180
+ accepted a failed check (`on_verify_failure: accept_flag`) — treat it as
181
+ unverified. `hook.trusted?` (on messages and parsed headers) is that decision
182
+ in one call: true only for status `"ok"` with `flagged` false, so a flagged,
183
+ `"failed"` or `"skipped"` hook, or one from a node older than the field, is
184
+ untrusted.
185
+
159
186
  ## Consuming queue messages
160
187
 
188
+ The message's `v` is the wire format's major version: `Ankusa.decode_message`
189
+ accepts every `v` from `Ankusa::MIN_SUPPORTED_VERSION` to
190
+ `Ankusa::MAX_SUPPORTED_VERSION` (1 and 2) into the one `Message`, with `v` as
191
+ the wire said it. A `v: 2` message is a `v: 1` message plus additive fields, so
192
+ a consumer reads it the same way; any other `v` is `unsupported_version`
193
+ (permanent: dead-letter it). `message.instance` and `message.region` are the
194
+ Ankusa instance that published the message and the region it is configured with
195
+ (nil for none). A message from the instance's lifecycle sinks carries a
196
+ CloudEvents event as its body: `Ankusa.decode_lifecycle(body)` returns an
197
+ `Ankusa::LifecycleEvent`.
198
+
161
199
  Every message sink — RabbitMQ, Kafka, NATS, Redis — delivers one JSON message
162
200
  per hook (the HTTP sink sends the original body plus `x-ankusa-*` headers
163
201
  instead):
@@ -254,8 +292,9 @@ unfollowed redirect, a non-JSON success body, or unreachable; retryable).
254
292
  ## Admin client
255
293
 
256
294
  The operator API on `admin.port` (default 4002): health, Prometheus metrics, the
257
- redacted config, the DLQ, replay jobs, and the quarantine list — the
258
- `operations`, `dlq`, `replays`, and `quarantine` tags of `admin.v1.yaml`.
295
+ redacted config, the DLQ, the audit trail, replay jobs, and the quarantine
296
+ list — the `operations`, `dlq`, `audit`, `replays`, and `quarantine` tags of
297
+ `admin.v1.yaml`.
259
298
 
260
299
  ```ruby
261
300
  require "ankusa/sdk"
@@ -265,6 +304,7 @@ admin = Ankusa::AdminClient.new(ENV.fetch("ADMIN_URL", "http://localhost:4002"))
265
304
  admin.health # {"status" => "ok", "instance" => ..., "roles" => [...]}
266
305
  admin.list_dead_letters({"limit" => 10}) # {"total" => ..., "entries" => [...]}
267
306
  admin.list_quarantined
307
+ admin.list_audit({"tenant" => "acme", "limit" => 50}) # {"entries" => [...], "next_cursor" => ...}
268
308
 
269
309
  job = admin.create_replay({"kind" => "dlq", "source_id" => "stripe", "rate" => 500})
270
310
  admin.get_replay(job["id"])
@@ -275,10 +315,12 @@ admin.update_replay(job["id"], {"state" => "paused"}) # resume, pause or cance
275
315
  `create_replay` is idempotent for retries: a second POST of the same filter
276
316
  while the job is `running`/`paused` returns the existing job (`200`) instead of
277
317
  starting another (`202`). `list_replays` returns `{"replays" => [...]}` exactly
278
- as the API sends it.
318
+ as the API sends it. `list_audit` returns the audit trail newest first,
319
+ `{"entries" => [...], "next_cursor" => ...}`, paged by `cursor`/`limit` and
320
+ filtered by `since`, `until`, `actor`, `action` and `tenant`.
279
321
 
280
322
  Methods: `health`, `metrics` (Prometheus text), `config`, `list_dead_letters`,
281
- `create_replay`, `get_replay`, `list_replays`, `update_replay`,
323
+ `list_audit`, `create_replay`, `get_replay`, `list_replays`, `update_replay`,
282
324
  `list_quarantined`. Failures are `AdminError` subclasses: `RoleNotEnabledError`
283
325
  (409 `role_not_enabled`, carrying `role`), `AdminRejectedError` (any other 4xx —
284
326
  a missing replay is a 404 with `code` `replay_not_found`), and
data/lib/ankusa/admin.rb CHANGED
@@ -34,14 +34,16 @@ module Ankusa
34
34
  # The listener rejected the request (any other 4xx).
35
35
  #
36
36
  # `status` is the HTTP status; `code` is the body's `error` field
37
- # (`invalid_filter`, ...).
37
+ # (`invalid_filter`, ...). `scope` is the scope a 403 forbidden says the
38
+ # credential lacks, nil when absent or not a 403.
38
39
  class AdminRejectedError < AdminError
39
- attr_reader :status, :code
40
+ attr_reader :status, :code, :scope
40
41
 
41
- def initialize(message, status:, code:)
42
+ def initialize(message, status:, code:, scope: nil)
42
43
  super(message)
43
44
  @status = status
44
45
  @code = code
46
+ @scope = scope
45
47
  end
46
48
  end
47
49
 
@@ -81,6 +83,15 @@ module Ankusa
81
83
  json(request("GET", "/v1/dlq", query: params))
82
84
  end
83
85
 
86
+ # GET /v1/audit -> a page of the audit trail, newest first.
87
+ #
88
+ # `params` may carry `cursor`, `limit`, `since`, `until`, `actor`,
89
+ # `action` and `tenant`; absent keys are omitted from the query string
90
+ # rather than sent as `=`.
91
+ def list_audit(params = nil)
92
+ json(request("GET", "/v1/audit", query: params))
93
+ end
94
+
84
95
  # POST /v1/replays -> the created Replay, as a Hash.
85
96
  #
86
97
  # `spec` is the replay spec: `{"kind" => "dlq", ...}`,
@@ -153,10 +164,12 @@ module Ankusa
153
164
  end
154
165
 
155
166
  if status >= 400 && status < 500
167
+ scope = body.is_a?(Hash) ? body["scope"] : nil
156
168
  raise AdminRejectedError.new(
157
169
  "admin listener rejected the request (#{status}): #{body.inspect}",
158
170
  status: status,
159
- code: code
171
+ code: code,
172
+ scope: scope
160
173
  )
161
174
  end
162
175
 
@@ -58,24 +58,31 @@ module Ankusa
58
58
  # Mirrors `#/components/schemas/Ref` in priv/openapi/claim_check.v1.yaml --
59
59
  # keep the two in sync. A ref is one string:
60
60
  # urn:ankusa:claim:v1:<tenant>:<claim_id>
61
+ # urn:ankusa:claim:v2:<tenant>:<claim_id>:<exp_unix>:<signature>
62
+ # <exp_unix> is a decimal of 1-12 digits with no leading zero and <signature>
63
+ # 43 unpadded base64url characters. The gateway checks the signature; the
64
+ # SDK only carries it.
61
65
  #
62
66
  # \A...\z, never ^...$: Ruby's ^ and $ are line anchors, so a ref ending in a
63
67
  # newline would otherwise parse.
64
68
  CLAIM_REF_PATTERN = /\Aurn:ankusa:claim:v1:([A-Za-z0-9_-]{1,64}):([0-7][0-9A-HJKMNP-TV-Z]{25})\z/
69
+ CLAIM_REF_V2_PATTERN =
70
+ /\Aurn:ankusa:claim:v2:([A-Za-z0-9_-]{1,64}):([0-7][0-9A-HJKMNP-TV-Z]{25}):([1-9][0-9]{0,11}):([A-Za-z0-9_-]{43})\z/
65
71
 
66
72
  SHA256_PATTERN = /\A[0-9a-f]{64}\z/
67
73
 
68
74
  # Parses a claim-check ref (the `claim` field of a queue message) into the
69
- # tenant id, claim id, and GET /v1/claims/{tenant_id}/{claim_id} path. Raises
70
- # `InvalidClaimRefError` -- never worth retrying -- if `ref` isn't a
71
- # well-formed ref.
75
+ # tenant id, claim id, and GET /v1/claims/{tenant_id}/{claim_id} path (with
76
+ # ?exp=&sig= for a v2 ref). Raises `InvalidClaimRefError` -- never worth
77
+ # retrying -- if `ref` isn't a well-formed ref.
72
78
  def self.parse_claim_ref(ref)
73
- match = ref.is_a?(String) ? CLAIM_REF_PATTERN.match(ref) : nil
79
+ match = ref.is_a?(String) ? (CLAIM_REF_V2_PATTERN.match(ref) || CLAIM_REF_PATTERN.match(ref)) : nil
74
80
  raise InvalidClaimRefError, "invalid claim-check ref: #{ref}" if match.nil?
75
81
 
76
- tenant_id = match[1]
77
- claim_id = match[2]
78
- ParsedClaimRef.new(tenant_id: tenant_id, claim_id: claim_id, path: "/v1/claims/#{tenant_id}/#{claim_id}")
82
+ tenant_id, claim_id, exp, sig = match.captures
83
+ path = "/v1/claims/#{tenant_id}/#{claim_id}"
84
+ path += "?exp=#{exp}&sig=#{sig}" if sig
85
+ ParsedClaimRef.new(tenant_id: tenant_id, claim_id: claim_id, path: path)
79
86
  end
80
87
 
81
88
  # Redeem claim-check refs against a deployment's claim-check gateway.
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ankusa
6
+ # One decoded lifecycle event: the CloudEvents 1.0 (structured mode) event an
7
+ # Ankusa instance publishes when a source or a route is created, updated or
8
+ # deleted, when a tenant is erased, or when the instance is restored from a
9
+ # backup. It arrives as the body of a queue message from the instance's
10
+ # lifecycle sinks (content type `application/cloudevents+json`, `source_id`
11
+ # `ankusa:lifecycle`): decode the message with `Ankusa.decode_message`,
12
+ # redeem its body, and hand the bytes to `Ankusa.decode_lifecycle`.
13
+ #
14
+ # Delivery is at least once and in commit order. `id` is derived from the
15
+ # change, so the same event always has the same id, and `revision` (per
16
+ # `subject`) lets a consumer ignore a stale update. `sequence` is the
17
+ # instance's audit sequence number, the decimal string it is on the wire.
18
+ # `data` is the entity as the admin API shows it, secrets redacted (nil when
19
+ # the event carries none). An optional field the event omitted or said `null`
20
+ # is nil.
21
+ LifecycleEvent = Data.define(
22
+ :specversion, :id, :source, :type, :subject, :time, :datacontenttype, :data, :revision,
23
+ :actor, :instance, :sequence, :replayid, :traceparent, :tracestate
24
+ )
25
+
26
+ LIFECYCLE_REQUIRED_FIELDS = %w[id source type subject time].freeze
27
+ LIFECYCLE_DECIMAL = /\A[0-9]+\z/
28
+ private_constant :LIFECYCLE_REQUIRED_FIELDS, :LIFECYCLE_DECIMAL
29
+
30
+ # Decodes one lifecycle event (the JSON of the CloudEvent) into a
31
+ # `LifecycleEvent`. The checks run in a fixed order and the first failure
32
+ # wins, raising `InvalidMessageError` (never retryable): `invalid_json`,
33
+ # `not_an_object`, `unsupported_version` (`specversion` is not "1.0") or
34
+ # `invalid_field` with the offending `field`. Keys and event types this SDK
35
+ # does not know are ignored, so a consumer keeps working when a newer node
36
+ # announces more.
37
+ def self.decode_lifecycle(data)
38
+ parsed =
39
+ begin
40
+ JSON.parse(data)
41
+ rescue JSON::ParserError
42
+ raise InvalidMessageError.new("event is not JSON", code: "invalid_json")
43
+ end
44
+
45
+ unless parsed.is_a?(Hash)
46
+ raise InvalidMessageError.new("event is not a JSON object", code: "not_an_object")
47
+ end
48
+
49
+ unless parsed["specversion"] == "1.0"
50
+ raise InvalidMessageError.new("unsupported specversion", code: "unsupported_version")
51
+ end
52
+
53
+ LIFECYCLE_REQUIRED_FIELDS.each do |field|
54
+ value = parsed[field]
55
+ lifecycle_invalid_field(field) unless value.is_a?(String) && !value.empty?
56
+ end
57
+
58
+ datacontenttype = lifecycle_optional_string(parsed, "datacontenttype")
59
+
60
+ body = parsed["data"]
61
+ lifecycle_invalid_field("data") unless body.nil? || body.is_a?(Hash)
62
+
63
+ revision = parsed["revision"]
64
+ lifecycle_invalid_field("revision") unless revision.nil? || lifecycle_revision?(revision)
65
+
66
+ actor = lifecycle_optional_string(parsed, "actor")
67
+ instance = lifecycle_optional_string(parsed, "instance")
68
+
69
+ sequence = parsed["sequence"]
70
+ lifecycle_invalid_field("sequence") unless sequence.nil? || lifecycle_decimal?(sequence)
71
+
72
+ LifecycleEvent.new(
73
+ specversion: "1.0",
74
+ id: parsed["id"],
75
+ source: parsed["source"],
76
+ type: parsed["type"],
77
+ subject: parsed["subject"],
78
+ time: parsed["time"],
79
+ datacontenttype: datacontenttype,
80
+ data: body,
81
+ revision: revision,
82
+ actor: actor,
83
+ instance: instance,
84
+ sequence: sequence,
85
+ replayid: lifecycle_optional_string(parsed, "replayid"),
86
+ traceparent: lifecycle_optional_string(parsed, "traceparent"),
87
+ tracestate: lifecycle_optional_string(parsed, "tracestate")
88
+ )
89
+ end
90
+
91
+ def self.lifecycle_optional_string(parsed, field)
92
+ value = parsed[field]
93
+ lifecycle_invalid_field(field) unless value.nil? || value.is_a?(String)
94
+ value
95
+ end
96
+ private_class_method :lifecycle_optional_string
97
+
98
+ def self.lifecycle_revision?(value)
99
+ value.is_a?(Integer) && value >= 1
100
+ end
101
+ private_class_method :lifecycle_revision?
102
+
103
+ def self.lifecycle_decimal?(value)
104
+ value.is_a?(String) && LIFECYCLE_DECIMAL.match?(value)
105
+ end
106
+ private_class_method :lifecycle_decimal?
107
+
108
+ def self.lifecycle_invalid_field(field)
109
+ raise InvalidMessageError.new("invalid or missing field: #{field}", code: "invalid_field", field: field)
110
+ end
111
+ private_class_method :lifecycle_invalid_field
112
+ end
@@ -47,17 +47,32 @@ module Ankusa
47
47
  end
48
48
  private_constant :IdempotencyKey
49
49
 
50
- # One decoded v1 queue message. `body` holds the inline bytes (or nil for the
50
+ # The message versions `Ankusa.decode_message` understands, both ends
51
+ # inclusive. `v` is the wire format's major version: a `v: 2` message is a
52
+ # `v: 1` message plus additive fields, decoded exactly like it (the keys this
53
+ # decoder does not know are ignored) into the one `Message` type, with `v` as
54
+ # the wire said it. Raising `MAX_SUPPORTED_VERSION` is the whole change an
55
+ # SDK needs for a producer that starts writing the next additive version.
56
+ MIN_SUPPORTED_VERSION = 1
57
+ MAX_SUPPORTED_VERSION = 2
58
+
59
+ # One decoded queue message; `v` is the wire's. `body` holds the inline bytes (or nil for the
51
60
  # claim form, where the bytes are fetched with `Ankusa::ClaimCheckClient`);
52
61
  # `claim` holds the ref string in that case. `headers` is the forwarded
53
- # provider request headers, `{}` when the message carried none.
62
+ # provider request headers, `{}` when the message carried none. `traceparent`
63
+ # and `tracestate` are the hook's W3C trace context, verbatim (nil when the
64
+ # message carries none): continue the trace from them. `instance` and `region`
65
+ # are the name of the Ankusa instance that published the message and the
66
+ # region it is configured with (nil when the message says null or predates
67
+ # the fields).
54
68
  #
55
69
  # `idempotency_key` is the one value a consumer should dedupe on, as
56
70
  # `Message#idempotency_key` (the field is nil only for a message from a node
57
71
  # that predates it); see `Ankusa.decode_message` for the decode rules.
58
72
  Message = Data.define(
59
73
  :v, :id, :source_id, :tenant_id, :received_at, :content_type, :size,
60
- :body, :claim, :sha256, :dedupe_key, :replay_id, :idempotency_key, :headers
74
+ :body, :claim, :sha256, :dedupe_key, :replay_id, :idempotency_key, :headers, :verification,
75
+ :traceparent, :tracestate, :instance, :region
61
76
  ) do
62
77
  # `Data.define` made `idempotency_key` the reader of the decoded field; keep
63
78
  # that value reachable here and let the helper below take over the name.
@@ -80,14 +95,24 @@ module Ankusa
80
95
  dedupe_key: dedupe_key, id: id, replay_id: replay_id, include_replay: include_replay
81
96
  )
82
97
  end
98
+
99
+ # Whether the node verified this message's hook at ingest and nothing flags
100
+ # it (see `Verification#trusted?`). A message with no `verification` (a node
101
+ # older than the field) is untrusted.
102
+ def trusted?
103
+ verification&.trusted? == true
104
+ end
83
105
  end
84
106
 
85
107
  # The optional string-keyed fields that may be a string, null, or absent.
86
108
  # Checked in this order, so the first offending key is the one reported.
87
- MESSAGE_STRING_FIELDS = %w[tenant_id content_type dedupe_key replay_id idempotency_key].freeze
109
+ MESSAGE_STRING_FIELDS = %w[
110
+ tenant_id content_type dedupe_key replay_id idempotency_key traceparent tracestate instance region
111
+ ].freeze
88
112
  private_constant :MESSAGE_STRING_FIELDS
89
113
 
90
- # Decodes one v1 queue message (the JSON body a sink or broker delivers) into
114
+ # Decodes one queue message of any supported `v` (`MIN_SUPPORTED_VERSION` to
115
+ # `MAX_SUPPORTED_VERSION`; the JSON body a sink or broker delivers) into
91
116
  # a `Message`, applying every rule in "Consuming queue messages" of the SDK
92
117
  # README in order; the first failure wins.
93
118
  #
@@ -107,7 +132,7 @@ module Ankusa
107
132
  end
108
133
 
109
134
  version = parsed["v"]
110
- unless version.is_a?(Integer) && version == 1
135
+ unless version.is_a?(Integer) && version.between?(MIN_SUPPORTED_VERSION, MAX_SUPPORTED_VERSION)
111
136
  raise InvalidMessageError.new("unsupported message version: #{version.inspect}", code: "unsupported_version")
112
137
  end
113
138
 
@@ -135,6 +160,8 @@ module Ankusa
135
160
  valid_sha256 = sha256.nil? || (sha256.is_a?(String) && SHA256_PATTERN.match?(sha256))
136
161
  invalid_field("sha256") unless valid_sha256
137
162
 
163
+ verification = decode_verification(parsed["verification"])
164
+
138
165
  body_base64 = parsed["body_base64"]
139
166
  claim = parsed["claim"]
140
167
 
@@ -172,7 +199,7 @@ module Ankusa
172
199
  end
173
200
 
174
201
  Message.new(
175
- v: 1,
202
+ v: version,
176
203
  id: id,
177
204
  source_id: source_id,
178
205
  tenant_id: tenant_id,
@@ -185,10 +212,32 @@ module Ankusa
185
212
  dedupe_key: parsed["dedupe_key"],
186
213
  replay_id: parsed["replay_id"],
187
214
  idempotency_key: parsed["idempotency_key"],
188
- headers: headers
215
+ headers: headers,
216
+ verification: verification,
217
+ traceparent: parsed["traceparent"],
218
+ tracestate: parsed["tracestate"],
219
+ instance: parsed["instance"],
220
+ region: parsed["region"]
189
221
  )
190
222
  end
191
223
 
224
+ # Absent or explicit `null` is `nil` (a node that predates the field).
225
+ # Anything else must be `{status: <non-empty string>, flagged: <boolean>}`;
226
+ # the status value itself is never validated, so unknown statuses pass
227
+ # through untouched.
228
+ def self.decode_verification(value)
229
+ return nil if value.nil?
230
+ invalid_field("verification") unless value.is_a?(Hash)
231
+
232
+ status = value["status"]
233
+ flagged = value["flagged"]
234
+ valid = status.is_a?(String) && !status.empty? && [true, false].include?(flagged)
235
+ invalid_field("verification") unless valid
236
+
237
+ Verification.new(status: status, flagged: flagged)
238
+ end
239
+ private_class_method :decode_verification
240
+
192
241
  def self.invalid_field(field)
193
242
  raise InvalidMessageError.new("invalid message field: #{field}", code: "invalid_field", field: field)
194
243
  end
data/lib/ankusa/routes.rb CHANGED
@@ -37,18 +37,20 @@ module Ankusa
37
37
  # The listener rejected the request (400 or any other non-404 4xx).
38
38
  #
39
39
  # `code` is the body's `error` field (`invalid_route`, `duplicate_route`,
40
- # `too_many_routes`, ...); `field`, `message`, `conflicting_id` and
41
- # `max_routes` are carried through when the body supplies them.
40
+ # `too_many_routes`, ...); `field`, `message`, `conflicting_id`,
41
+ # `max_routes` and `scope` are carried through when the body supplies them.
42
+ # `scope` is the scope a 403 forbidden says the credential lacks.
42
43
  class RoutesRejectedError < RoutesError
43
- attr_reader :status, :code, :field, :conflicting_id, :max_routes
44
+ attr_reader :status, :code, :field, :conflicting_id, :max_routes, :scope
44
45
 
45
- def initialize(message, status:, code:, field: nil, conflicting_id: nil, max_routes: nil)
46
+ def initialize(message, status:, code:, field: nil, conflicting_id: nil, max_routes: nil, scope: nil)
46
47
  super(message)
47
48
  @status = status
48
49
  @code = code
49
50
  @field = field
50
51
  @conflicting_id = conflicting_id
51
52
  @max_routes = max_routes
53
+ @scope = scope
52
54
  end
53
55
  end
54
56
 
@@ -149,7 +151,8 @@ module Ankusa
149
151
  code: field(body, "error"),
150
152
  field: field(body, "field"),
151
153
  conflicting_id: field(body, "conflicting_id"),
152
- max_routes: field(body, "max_routes")
154
+ max_routes: field(body, "max_routes"),
155
+ scope: field(body, "scope")
153
156
  )
154
157
  end
155
158
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ankusa
4
4
  module SDK
5
- VERSION = "0.5.0"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
data/lib/ankusa/sdk.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "webhook"
8
8
  require_relative "signature"
9
9
  require_relative "claim_check"
10
10
  require_relative "message"
11
+ require_relative "lifecycle"
11
12
  require_relative "routes"
12
13
  require_relative "admin"
13
14
  require_relative "sources"
@@ -1,6 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ankusa
4
+ # How Ankusa verified the hook at ingest. `status` is `ok`, `failed` or
5
+ # `skipped`, but other values pass through untouched (forward-compatible).
6
+ # `flagged` is true when a failed check was accepted
7
+ # (`on_verify_failure: accept_flag`): treat the delivery as unverified.
8
+ Verification = Data.define(:status, :flagged) do
9
+ # Whether the node verified the hook and nothing flags it: true only for
10
+ # status "ok" with `flagged` false. A flagged hook, a "failed" or "skipped"
11
+ # one, and any other status are untrusted.
12
+ def trusted?
13
+ status == "ok" && flagged == false
14
+ end
15
+ end
16
+
4
17
  # The identity of one HTTP-sink delivery: the headers Ankusa's HTTP sink
5
18
  # attaches to every delivery.
6
19
  #
@@ -8,9 +21,15 @@ module Ankusa
8
21
  # nil when the delivery had no such header (a tenant only travels when the
9
22
  # source has one; `dedupe_key` only when the source declared a dedupe key;
10
23
  # `replay_id` only when the delivery is a replay; `idempotency_key` is
11
- # missing only from a sender that predates the header).
24
+ # missing only from a sender that predates the header). `received_at` (when
25
+ # the edge accepted the hook, unix milliseconds, an Integer) and `body_sha256`
26
+ # (the lowercase hex SHA-256 of the body Ankusa sent) are advisory: nil when
27
+ # the header is absent, empty or malformed (not 1 to 15 decimal digits; not
28
+ # exactly 64 lowercase hex characters), never an error. `verification` is nil
29
+ # when `x-ankusa-verification` is absent or empty.
12
30
  HookHeaders = Data.define(
13
- :id, :source, :tenant, :content_type, :dedupe_key, :replay_id, :idempotency_key
31
+ :id, :source, :tenant, :content_type, :dedupe_key, :replay_id, :idempotency_key,
32
+ :received_at, :body_sha256, :verification
14
33
  ) do
15
34
  # `Data.define` made `idempotency_key` the reader of the parsed header; keep
16
35
  # that value reachable here and let the helper below take over the name.
@@ -33,6 +52,13 @@ module Ankusa
33
52
  dedupe_key: dedupe_key, id: id, replay_id: replay_id, include_replay: include_replay
34
53
  )
35
54
  end
55
+
56
+ # Whether the node verified this delivery at ingest and nothing flags it
57
+ # (see `Verification#trusted?`). A delivery with no `x-ankusa-verification`
58
+ # header (a node older than the field) is untrusted.
59
+ def trusted?
60
+ verification&.trusted? == true
61
+ end
36
62
  end
37
63
 
38
64
  # Raised by `Ankusa.parse_headers` when `x-ankusa-id` is absent or empty.
@@ -62,7 +88,10 @@ module Ankusa
62
88
  content_type: lowered["content-type"],
63
89
  dedupe_key: presence(lowered["x-ankusa-dedupe-key"]),
64
90
  replay_id: presence(lowered["x-ankusa-replay-id"]),
65
- idempotency_key: presence(lowered["x-ankusa-idempotency-key"])
91
+ idempotency_key: presence(lowered["x-ankusa-idempotency-key"]),
92
+ received_at: parse_received_at(lowered["x-ankusa-received-at"]),
93
+ body_sha256: parse_body_sha256(lowered["x-ankusa-body-sha256"]),
94
+ verification: parse_verification(lowered)
66
95
  )
67
96
  end
68
97
 
@@ -72,4 +101,65 @@ module Ankusa
72
101
  (value.nil? || value.empty?) ? nil : value
73
102
  end
74
103
  private_class_method :presence
104
+
105
+ # An Integer for 1 to 15 decimal digits and nothing else, else nil.
106
+ def self.parse_received_at(value)
107
+ value.to_i if value&.match?(/\A[0-9]{1,15}\z/)
108
+ end
109
+ private_class_method :parse_received_at
110
+
111
+ # The value when it is exactly 64 lowercase hex characters, else nil.
112
+ def self.parse_body_sha256(value)
113
+ value if value&.match?(/\A[0-9a-f]{64}\z/)
114
+ end
115
+ private_class_method :parse_body_sha256
116
+
117
+ # `nil` when `x-ankusa-verification` is absent or empty; otherwise the
118
+ # header's value as `status`, and `flagged` true exactly when
119
+ # `x-ankusa-verification-flagged` is the literal string "true".
120
+ def self.parse_verification(lowered)
121
+ status = presence(lowered["x-ankusa-verification"])
122
+ return nil if status.nil?
123
+
124
+ Verification.new(status: status, flagged: lowered["x-ankusa-verification-flagged"] == "true")
125
+ end
126
+ private_class_method :parse_verification
127
+
128
+ # An accepted delivery: its identity (`HookHeaders`), and whether its
129
+ # signature verified (`false` only for an unsigned delivery `allow_unsigned`
130
+ # let through).
131
+ ReceivedDelivery = Data.define(:headers, :verified)
132
+
133
+ # Judges one HTTP-sink delivery: is it signed, and what is its identity.
134
+ #
135
+ # Ankusa's HTTP sink signs every delivery unless it is configured
136
+ # `allow_unsigned: true`, so a receiver is closed by default: without the
137
+ # sink's `secrets` (or an explicit `allow_unsigned: true`) it refuses every
138
+ # delivery, because any host that can reach the receiver's URL could
139
+ # otherwise inject hooks. With `secrets` the delivery MUST verify
140
+ # (`Ankusa.verify_signature`); `allow_unsigned` does not weaken that.
141
+ #
142
+ # `body` must be the raw bytes received. Raises `InvalidSignatureError`
143
+ # (answer 401) when there is no valid signature -- `invalid_secret` when no
144
+ # secret is configured and `allow_unsigned` is not set -- and
145
+ # `MissingHookIdError` (400) when `x-ankusa-id` is absent. The signature is
146
+ # judged first.
147
+ def self.receive_delivery(headers, body, secrets: [], allow_unsigned: false, tolerance_seconds: 300, now: nil)
148
+ configured = Array(secrets)
149
+ verified =
150
+ if !configured.empty?
151
+ verify_signature(headers, body, configured, tolerance_seconds: tolerance_seconds, now: now)
152
+ true
153
+ elsif allow_unsigned
154
+ false
155
+ else
156
+ raise InvalidSignatureError.new(
157
+ "no secret configured: deliveries are signed, so the receiver needs the sink's secret " \
158
+ "(or allow_unsigned: true)",
159
+ code: "invalid_secret"
160
+ )
161
+ end
162
+
163
+ ReceivedDelivery.new(headers: parse_headers(headers), verified: verified)
164
+ end
75
165
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ankusa-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Carr
@@ -24,6 +24,7 @@ files:
24
24
  - lib/ankusa/claim_check.rb
25
25
  - lib/ankusa/connection.rb
26
26
  - lib/ankusa/error.rb
27
+ - lib/ankusa/lifecycle.rb
27
28
  - lib/ankusa/message.rb
28
29
  - lib/ankusa/routes.rb
29
30
  - lib/ankusa/sdk.rb