mobiscroll-connect 1.5.0 → 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: b741101bdf4d7452a820bd7b988d9dff51f02cf4683d867fb332292ab0a4177e
4
- data.tar.gz: 1516abffa7aea7972ebaf558c77bcd8999c397606bf66b9cf27f7519bc5198ea
3
+ metadata.gz: bfda16db0b33649065d21a3df62ae7f3b6962adaa0e57fd11944b5e0edce780f
4
+ data.tar.gz: 476dd4536d5bae00d3e169d26c30bb98ac3ab24aa82fe4b4b65a47b55cb57bea
5
5
  SHA512:
6
- metadata.gz: 5778815c08e0d3cda4217a0c0b559ef6ad9e840b7c83760409875a446c9bcfcd9d26ee96caa18c74ef4ba194e9d51e6974e1b32d3ce58daec68fbb2ab510b52c
7
- data.tar.gz: '089dd3ea229d309050d63097ce83bc2b4f74a2c2513b1668a716154563b5a9d01e9e9a4ebe4ab384f1c9f9d83a3f7f804ac2dcbbc8681d831817f26a18412934'
6
+ metadata.gz: 6ffcf4cddf3cfafe432cb53bff0ebda0b0f8af4fc9d113e18fd5540cddc3328fe32d40d47b5ce8922cb32e4e758198e24a804c939e4676aa842991ebe98334e5
7
+ data.tar.gz: 35f100a37e7fdc727c8bf824726c907100286d41b4738e7aa78fe2d4f9e9fe05bdcdeef296f3898ecbe14193842957132e1bc2ccddcb8ab86448015ff49a9232
data/CHANGELOG.md ADDED
@@ -0,0 +1,57 @@
1
+ # Changelog
2
+
3
+ All notable changes to the Mobiscroll Connect Ruby SDK (RubyGems: `mobiscroll-connect`) are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this SDK's major.minor always matches the Connect API's (for example API 1.5.x → SDK 1.5.x): each release bumps only the patch, including releases with breaking changes. Patch numbers are independent per SDK, so this file covers the Ruby SDK only.
6
+
7
+ ## [1.6.0] — 2026-10-08
8
+
9
+ ### Added
10
+
11
+ - `webhooks.verify_webhook` checks a delivery's Ed25519 signature and timestamp and returns the parsed `WebhookDelivery`, or raises `WebhookVerificationError`. It fetches and caches the public keys from `/.well-known/webhook-keys` and re-fetches them once before rejecting, so key rotations need no action.
12
+ - `webhook_public_key` client option: a pinned key, used only when the keys endpoint cannot be reached.
13
+ - `Mobiscroll::Connect.verify_webhook_signature` for verifying against keys you supply, with no fetching.
14
+
15
+ ## [1.5.1] — 2026-09-22
16
+
17
+ ### Changed
18
+
19
+ - Updated package metadata. No code or public API changes.
20
+
21
+ ## [1.5.0] — 2026-09-21
22
+
23
+ ### Added
24
+
25
+ - `webhooks` resource with `subscribe_webhook()` and `unsubscribe_webhook()` for calendar change notifications.
26
+ - `sync_state` and `sync_state_updated_at` on `ConnectedAccount`.
27
+
28
+ ## [1.2.0] — 2026-08-27
29
+
30
+ ### Added
31
+
32
+ - Granted OAuth scopes are reported on the connection status, and a missing scope now raises `CalendarPermissionError`.
33
+
34
+ ## [1.1.0] — 2026-06-03
35
+
36
+ ### Added
37
+
38
+ - Optional `lng` parameter on the authorization URL, for Connect's localized consent screens.
39
+
40
+ ## [1.0.2] — 2026-05-26
41
+
42
+ ### Added
43
+
44
+ - First published release: `auth`, `calendars` and `events` resources over Google Calendar, Microsoft Outlook, Apple Calendar and CalDAV.
45
+ - Automatic token refresh on 401 with an `on_tokens_refreshed` callback.
46
+ - Typed error hierarchy rooted at `Mobiscroll::Connect::Error`.
47
+ - Sinatra reference application under `minimal-app/`.
48
+
49
+ ### Changed
50
+
51
+ - Minimum supported Ruby raised to 3.2.
52
+
53
+ [1.5.1]: https://github.com/acidb/mobiscroll-connect-sdks/releases/tag/ruby-v1.5.1
54
+ [1.5.0]: https://github.com/acidb/mobiscroll-connect-sdks/releases/tag/ruby-v1.5.0
55
+ [1.2.0]: https://github.com/acidb/mobiscroll-connect-sdks/releases/tag/ruby-v1.2.0
56
+ [1.1.0]: https://github.com/acidb/mobiscroll-connect-sdks/releases/tag/ruby-v1.1.0
57
+ [1.0.2]: https://github.com/acidb/mobiscroll-connect-sdks/releases/tag/ruby-v1.0.2
data/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # Mobiscroll Connect Ruby SDK
2
2
 
3
- Official Ruby client for the [Mobiscroll Connect API](https://connect.mobiscroll.com). Sync calendar events across Google Calendar, Microsoft Outlook, Apple Calendar, and CalDAV.
3
+ Ruby client for [Mobiscroll Connect](https://mobiscroll.com/connect), the calendar connectivity layer for scheduling products — Google Calendar, Microsoft Outlook, Apple Calendar and CalDAV through one API. Backend only — works with your own UI.
4
+
5
+ [![RubyGems](https://img.shields.io/gem/v/mobiscroll-connect?label=RubyGems)](https://rubygems.org/gems/mobiscroll-connect)
6
+
7
+ **[RubyGems](https://rubygems.org/gems/mobiscroll-connect)** · **[Documentation](https://mobiscroll.com/docs/connect/ruby-sdk)** · **[Changelog](https://github.com/acidb/mobiscroll-connect-sdks/blob/main/sdks/ruby/CHANGELOG.md)** · **[Source](https://github.com/acidb/mobiscroll-connect-sdks/tree/main/sdks/ruby)**
4
8
 
5
9
  ## Installation
6
10
 
@@ -94,6 +98,8 @@ The SDK automatically refreshes expired access tokens. When a request returns 40
94
98
 
95
99
  Concurrent 401s share a single in-flight refresh — only one `POST /oauth/token` is ever issued per `Client` instance at a time.
96
100
 
101
+ **Running more than one instance.** The single in-flight refresh above is per `Client` instance. The SDK does not coordinate across processes — separate containers, cluster workers or serverless invocations each refresh from the tokens they hold in memory. Connect accepts concurrent refreshes of the same token, but refreshing with a copy that is two or more refreshes out of date revokes the user's authorization. Persist refreshed tokens to storage every instance reads, and call `client.set_credentials(...)` with the current tokens when a process starts a job or handles a request. See [Refreshing from several instances](https://mobiscroll.com/docs/connect/api/oauth#concurrent-refresh).
102
+
97
103
  To persist refreshed tokens (e.g., back to a session or database):
98
104
 
99
105
  ```ruby
@@ -193,6 +199,40 @@ client.webhooks.unsubscribe_webhook(
193
199
  )
194
200
  ```
195
201
 
202
+ ### Verify webhook deliveries
203
+
204
+ Every delivery to your webhook URL is signed. `verify_webhook` checks the signature and the timestamp, then returns the parsed `WebhookDelivery`, or raises `WebhookVerificationError`. Pass the **raw** request body: a JSON body parser that runs first changes the bytes and every check fails.
205
+
206
+ ```ruby
207
+ post '/webhooks/mobiscroll' do
208
+ begin
209
+ delivery = client.webhooks.verify_webhook(request.body.read, request.env)
210
+ rescue Mobiscroll::Connect::WebhookVerificationError => e
211
+ # 503 lets Connect retry when the keys could not be loaded; 401 is final.
212
+ halt(e.reason == 'no_public_keys' ? 503 : 401)
213
+ end
214
+ handle_delivery(delivery)
215
+ status 204
216
+ end
217
+ ```
218
+
219
+ `headers` can be a Hash with any key casing, a Rack env (`HTTP_WEBHOOK_ID` keys), or Rails' `request.headers` (pass `request.raw_post` as the body in Rails).
220
+
221
+ The public keys are fetched from `https://connect.mobiscroll.com/.well-known/webhook-keys` on the first delivery and cached for the whole process as the endpoint's `Cache-Control` allows. If no signature matches, the keys are fetched again (at most once a minute) before the delivery is rejected, so key rotations need no action on your side.
222
+
223
+ If your handler cannot make outbound requests, pin the key. `webhook_public_key` is used only when the keys endpoint cannot be reached; it stops working when Mobiscroll retires that key, so you must replace it on every rotation.
224
+
225
+ ```ruby
226
+ client = Mobiscroll::Connect::Client.new(
227
+ client_id: ENV['MOBISCROLL_CLIENT_ID'],
228
+ client_secret: ENV['MOBISCROLL_CLIENT_SECRET'],
229
+ redirect_uri: ENV['MOBISCROLL_REDIRECT_URI'],
230
+ webhook_public_key: ENV['MOBISCROLL_WEBHOOK_PUBLIC_KEY'] # whpk_...
231
+ )
232
+ ```
233
+
234
+ To check against keys you supply, with no fetching, call `Mobiscroll::Connect.verify_webhook_signature(raw_body, headers, ['whpk_...'])`. It raises `WebhookVerificationError` and returns `nil`. See [Verifying deliveries](https://mobiscroll.com/docs/connect/api/webhooks#verifying-deliveries).
235
+
196
236
  ## Error handling
197
237
 
198
238
  All errors are subclasses of `Mobiscroll::Connect::Error`:
@@ -225,6 +265,7 @@ end
225
265
  | `RateLimitError` | 429 | `retry_after` (seconds) |
226
266
  | `ServerError` | 5xx | `status_code` |
227
267
  | `NetworkError` | transport | `cause` |
268
+ | `WebhookVerificationError` | — (from `verify_webhook`) | `reason` |
228
269
 
229
270
  ## Minimal demo app
230
271
 
@@ -7,14 +7,15 @@ module Mobiscroll
7
7
 
8
8
  def initialize(client_id:, client_secret:, redirect_uri:,
9
9
  base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
10
- on_tokens_refreshed: nil)
10
+ on_tokens_refreshed: nil, webhook_public_key: nil)
11
11
  @config = Config.new(
12
12
  client_id: client_id,
13
13
  client_secret: client_secret,
14
14
  redirect_uri: redirect_uri,
15
15
  base_url: base_url,
16
16
  timeout: timeout,
17
- on_tokens_refreshed: on_tokens_refreshed
17
+ on_tokens_refreshed: on_tokens_refreshed,
18
+ webhook_public_key: webhook_public_key
18
19
  )
19
20
  @api_client = ApiClient.new(@config)
20
21
  @auth = Resources::Auth.new(@config, @api_client)
@@ -6,12 +6,14 @@ module Mobiscroll
6
6
  DEFAULT_TIMEOUT = 30
7
7
 
8
8
  class Config
9
- attr_reader :client_id, :client_secret, :redirect_uri, :base_url, :timeout
9
+ # `webhook_public_key` is a pinned `whpk_` key, used by Webhooks#verify_webhook only when
10
+ # the keys endpoint cannot be reached; it stops verifying once Mobiscroll retires that key.
11
+ attr_reader :client_id, :client_secret, :redirect_uri, :base_url, :timeout, :webhook_public_key
10
12
  attr_accessor :on_tokens_refreshed
11
13
 
12
14
  def initialize(client_id:, client_secret:, redirect_uri:,
13
15
  base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
14
- on_tokens_refreshed: nil)
16
+ on_tokens_refreshed: nil, webhook_public_key: nil)
15
17
  raise Error, 'client_id is required' if client_id.nil? || client_id.empty?
16
18
  raise Error, 'client_secret is required' if client_secret.nil? || client_secret.empty?
17
19
  raise Error, 'redirect_uri is required' if redirect_uri.nil? || redirect_uri.empty?
@@ -22,6 +24,7 @@ module Mobiscroll
22
24
  @base_url = base_url
23
25
  @timeout = timeout
24
26
  @on_tokens_refreshed = on_tokens_refreshed
27
+ @webhook_public_key = webhook_public_key
25
28
  end
26
29
  end
27
30
  end
@@ -78,6 +78,19 @@ module Mobiscroll
78
78
  end
79
79
  end
80
80
 
81
+ # Raised when a webhook delivery fails verification. Respond with a 4xx and do not process it.
82
+ #
83
+ # +reason+ is one of 'missing_headers', 'invalid_timestamp', 'timestamp_out_of_tolerance',
84
+ # 'no_public_keys', 'no_matching_signature' or 'invalid_payload'.
85
+ class WebhookVerificationError < Error
86
+ attr_reader :reason
87
+
88
+ def initialize(message = 'Webhook verification failed', reason: nil)
89
+ super(message, code: 'WEBHOOK_VERIFICATION_ERROR')
90
+ @reason = reason
91
+ end
92
+ end
93
+
81
94
  # Maps an HTTP response (status + body hash + headers) to the matching
82
95
  # typed error. Returns nil for 2xx responses.
83
96
  def self.map_response_error(status, body, headers)
@@ -242,6 +242,52 @@ module Mobiscroll
242
242
  end
243
243
  end
244
244
 
245
+ # A changed event in a webhook delivery: every CalendarEvent field plus `change_type`
246
+ # ('created', 'updated' or 'deleted').
247
+ WebhookEvent = Struct.new(*CalendarEvent.members, :change_type, keyword_init: true) do
248
+ def self.from_h(hash)
249
+ return nil if hash.nil?
250
+
251
+ new(**CalendarEvent.from_h(hash).to_h, change_type: hash['changeType'] || hash[:changeType])
252
+ end
253
+ end
254
+
255
+ # `metadata` of a webhook delivery.
256
+ WebhookDeliveryMetadata = Struct.new(:channel_id, :event_count, :is_initial_sync, keyword_init: true) do
257
+ def self.from_h(hash)
258
+ return nil if hash.nil?
259
+
260
+ key = 'isInitialSync'
261
+ new(
262
+ channel_id: hash['channelId'] || hash[:channelId],
263
+ event_count: hash['eventCount'] || hash[:eventCount],
264
+ is_initial_sync: hash.key?(key) ? hash[key] : hash[key.to_sym]
265
+ )
266
+ end
267
+ end
268
+
269
+ # A verified webhook delivery, returned by Webhooks#verify_webhook. `change_type` is
270
+ # 'created', 'updated', 'deleted' or 'mixed'; `timestamp` is the ISO 8601 time Connect
271
+ # processed the change.
272
+ WebhookDelivery = Struct.new(
273
+ :provider, :user_id, :calendar_id, :events, :change_type, :timestamp, :metadata, keyword_init: true
274
+ ) do
275
+ def self.from_h(hash)
276
+ return nil if hash.nil?
277
+
278
+ metadata = hash['metadata'] || hash[:metadata]
279
+ new(
280
+ provider: hash['provider'] || hash[:provider],
281
+ user_id: hash['userId'] || hash[:userId],
282
+ calendar_id: hash['calendarId'] || hash[:calendarId],
283
+ events: Array(hash['events'] || hash[:events]).filter_map { |e| WebhookEvent.from_h(e) if e.is_a?(Hash) },
284
+ change_type: hash['changeType'] || hash[:changeType],
285
+ timestamp: hash['timestamp'] || hash[:timestamp],
286
+ metadata: metadata.is_a?(Hash) ? WebhookDeliveryMetadata.from_h(metadata) : nil
287
+ )
288
+ end
289
+ end
290
+
245
291
  # Response from Webhooks#unsubscribe_webhook. `success` is true whenever the local
246
292
  # mapping was removed, even if the provider-side unsubscribe itself failed — `message`
247
293
  # explains what happened in that case.
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'uri'
4
5
 
5
6
  module Mobiscroll
6
7
  module Connect
7
8
  module Resources
8
9
  class Webhooks
9
- def initialize(_config, api_client)
10
+ RETRYABLE_REASONS = %w[no_matching_signature no_public_keys].freeze
11
+
12
+ def initialize(config, api_client)
13
+ @config = config
10
14
  @api_client = api_client
11
15
  end
12
16
 
@@ -36,6 +40,62 @@ module Mobiscroll
36
40
  parsed = @api_client.post('/unsubscribe-webhook', body: JSON.generate(body))
37
41
  UnsubscribeWebhookResponse.from_h(parsed)
38
42
  end
43
+
44
+ # Verifies that a webhook delivery came from Mobiscroll Connect, and parses it.
45
+ #
46
+ # Fetches the public keys from `/.well-known/webhook-keys` on first use and caches them for
47
+ # the whole process, refreshing them as the endpoint's `Cache-Control` allows. When no
48
+ # signature matches, it re-fetches the keys once (at most once a minute) before rejecting,
49
+ # so a key rotation never rejects genuine deliveries. `webhook_public_key` from the config
50
+ # is used only when the endpoint cannot be reached.
51
+ #
52
+ # `payload` is the raw request body String, exactly as received; not a parsed Hash.
53
+ # `headers` is a Hash (any key casing) or a Rack env / Rails `request.headers`.
54
+ #
55
+ # Returns a WebhookDelivery. Raises WebhookVerificationError when the delivery is not
56
+ # genuine; respond with a 4xx.
57
+ #
58
+ # post '/webhooks/mobiscroll' do
59
+ # delivery = client.webhooks.verify_webhook(request.body.read, request.env)
60
+ # handle_delivery(delivery)
61
+ # status 204
62
+ # rescue Mobiscroll::Connect::WebhookVerificationError
63
+ # halt 401
64
+ # end
65
+ def verify_webhook(payload, headers)
66
+ store = WebhookKeyStore.for_url(URI.join(@config.base_url, WEBHOOK_KEYS_PATH).to_s)
67
+
68
+ begin
69
+ Connect.verify_webhook_signature(payload, headers, keys_or_pinned(store.keys))
70
+ rescue WebhookVerificationError => e
71
+ raise unless RETRYABLE_REASONS.include?(e.reason) && store.can_refetch?
72
+
73
+ store.refresh
74
+ Connect.verify_webhook_signature(payload, headers, keys_or_pinned(store.keys))
75
+ end
76
+
77
+ parse_delivery(payload)
78
+ end
79
+
80
+ private
81
+
82
+ # The pinned key is never merged with fetched keys: after an emergency rotation a pinned
83
+ # retired key must stop verifying.
84
+ def keys_or_pinned(keys)
85
+ pinned = @config.webhook_public_key
86
+ keys.empty? && pinned && !pinned.empty? ? [pinned] : keys
87
+ end
88
+
89
+ def parse_delivery(payload)
90
+ parsed = begin
91
+ JSON.parse(payload.dup.force_encoding(Encoding::UTF_8))
92
+ rescue JSON::ParserError, EncodingError
93
+ nil
94
+ end
95
+ return WebhookDelivery.from_h(parsed) if parsed.is_a?(Hash)
96
+
97
+ raise WebhookVerificationError.new('Webhook payload is not a JSON object', reason: 'invalid_payload')
98
+ end
39
99
  end
40
100
  end
41
101
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Mobiscroll
4
4
  module Connect
5
- VERSION = '1.5.0'
5
+ VERSION = '1.6.0'
6
6
  end
7
7
  end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'faraday'
5
+ require 'json'
6
+ require 'openssl'
7
+
8
+ module Mobiscroll
9
+ module Connect
10
+ WEBHOOK_TOLERANCE_SECONDS = 300
11
+ WEBHOOK_KEYS_PATH = '/.well-known/webhook-keys'
12
+
13
+ # Verifies the Standard Webhooks `v1a` (Ed25519) signature of a Mobiscroll Connect delivery
14
+ # against the given public keys, without fetching anything.
15
+ #
16
+ # Use it with a pinned key in handlers that cannot make outbound requests. Otherwise prefer
17
+ # `client.webhooks.verify_webhook`, which fetches and refreshes the keys for you.
18
+ #
19
+ # `payload` is the raw request body String, exactly as received. `headers` is a Hash (any
20
+ # key casing, Array values take the first) or a Rack env / Rails `request.headers`, where
21
+ # `HTTP_WEBHOOK_ID`-style keys are matched too. `public_keys` are `whpk_` keys; the delivery
22
+ # is genuine if any signature verifies against any key. `now` is the current Unix time in
23
+ # seconds, defaulting to the system clock.
24
+ #
25
+ # Returns nil. Raises WebhookVerificationError when the delivery is not genuine or cannot
26
+ # be checked.
27
+ #
28
+ # Mobiscroll::Connect.verify_webhook_signature(
29
+ # request.body.read, request.env, [ENV.fetch('MOBISCROLL_WEBHOOK_PUBLIC_KEY')]
30
+ # )
31
+ def self.verify_webhook_signature(payload, headers, public_keys,
32
+ tolerance_seconds: WEBHOOK_TOLERANCE_SECONDS, now: nil)
33
+ WebhookSignature.verify(payload, headers, public_keys, tolerance_seconds: tolerance_seconds, now: now)
34
+ nil
35
+ end
36
+
37
+ # @api private
38
+ module WebhookSignature
39
+ PUBLIC_KEY_PREFIX = 'whpk_'
40
+ SIGNATURE_VERSION = 'v1a'
41
+ # DER SubjectPublicKeyInfo header for an Ed25519 key; `OpenSSL::PKey.new_raw_public_key`
42
+ # needs openssl gem 3.2+, which Ruby 3.2 does not bundle.
43
+ ED25519_SPKI_PREFIX = ['302a300506032b6570032100'].pack('H*').freeze
44
+
45
+ module_function
46
+
47
+ def verify(payload, headers, public_keys, tolerance_seconds:, now:)
48
+ unless payload.is_a?(String)
49
+ raise WebhookVerificationError.new(
50
+ 'Pass the raw request body as a String, not a parsed object', reason: 'invalid_payload'
51
+ )
52
+ end
53
+
54
+ id = read_header(headers, 'webhook-id')
55
+ timestamp = read_header(headers, 'webhook-timestamp')
56
+ signature_header = read_header(headers, 'webhook-signature')
57
+ if [id, timestamp, signature_header].any? { |value| value.nil? || value.empty? }
58
+ raise WebhookVerificationError.new(
59
+ 'Missing webhook-id, webhook-timestamp or webhook-signature header', reason: 'missing_headers'
60
+ )
61
+ end
62
+
63
+ check_timestamp(timestamp, tolerance_seconds, now || Time.now.to_i)
64
+
65
+ keys = Array(public_keys).filter_map { |key| parse_public_key(key) }
66
+ if keys.empty?
67
+ raise WebhookVerificationError.new('No valid webhook public keys available', reason: 'no_public_keys')
68
+ end
69
+
70
+ signed_content = "#{id}.#{timestamp}.".b << payload.b
71
+ return if signature_header.split(/ /).any? { |entry| entry_matches?(entry, signed_content, keys) }
72
+
73
+ raise WebhookVerificationError.new('No webhook signature matched', reason: 'no_matching_signature')
74
+ end
75
+
76
+ def check_timestamp(timestamp, tolerance_seconds, now)
77
+ unless timestamp.match?(/\A\d+\z/)
78
+ raise WebhookVerificationError.new('Invalid webhook-timestamp header', reason: 'invalid_timestamp')
79
+ end
80
+ return if (now - timestamp.to_i).abs <= tolerance_seconds
81
+
82
+ raise WebhookVerificationError.new(
83
+ 'Webhook timestamp is outside the allowed tolerance', reason: 'timestamp_out_of_tolerance'
84
+ )
85
+ end
86
+
87
+ def read_header(headers, name)
88
+ return nil unless headers.respond_to?(:each)
89
+
90
+ rack_name = "HTTP_#{name.upcase.tr('-', '_')}"
91
+ headers.each do |key, value|
92
+ next unless key.to_s.casecmp?(name) || key.to_s.casecmp?(rack_name)
93
+
94
+ value = value.first if value.is_a?(Array)
95
+ return value&.to_s&.b
96
+ end
97
+ nil
98
+ end
99
+
100
+ def parse_public_key(value)
101
+ return nil unless value.is_a?(String)
102
+
103
+ raw = Base64.decode64(value.strip.delete_prefix(PUBLIC_KEY_PREFIX))
104
+ return nil unless raw.bytesize == 32
105
+
106
+ OpenSSL::PKey.read(ED25519_SPKI_PREFIX + raw)
107
+ rescue OpenSSL::PKey::PKeyError
108
+ nil
109
+ end
110
+
111
+ def entry_matches?(entry, signed_content, keys)
112
+ version, separator, encoded = entry.partition(',')
113
+ return false if separator.empty? || version != SIGNATURE_VERSION
114
+
115
+ signature = Base64.decode64(encoded)
116
+ return false unless signature.bytesize == 64
117
+
118
+ keys.any? do |key|
119
+ key.verify(nil, signature, signed_content)
120
+ rescue OpenSSL::PKey::PKeyError
121
+ false
122
+ end
123
+ end
124
+ end
125
+ private_constant :WebhookSignature
126
+
127
+ # Process-wide cache of the keys published at one keys URL, shared by every client instance.
128
+ # Follows the endpoint's `Cache-Control`, re-fetches at most once a minute, and keeps the last
129
+ # good keys when a fetch fails. Concurrent callers share one in-flight fetch.
130
+ #
131
+ # @api private
132
+ class WebhookKeyStore
133
+ DEFAULT_MAX_AGE = 60 * 60
134
+ MAX_MAX_AGE = 24 * 60 * 60
135
+ MIN_REFETCH_INTERVAL = 60
136
+ FETCH_TIMEOUT = 10
137
+
138
+ @stores = {}
139
+ @stores_lock = Mutex.new
140
+
141
+ class << self
142
+ def for_url(url)
143
+ @stores_lock.synchronize { @stores[url] ||= new(url) }
144
+ end
145
+
146
+ # Clears every cached key set; for tests.
147
+ def reset!
148
+ @stores_lock.synchronize { @stores.clear }
149
+ end
150
+ end
151
+
152
+ def initialize(url)
153
+ @url = url
154
+ @keys = [].freeze
155
+ @fetched_at = nil
156
+ @last_attempt_at = nil
157
+ @max_age = DEFAULT_MAX_AGE
158
+ @in_flight = false
159
+ @lock = Mutex.new
160
+ @cond = ConditionVariable.new
161
+ end
162
+
163
+ # The current keys, fetching them first when they are missing or stale and a fetch is allowed.
164
+ def keys
165
+ fetch_if { |now| stale?(now) && can_refetch_at?(now) }
166
+ @lock.synchronize { @keys }
167
+ end
168
+
169
+ def can_refetch?
170
+ @lock.synchronize { can_refetch_at?(Time.now.to_f) }
171
+ end
172
+
173
+ # Fetches the keys unless a fetch was attempted in the last minute; joins one in flight.
174
+ def refresh
175
+ fetch_if { |now| can_refetch_at?(now) }
176
+ end
177
+
178
+ private
179
+
180
+ def stale?(now)
181
+ @fetched_at.nil? || now - @fetched_at > @max_age
182
+ end
183
+
184
+ def can_refetch_at?(now)
185
+ @last_attempt_at.nil? || now - @last_attempt_at >= MIN_REFETCH_INTERVAL
186
+ end
187
+
188
+ # Starts a fetch when the block allows it, or waits for the one already in flight; the
189
+ # decision and the in-flight flag are taken under one lock so callers never fetch twice.
190
+ def fetch_if
191
+ @lock.synchronize do
192
+ if @in_flight
193
+ @cond.wait(@lock) while @in_flight
194
+ return
195
+ end
196
+ now = Time.now.to_f
197
+ return unless yield(now)
198
+
199
+ @in_flight = true
200
+ @last_attempt_at = now
201
+ end
202
+
203
+ result = nil
204
+ begin
205
+ result = fetch_keys
206
+ ensure
207
+ @lock.synchronize do
208
+ if result
209
+ @keys, @max_age = result
210
+ @fetched_at = Time.now.to_f
211
+ end
212
+ @in_flight = false
213
+ @cond.broadcast
214
+ end
215
+ end
216
+ end
217
+
218
+ # Returns [keys, max_age], or nil when the fetch fails or yields no usable key. Uses a plain
219
+ # connection: the keys endpoint is public and must never go through the token-refresh path.
220
+ def fetch_keys
221
+ response = Faraday.new(request: { timeout: FETCH_TIMEOUT, open_timeout: FETCH_TIMEOUT }).get(@url) do |req|
222
+ req.headers['Accept'] = 'application/json'
223
+ end
224
+ return nil unless response.success?
225
+
226
+ parsed = JSON.parse(response.body.to_s)
227
+ keys = Array(parsed.is_a?(Hash) ? parsed['keys'] : nil).filter_map { |entry| usable_key(entry) }
228
+ keys.empty? ? nil : [keys.freeze, parse_max_age(response.headers['cache-control'])]
229
+ rescue StandardError
230
+ nil
231
+ end
232
+
233
+ def usable_key(entry)
234
+ return nil unless entry.is_a?(Hash)
235
+
236
+ key = entry['key']
237
+ alg = entry['alg'].to_s
238
+ return nil unless key.is_a?(String) && key.start_with?(WebhookSignature::PUBLIC_KEY_PREFIX)
239
+ return nil unless alg.empty? || alg.casecmp?('ed25519')
240
+
241
+ key
242
+ end
243
+
244
+ def parse_max_age(cache_control)
245
+ match = /max-age=(\d+)/i.match(cache_control.to_s)
246
+ match ? [match[1].to_i, MAX_MAX_AGE].min : DEFAULT_MAX_AGE
247
+ end
248
+ end
249
+ end
250
+ end
@@ -6,6 +6,7 @@ require_relative 'connect/provider'
6
6
  require_relative 'connect/config'
7
7
  require_relative 'connect/models'
8
8
  require_relative 'connect/api_client'
9
+ require_relative 'connect/webhook_verification'
9
10
  require_relative 'connect/resources/auth'
10
11
  require_relative 'connect/resources/calendars'
11
12
  require_relative 'connect/resources/events'
@@ -8,10 +8,13 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ['Mobiscroll']
9
9
  spec.email = ['support@mobiscroll.com']
10
10
 
11
- spec.summary = 'Official Ruby SDK for the Mobiscroll Connect API.'
12
- spec.description = 'Ruby client for the Mobiscroll Connect API. Unified ' \
13
- 'OAuth, calendars, and events across Google Calendar, ' \
14
- 'Microsoft Outlook, Apple Calendar, and CalDAV.'
11
+ spec.summary = 'Ruby client for Mobiscroll Connect — Google Calendar, ' \
12
+ 'Microsoft Outlook, Apple Calendar and CalDAV through one API.'
13
+ spec.description = 'Ruby client for Mobiscroll Connect, the calendar ' \
14
+ 'connectivity layer for scheduling products. Google ' \
15
+ 'Calendar, Microsoft Outlook, Apple Calendar and CalDAV ' \
16
+ 'through one API, with OAuth 2.0 and webhooks. Backend ' \
17
+ 'only — works with your own UI.'
15
18
  spec.homepage = 'https://mobiscroll.com/connect'
16
19
  spec.license = 'MIT'
17
20
 
@@ -19,7 +22,8 @@ Gem::Specification.new do |spec|
19
22
 
20
23
  spec.metadata = {
21
24
  'homepage_uri' => spec.homepage,
22
- 'source_code_uri' => 'https://github.com/acidb/mobiscroll-connect-sdks',
25
+ 'source_code_uri' => 'https://github.com/acidb/mobiscroll-connect-sdks/tree/main/sdks/ruby',
26
+ 'documentation_uri' => 'https://mobiscroll.com/docs/connect/ruby-sdk',
23
27
  'bug_tracker_uri' => 'https://github.com/acidb/mobiscroll-connect-sdks/issues',
24
28
  'changelog_uri' => 'https://github.com/acidb/mobiscroll-connect-sdks/blob/main/sdks/ruby/CHANGELOG.md',
25
29
  'rubygems_mfa_required' => 'true'
@@ -28,6 +32,7 @@ Gem::Specification.new do |spec|
28
32
  spec.files = Dir[
29
33
  'lib/**/*.rb',
30
34
  'README.md',
35
+ 'CHANGELOG.md',
31
36
  'LICENSE',
32
37
  'mobiscroll-connect.gemspec'
33
38
  ]
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mobiscroll-connect
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mobiscroll
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-22 00:00:00.000000000 Z
11
+ date: 2026-10-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -80,14 +80,17 @@ dependencies:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
82
  version: '3.23'
83
- description: Ruby client for the Mobiscroll Connect API. Unified OAuth, calendars,
84
- and events across Google Calendar, Microsoft Outlook, Apple Calendar, and CalDAV.
83
+ description: Ruby client for Mobiscroll Connect, the calendar connectivity layer for
84
+ scheduling products. Google Calendar, Microsoft Outlook, Apple Calendar and CalDAV
85
+ through one API, with OAuth 2.0 and webhooks. Backend only — works with your own
86
+ UI.
85
87
  email:
86
88
  - support@mobiscroll.com
87
89
  executables: []
88
90
  extensions: []
89
91
  extra_rdoc_files: []
90
92
  files:
93
+ - CHANGELOG.md
91
94
  - LICENSE
92
95
  - README.md
93
96
  - lib/mobiscroll-connect.rb
@@ -103,13 +106,15 @@ files:
103
106
  - lib/mobiscroll/connect/resources/events.rb
104
107
  - lib/mobiscroll/connect/resources/webhooks.rb
105
108
  - lib/mobiscroll/connect/version.rb
109
+ - lib/mobiscroll/connect/webhook_verification.rb
106
110
  - mobiscroll-connect.gemspec
107
111
  homepage: https://mobiscroll.com/connect
108
112
  licenses:
109
113
  - MIT
110
114
  metadata:
111
115
  homepage_uri: https://mobiscroll.com/connect
112
- source_code_uri: https://github.com/acidb/mobiscroll-connect-sdks
116
+ source_code_uri: https://github.com/acidb/mobiscroll-connect-sdks/tree/main/sdks/ruby
117
+ documentation_uri: https://mobiscroll.com/docs/connect/ruby-sdk
113
118
  bug_tracker_uri: https://github.com/acidb/mobiscroll-connect-sdks/issues
114
119
  changelog_uri: https://github.com/acidb/mobiscroll-connect-sdks/blob/main/sdks/ruby/CHANGELOG.md
115
120
  rubygems_mfa_required: 'true'
@@ -131,5 +136,6 @@ requirements: []
131
136
  rubygems_version: 3.5.22
132
137
  signing_key:
133
138
  specification_version: 4
134
- summary: Official Ruby SDK for the Mobiscroll Connect API.
139
+ summary: Ruby client for Mobiscroll Connect — Google Calendar, Microsoft Outlook,
140
+ Apple Calendar and CalDAV through one API.
135
141
  test_files: []