hookd-client 1.5.1 → 1.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: ea1070dc0c7ba33c48d14a8e0f15bfa8eebb0d0ce2760c163705edaa80ef523e
4
- data.tar.gz: c9908f45314c965d5b8038945fe3b7000c966bc62169e089cb772845feedfc30
3
+ metadata.gz: 34ae0dd926717518a70cad0cb175f770147117b2154e758d4c2923d365d1b38a
4
+ data.tar.gz: 879aef64cc1a3bdae6c98c8206bd975f1352a03dc56b1267951445a37edc0e3f
5
5
  SHA512:
6
- metadata.gz: 4a6675e6a729a717c565d580537063e8c11b6149d4b2da879439e0b762d97c0df87076f9d9e48e4ca507487b6460f8a0cb0e0bf0a2a43ea39ea6e379a1561762
7
- data.tar.gz: 994aee67d64f6331efcfac36ddf54fd9a56c1c558140a9720ab99e7a3362fcd8f2ea7dcbf35887f1a0224dec0e9262c7671de3ca14e7635698493cbaade18cbf
6
+ metadata.gz: c2ee6ea4abf3dd624ec309c0c8a59b0245ba4f630504f4a2e3fb9d06c7c9449a6e057ee6aa0907a55b988f567ae6c93addb2c6f4c2a2762ac353c1d96814d46e
7
+ data.tar.gz: f11ab8a0431425559449126b85a87d158257c56f7714f6a67fd0b70862e6ffae561ec04d74d7ff72fec69f41ecd8647f272f0b8bf6330e5f3f4ff8cf799f1e13
data/README.md CHANGED
@@ -110,13 +110,40 @@ end
110
110
 
111
111
  The client requires two configuration parameters:
112
112
 
113
- - `server`: The Hookd server URL (e.g., `https://hookd.example.com`)
113
+ - `server`: The Hookd server URL (e.g., `https://hookd.example.com`). When the server runs its private API listener (`server.api.listener`), use that listener's address instead, e.g. `https://hookd.example.com:8443`
114
114
  - `token`: Authentication token for API access
115
115
 
116
116
  An optional `max_response_bytes:` caps the response payload the client will read
117
117
  (64 MiB by default; zero or less disables it). Exceeding it raises
118
118
  `Hookd::ResponseTooLargeError`.
119
119
 
120
+ ### Owner Secrets
121
+
122
+ When the server enables owner secrets (`server.api.owner_secret.enabled`), each
123
+ hook is readable only with the secret it was registered under, sent as the
124
+ `X-Hookd-Secret` header. Without a `secret:`, the first registration adopts the
125
+ one the server generates; every later request, registrations included, sends
126
+ it, so all the client's hooks share one owner. Store it to read them later:
127
+
128
+ ```ruby
129
+ hook = client.register
130
+ secret = client.secret # generated by the server, returned only once
131
+
132
+ # Later, or from another process:
133
+ client = Hookd::Client.new(server: server, token: token, secret: secret)
134
+ client.poll(hook.id)
135
+ ```
136
+
137
+ Every hook `#register` and `#register_batch` return also carries its owner
138
+ secret in `hook.secret`, so it is never lost, even if the client's secret
139
+ changes in the meantime.
140
+
141
+ To use your own secret (32 to 256 visible ASCII characters, at least 128 random
142
+ bits), pass `secret:` or set `client.secret =` before registering. For one owner
143
+ per hook, set `client.secret = nil` before each registration and keep each
144
+ `hook.secret`. On a server without owner secrets, nothing changes and
145
+ `client.secret` stays `nil`.
146
+
120
147
  ### API Reference
121
148
 
122
149
  #### `Hookd::Client`
data/lib/hookd/client.rb CHANGED
@@ -7,6 +7,8 @@ require 'uri'
7
7
  module Hookd
8
8
  # HTTP client for interacting with Hookd server
9
9
  class Client
10
+ include OwnerSecret
11
+
10
12
  # Caps the payload materialised as a String. Generous, since a poll can
11
13
  # return many interactions with full bodies. Zero or less disables it.
12
14
  DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024
@@ -16,10 +18,13 @@ module Hookd
16
18
 
17
19
  attr_reader :server, :token, :max_response_bytes
18
20
 
19
- def initialize(server:, token:, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES)
21
+ # @param secret [String, nil] owner secret sent with every request; when nil,
22
+ # the first registration adopts the one the server generates
23
+ def initialize(server:, token:, secret: nil, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES)
20
24
  @server = server
21
25
  @token = token
22
26
  @max_response_bytes = max_response_bytes
27
+ init_secret(secret)
23
28
  @http = HTTPX.with(
24
29
  headers: { 'X-API-Key' => token },
25
30
  timeout: {
@@ -44,7 +49,7 @@ module Hookd
44
49
  def register(count: nil, ttl: nil, metadata: nil)
45
50
  raise ArgumentError, 'count must be a positive integer' if count && (!count.is_a?(Integer) || count < 1)
46
51
 
47
- parse_register_response(post('/register', register_body(count, ttl, metadata)))
52
+ parse_register_response(*post_register(register_body(count, ttl, metadata)))
48
53
  end
49
54
 
50
55
  # Register one hook per spec in a single request, each with its own ttl and
@@ -56,7 +61,7 @@ module Hookd
56
61
  raise ArgumentError, 'specs must be a non-empty array' unless specs.is_a?(Array) && !specs.empty?
57
62
 
58
63
  body = specs.map { |spec| register_body(nil, spec[:ttl], spec[:metadata]) || {} }
59
- hook_list(post('/register', { hooks: body }))
64
+ hook_list(*post_register({ hooks: body }))
60
65
  end
61
66
 
62
67
  # List long-lived hooks, without interactions, whose metadata matches every
@@ -98,7 +103,7 @@ module Hookd
98
103
  validate_hook_ids(hook_ids)
99
104
 
100
105
  url = "#{@server}/poll"
101
- options = { headers: { 'Content-Type' => 'application/json' }, json: hook_ids }
106
+ options = { headers: { 'Content-Type' => 'application/json' }.merge(secret_headers), json: hook_ids }
102
107
  response = @http.post(url, **options)
103
108
  response_data = handle_response(response)
104
109
 
@@ -135,7 +140,8 @@ module Hookd
135
140
  # @raise [ArgumentError] if through is invalid
136
141
  def ack(hook_id, through:)
137
142
  validate_cursor(through, 'through')
138
- handle_response(@http.delete("#{@server}/poll/#{hook_id}?through=#{through}"))['acknowledged']
143
+ url = "#{@server}/poll/#{hook_id}?through=#{through}"
144
+ handle_response(@http.delete(url, headers: secret_headers))['acknowledged']
139
145
  end
140
146
 
141
147
  # Read several hooks past their cursors in one request
@@ -200,25 +206,25 @@ module Hookd
200
206
  body.empty? ? nil : body
201
207
  end
202
208
 
203
- def parse_register_response(response)
209
+ def parse_register_response(response, secret)
204
210
  # Single hook response (backward compatible)
205
- return Hook.from_hash(response) if response.key?('id')
211
+ return Hook.from_hash(response, secret: secret) if response.key?('id')
206
212
 
207
213
  # Multiple hooks response
208
214
  return [] if response['hooks'].nil? || response['hooks'].empty?
209
215
 
210
- response['hooks'].map { |h| Hook.from_hash(h) }
216
+ response['hooks'].map { |h| Hook.from_hash(h, secret: secret) }
211
217
  end
212
218
 
213
219
  def get(path)
214
220
  url = "#{@server}#{path}"
215
- response = @http.get(url)
221
+ response = @http.get(url, headers: secret_headers)
216
222
  handle_response(response)
217
223
  end
218
224
 
219
- def post(path, body = nil)
225
+ def post(path, body = nil, as: secret)
220
226
  url = "#{@server}#{path}"
221
- options = { headers: { 'Content-Type' => 'application/json' } }
227
+ options = { headers: { 'Content-Type' => 'application/json' }.merge(secret_headers(as)) }
222
228
  options[:json] = body if body
223
229
 
224
230
  response = @http.post(url, **options)
@@ -231,11 +237,11 @@ module Hookd
231
237
  "?#{URI.encode_www_form(metadata.to_h { |k, v| ["metadata.#{k}", v.to_s] })}"
232
238
  end
233
239
 
234
- def hook_list(response)
240
+ def hook_list(response, secret = nil)
235
241
  hooks = response['hooks']
236
242
  raise Error, 'Invalid response format: missing hooks' unless hooks.is_a?(Array)
237
243
 
238
- hooks.map { |h| Hook.from_hash(h) }
244
+ hooks.map { |h| Hook.from_hash(h, secret: secret) }
239
245
  end
240
246
 
241
247
  def validate_cursor(seq, name)
data/lib/hookd/hook.rb CHANGED
@@ -3,11 +3,12 @@
3
3
  module Hookd
4
4
  # Represents a registered hook with its endpoints. smtp is set only when the
5
5
  # server runs a mail listener. expires_at and metadata are populated for
6
- # long-lived hooks (registered with a ttl) and nil otherwise.
6
+ # long-lived hooks (registered with a ttl) and nil otherwise. secret is the
7
+ # owner secret a registered hook belongs to, on servers with owner secrets.
7
8
  class Hook
8
- attr_reader :id, :dns, :http, :https, :smtp, :created_at, :expires_at, :metadata
9
+ attr_reader :id, :dns, :http, :https, :smtp, :created_at, :expires_at, :metadata, :secret
9
10
 
10
- def initialize(id:, dns:, http:, https:, created_at:, smtp: nil, expires_at: nil, metadata: nil)
11
+ def initialize(id:, dns:, http:, https:, created_at:, smtp: nil, expires_at: nil, metadata: nil, secret: nil)
11
12
  @id = id
12
13
  @dns = dns
13
14
  @http = http
@@ -16,10 +17,11 @@ module Hookd
16
17
  @created_at = created_at
17
18
  @expires_at = expires_at
18
19
  @metadata = metadata
20
+ @secret = secret
19
21
  end
20
22
 
21
23
  # Create a Hook from API response hash
22
- def self.from_hash(hash)
24
+ def self.from_hash(hash, secret: nil)
23
25
  raise ArgumentError, "Invalid hash: expected Hash, got #{hash.class}" unless hash.is_a?(Hash)
24
26
 
25
27
  new(
@@ -30,7 +32,8 @@ module Hookd
30
32
  smtp: hash['smtp'],
31
33
  created_at: hash['created_at'],
32
34
  expires_at: hash['expires_at'],
33
- metadata: hash['metadata']
35
+ metadata: hash['metadata'],
36
+ secret: secret
34
37
  )
35
38
  end
36
39
 
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hookd
4
+ # Owner secret handling for servers with owner secrets enabled, where each
5
+ # hook is readable only with the secret it was registered under.
6
+ module OwnerSecret
7
+ # Header carrying the owner secret.
8
+ SECRET_HEADER = 'X-Hookd-Secret'
9
+
10
+ # The owner secret in use, possibly generated by the server on the first
11
+ # registration. Store it to read the hooks later.
12
+ # @return [String, nil]
13
+ def secret
14
+ @secret_lock.synchronize { @secret }
15
+ end
16
+
17
+ # Set the owner secret; nil lets the next registration adopt a new one.
18
+ # @param value [String, nil]
19
+ def secret=(value)
20
+ value = nil if value == ''
21
+ @secret_lock.synchronize do
22
+ @secret = value
23
+ @settled = !value.nil?
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def init_secret(value)
30
+ @secret_lock = Mutex.new
31
+ @register_lock = Mutex.new
32
+ self.secret = value
33
+ end
34
+
35
+ # Registers and returns the response with the secret its hooks belong to:
36
+ # the one sent, or the one the server generated, adopted when the client
37
+ # has none. Until a secret is known, registrations run one at a time, so
38
+ # concurrent first calls end up under the same owner.
39
+ def post_register(body)
40
+ return register_as(body) if @secret_lock.synchronize { @settled }
41
+
42
+ @register_lock.synchronize { register_as(body) }
43
+ end
44
+
45
+ def register_as(body)
46
+ sent = secret
47
+ response = post('/register', body, as: sent)
48
+ owner = (response['secret'] if response.is_a?(Hash)) || sent
49
+ @secret_lock.synchronize do
50
+ @secret ||= owner
51
+ @settled = true
52
+ end
53
+ [response, owner]
54
+ end
55
+
56
+ def secret_headers(value = secret)
57
+ value ? { SECRET_HEADER => value } : {}
58
+ end
59
+ end
60
+ end
data/lib/hookd/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hookd
4
- VERSION = '1.5.1'
4
+ VERSION = '1.6.0'
5
5
  end
data/lib/hookd.rb CHANGED
@@ -6,6 +6,7 @@ require_relative 'hookd/hook'
6
6
  require_relative 'hookd/hook_activity'
7
7
  require_relative 'hookd/interaction'
8
8
  require_relative 'hookd/cursor_read'
9
+ require_relative 'hookd/owner_secret'
9
10
  require_relative 'hookd/client'
10
11
 
11
12
  # Hookd client library for interacting with Hookd DNS/HTTP interaction server
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hookd-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.1
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua MARTINELLE
@@ -39,6 +39,7 @@ files:
39
39
  - lib/hookd/hook.rb
40
40
  - lib/hookd/hook_activity.rb
41
41
  - lib/hookd/interaction.rb
42
+ - lib/hookd/owner_secret.rb
42
43
  - lib/hookd/version.rb
43
44
  homepage: https://github.com/JoshuaMart/Hookd
44
45
  licenses: