iorwerth-hub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8fe7207839b271a72800dc05b9e9f8ed43b39208e765082e3272f548abc3035a
4
+ data.tar.gz: 050fad5950fea4cec1f3d54fa4d2a5fa8a366331a9933ec514d42191bb3fec81
5
+ SHA512:
6
+ metadata.gz: 0c66317ece2c5fb5413f8ea43d1627e29a2af29d0b9aa39f6f366a7a54e5f84704ec4f517561625eed63c166074a1dea9c666a6618b4596e188a85ec2b26cef8
7
+ data.tar.gz: 987ca13d118419db3f8d9a9b11a35b87c96772388d5afd248f4e15526db858f634016781ec4b55fb4dd5d7ac4e01f5b7910e128d1f4f5798db9013570b6dc778
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ - First release: `Iorwerth::Hub::Client` for every `/api/v1` operation, `entitled?` and
6
+ `limit` with a cache that survives a Hub outage, `Webhook.verify` and a Rack
7
+ `Webhook::Receiver`, and the `:iorwerth_hub` OmniAuth strategy.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Iorwerth Technologies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,226 @@
1
+ # iorwerth-hub
2
+
3
+ Connects a Ruby product to the Iorwerth Customer Hub. It provides four pieces:
4
+
5
+ - **Sign-in:** the `:iorwerth_hub` OmniAuth strategy. OpenID Connect, code flow with PKCE.
6
+ - **API client:** `Iorwerth::Hub::Client`, one method per `/api/v1` operation.
7
+ - **Entitlement checks:** `Iorwerth::Hub.entitled?`, cached, and still answering while the Hub is down.
8
+ - **Webhooks:** `Iorwerth::Hub::Webhook::Receiver`, a Rack endpoint that verifies each signature.
9
+
10
+ Start with [Connecting a product to the Hub](../../docs/integration/connecting-a-product.md),
11
+ which goes through every step: registering the product, credentials, plans, tenants
12
+ and webhooks. This README covers the Ruby side of each.
13
+
14
+ The Hub's contract is described in
15
+ [docs/architecture/customer-hub.md](../../docs/architecture/customer-hub.md), Product integration contract.
16
+ The API itself is defined by [apps/hub/openapi.json](../../apps/hub/openapi.json). A test in
17
+ this gem fails if the client and that file disagree.
18
+
19
+ ## Install
20
+
21
+ ```ruby
22
+ # Gemfile
23
+ gem "iorwerth-hub"
24
+ gem "omniauth_openid_connect" # only for sign-in
25
+ ```
26
+
27
+ Ruby 3.1 or later. The gem itself has no other dependencies.
28
+
29
+ ## Configure
30
+
31
+ Every product reads the same environment variables. The Hub's admin prints them as a block when it
32
+ issues the product's credentials:
33
+
34
+ ```
35
+ HUB_URL=https://id.iorwerth.com
36
+ HUB_PRODUCT_KEY=podium
37
+ HUB_OIDC_CLIENT_ID=...
38
+ HUB_OIDC_CLIENT_SECRET=...
39
+ HUB_API_KEY=...
40
+ HUB_WEBHOOK_SECRET=...
41
+ ```
42
+
43
+ You can override any of them in code:
44
+
45
+ ```ruby
46
+ Iorwerth::Hub.configure do |hub|
47
+ hub.cache = Rails.cache # default: an in-process cache
48
+ hub.entitlements_ttl = 600 # seconds; default 300
49
+ end
50
+ ```
51
+
52
+ ## Sign-in
53
+
54
+ ```ruby
55
+ # config/initializers/omniauth.rb
56
+ require "iorwerth/hub/omniauth"
57
+
58
+ Rails.application.config.middleware.use OmniAuth::Builder do
59
+ provider :iorwerth_hub, redirect_uri: "https://podium.app/auth/iorwerth_hub/callback"
60
+ end
61
+ ```
62
+
63
+ - **Signing in:** send people to `/auth/iorwerth_hub`. The callback receives the usual OmniAuth hash.
64
+ - **Starting a trial:** send people to `/auth/iorwerth_hub?prompt=create&plan=<plan key>`. The Hub
65
+ shows its signup page and records the requested plan.
66
+
67
+ The callback's `uid` is the Hub's `usr_` id. `info` holds the person's name and email.
68
+ `Iorwerth::Hub.organizations(auth)` returns the organizations they belong to, as this
69
+ product sees each one:
70
+
71
+ ```ruby
72
+ def callback
73
+ auth = request.env["omniauth.auth"]
74
+ user = User.find_or_initialize_by(hub_id: auth.uid)
75
+ user.update!(email: auth.info.email, name: auth.info.name)
76
+ Iorwerth::Hub.organizations(auth).each do |organization|
77
+ tenant = Tenant.find_or_create_by!(hub_organization_id: organization["id"])
78
+ # ...
79
+ end
80
+ end
81
+ ```
82
+
83
+ ## API
84
+
85
+ ```ruby
86
+ hub = Iorwerth::Hub.client
87
+ hub.get_organization("org_01J...") # profile, members, subscription, entitlements
88
+ hub.report_tenant("org_01J...", external_tenant_id: tenant.id.to_s)
89
+ hub.report_usage("org_01J...", usage: { teams: 7 })
90
+ hub.invite("org_01J...", email: "sam@example.com", role: "member")
91
+ hub.each_organization { |organization| ... } # every organization this product can see
92
+ hub.each_event(since: last_event_id) { |event| ... } # catch up after downtime
93
+ ```
94
+
95
+ - **Answers:** the parsed JSON, as a Hash with string keys.
96
+ - **Errors:** an error answer raises the matching subclass of `Iorwerth::Hub::ApiError`:
97
+ `NotFound`, `Refused` (422), `RateLimited`, and so on. Each carries the Hub's
98
+ `code` and message.
99
+ - **Unreachable Hub:** raises `Iorwerth::Hub::ConnectionError`.
100
+ - **Retries:** every POST sends an `Idempotency-Key`. To retry safely, pass the same
101
+ `idempotency_key:` again.
102
+
103
+ ## Entitlements
104
+
105
+ ```ruby
106
+ Iorwerth::Hub.entitled?(organization_id, :stats) # a switch, or a limit above 0
107
+ Iorwerth::Hub.limit(organization_id, :teams) # max_teams, or nil when there is none
108
+ ```
109
+
110
+ - **Caching:** each organization's map is cached for `entitlements_ttl` seconds (300 by default).
111
+ - **Hub outage:** if the Hub can't be reached once the cache has expired, the last
112
+ known map answers for up to a day. Customers keep their access.
113
+ - **Unseen organizations:** one this product can't see is entitled to nothing.
114
+
115
+ ## Webhooks
116
+
117
+ ```ruby
118
+ # config/routes.rb
119
+ mount Iorwerth::Hub::Webhook::Receiver.new { |event| HubEventJob.perform_later(event) },
120
+ at: "/hub/webhooks"
121
+ ```
122
+
123
+ The receiver handles signatures and caching for you:
124
+
125
+ - It verifies `X-Hub-Signature` over the raw body and refuses timestamps more than
126
+ five minutes off.
127
+ - It drops the organization's cached entitlements for `organization.*`, `member.*` and
128
+ `subscription.*` events.
129
+ - It answers 204 once your block returns.
130
+
131
+ Your block has two jobs:
132
+
133
+ - **Return quickly:** do the work in a background job. If your block raises, the
134
+ request fails, and the Hub retries for a day.
135
+ - **Process each event `id` once:** the Hub can deliver the same event again.
136
+
137
+ Each event looks like this:
138
+
139
+ ```ruby
140
+ {
141
+ "id" => "evt_01J...",
142
+ "type" => "member.added",
143
+ "created_at" => "2026-10-02T18:47:20.022Z",
144
+ "organization_id" => "org_01J...",
145
+ "product" => "podium",
146
+ "data" => {
147
+ "organization" => { ... }, # as get_organization returns it
148
+ "member" => { ... }
149
+ }
150
+ }
151
+ ```
152
+
153
+ To verify a webhook yourself: `Iorwerth::Hub::Webhook.verify(raw_body, header)`.
154
+
155
+ ## Developing
156
+
157
+ ```bash
158
+ cd packages/hub-sdk-ruby
159
+ bundle install
160
+ bundle exec rake test
161
+ ```
162
+
163
+ ## Releasing
164
+
165
+ Releases are published to RubyGems by `.github/workflows/sdk-ruby-release.yml` when a tag
166
+ `sdk-ruby-vX.Y.Z` is pushed. It uses trusted publishing: RubyGems accepts the push because
167
+ GitHub vouches for the workflow, so no RubyGems API key is stored anywhere.
168
+
169
+ ### One-time setup, before the first release
170
+
171
+ The gem doesn't exist on RubyGems until its first push, so it starts as a *pending*
172
+ trusted publisher. The first successful release turns that into the gem's real trusted
173
+ publisher, owned by the account that created it.
174
+
175
+ 1. **Prepare a RubyGems account.** Sign in at [rubygems.org](https://rubygems.org), or
176
+ create an account, ideally one tied to a company address. Then turn on multi-factor
177
+ authentication: avatar menu → **Edit settings** → **Multi-factor authentication**.
178
+ The gemspec sets `rubygems_mfa_required`, so every owner needs MFA.
179
+ 2. **Check the name is free.** Visit `https://rubygems.org/gems/iorwerth-hub`. It should
180
+ show "This gem could not be found".
181
+ 3. **Add the pending publisher.** Avatar menu → **Edit settings** → **Trusted
182
+ publishers**, or go straight to
183
+ `https://rubygems.org/profile/oidc/pending_trusted_publishers`. Choose **Create**,
184
+ then fill in exactly:
185
+
186
+ | Field | Value |
187
+ | --- | --- |
188
+ | RubyGem name | `iorwerth-hub` |
189
+ | Trusted publisher type | GitHub Actions |
190
+ | Repository owner | `Iorwerth-technologies` |
191
+ | Repository name | `platform` |
192
+ | Workflow filename | `sdk-ruby-release.yml` (the file name only, not the path) |
193
+ | Environment | leave empty |
194
+
195
+ Save it. A pending publisher can expire if it goes unused, so do this shortly before
196
+ you tag. If the release later fails with a trusted-publishing error, check whether it
197
+ is still listed, and add it again if not.
198
+
199
+ ### Each release
200
+
201
+ 1. Set the new version in `lib/iorwerth/hub/version.rb`, add an entry to `CHANGELOG.md`,
202
+ and merge to `main`.
203
+ 2. Tag the merge commit and push the tag:
204
+ ```bash
205
+ git switch main && git pull
206
+ git tag sdk-ruby-v0.1.0 && git push origin sdk-ruby-v0.1.0
207
+ ```
208
+ 3. Watch the **sdk-ruby-release** run in GitHub Actions. It does four things, in order:
209
+ - checks that the tag matches `version.rb`
210
+ - runs the tests
211
+ - exchanges GitHub's token for a short-lived RubyGems key
212
+ - builds and pushes the gem
213
+ 4. Check that `https://rubygems.org/gems/iorwerth-hub` shows the new version. After the
214
+ first release, the gem's page lists the trusted publisher under its settings, in
215
+ place of the pending one.
216
+
217
+ A version can't be pushed twice. If a release fails after the push step, fix forward with
218
+ a new version.
219
+
220
+ To share ownership with another account:
221
+ `gem owner iorwerth-hub --add someone@example.com`. Run it from a machine signed in to
222
+ RubyGems as an existing owner, with MFA.
223
+
224
+ ## License
225
+
226
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "securerandom"
7
+ require "uri"
8
+
9
+ module Iorwerth
10
+ module Hub
11
+ # The Hub's /api/v1, one method per operation in apps/hub/openapi.json. A test in this
12
+ # gem fails if the spec gains an operation OPERATIONS does not have, or they disagree.
13
+ #
14
+ # hub = Iorwerth::Hub::Client.new
15
+ # hub.get_organization("org_01J...")["entitlements"] # => {"max_teams" => 12}
16
+ #
17
+ # Answers are the parsed JSON (string keys). An error answer raises the ApiError
18
+ # subclass for its status; an unreachable Hub raises ConnectionError.
19
+ class Client
20
+ # operationId => [HTTP method, path]. Path parameters are {named} as in the spec.
21
+ OPERATIONS = {
22
+ "list_organizations" => [:get, "/api/v1/organizations"],
23
+ "get_organization" => [:get, "/api/v1/organizations/{organization_id}"],
24
+ "get_entitlements" => [:get, "/api/v1/organizations/{organization_id}/entitlements"],
25
+ "report_tenant" => [:post, "/api/v1/organizations/{organization_id}/tenant"],
26
+ "get_usage" => [:get, "/api/v1/organizations/{organization_id}/usage"],
27
+ "report_usage" => [:post, "/api/v1/organizations/{organization_id}/usage"],
28
+ "invite" => [:post, "/api/v1/organizations/{organization_id}/invites"],
29
+ "get_user" => [:get, "/api/v1/users/{user_id}"],
30
+ "list_events" => [:get, "/api/v1/events"],
31
+ "list_plans" => [:get, "/api/v1/plans"]
32
+ }.freeze
33
+
34
+ def initialize(url: Hub.config.url, api_key: Hub.config.api_key,
35
+ timeout: Hub.config.timeout, open_timeout: Hub.config.open_timeout)
36
+ raise ConfigurationError, "Set HUB_URL." if url.nil? || url.empty?
37
+
38
+ @base = URI(url.chomp("/"))
39
+ @api_key = api_key
40
+ @timeout = timeout
41
+ @open_timeout = open_timeout
42
+ end
43
+
44
+ # The organizations this product can see, oldest first: {"data" => [...], "next_cursor" => ...}.
45
+ def list_organizations(cursor: nil, limit: nil)
46
+ call("list_organizations", query: { cursor: cursor, limit: limit })
47
+ end
48
+
49
+ # Every organization this product can see, page by page.
50
+ def each_organization(limit: 100, &block)
51
+ return enum_for(:each_organization, limit: limit) unless block
52
+
53
+ cursor = nil
54
+ loop do
55
+ page = list_organizations(cursor: cursor, limit: limit)
56
+ page["data"].each(&block)
57
+ cursor = page["next_cursor"] or break
58
+ end
59
+ end
60
+
61
+ def get_organization(organization_id)
62
+ call("get_organization", organization_id: organization_id)
63
+ end
64
+
65
+ # Just the flat map and the subscription: what Entitlements caches.
66
+ def get_entitlements(organization_id)
67
+ call("get_entitlements", organization_id: organization_id)
68
+ end
69
+
70
+ # Record the tenant this product provisioned. Reporting the same one again is harmless.
71
+ def report_tenant(organization_id, external_tenant_id:, idempotency_key: SecureRandom.uuid)
72
+ call("report_tenant", organization_id: organization_id,
73
+ body: { external_tenant_id: external_tenant_id },
74
+ idempotency_key: idempotency_key)
75
+ end
76
+
77
+ def get_usage(organization_id)
78
+ call("get_usage", organization_id: organization_id)
79
+ end
80
+
81
+ # Current figures, e.g. report_usage(id, usage: {teams: 7}); over a limit is reported, not refused.
82
+ def report_usage(organization_id, usage:, idempotency_key: SecureRandom.uuid)
83
+ call("report_usage", organization_id: organization_id, body: { usage: usage },
84
+ idempotency_key: idempotency_key)
85
+ end
86
+
87
+ # The Hub sends the email, in this product's theme. role: "admin" or "member".
88
+ def invite(organization_id, email:, role:, invited_by: nil, idempotency_key: SecureRandom.uuid)
89
+ body = { email: email, role: role, invited_by: invited_by }.compact
90
+ call("invite", organization_id: organization_id, body: body, idempotency_key: idempotency_key)
91
+ end
92
+
93
+ def get_user(user_id)
94
+ call("get_user", user_id: user_id)
95
+ end
96
+
97
+ # The webhooks this product was sent, oldest first, after the event id `since`.
98
+ def list_events(since: nil, limit: nil)
99
+ call("list_events", query: { since: since, limit: limit })
100
+ end
101
+
102
+ # Every event after `since`, page by page: catching up after downtime.
103
+ def each_event(since: nil, limit: 100, &block)
104
+ return enum_for(:each_event, since: since, limit: limit) unless block
105
+
106
+ loop do
107
+ page = list_events(since: since, limit: limit)
108
+ page["data"].each(&block)
109
+ since = page["next_cursor"] or break
110
+ end
111
+ end
112
+
113
+ # A product's public plans, for a pricing page. The one call that sends no API key.
114
+ def list_plans(workspace:, product: Hub.config.product_key)
115
+ call("list_plans", query: { workspace: workspace, product: product }, authenticated: false)
116
+ end
117
+
118
+ private
119
+
120
+ def call(operation, body: nil, query: {}, idempotency_key: nil, authenticated: true, **path)
121
+ verb, template = OPERATIONS.fetch(operation)
122
+ uri = @base.dup
123
+ uri.path = @base.path + template.gsub(/\{(\w+)\}/) do
124
+ URI.encode_www_form_component(path.fetch(Regexp.last_match(1).to_sym).to_s)
125
+ end
126
+ query = query.compact
127
+ uri.query = URI.encode_www_form(query) unless query.empty?
128
+ request = (verb == :post ? Net::HTTP::Post : Net::HTTP::Get).new(uri)
129
+ request["Accept"] = "application/json"
130
+ request["User-Agent"] = "iorwerth-hub-ruby/#{VERSION}"
131
+ if authenticated
132
+ raise ConfigurationError, "Set HUB_API_KEY." if @api_key.nil? || @api_key.empty?
133
+
134
+ request["Authorization"] = "Bearer #{@api_key}"
135
+ end
136
+ request["Idempotency-Key"] = idempotency_key if idempotency_key
137
+ if body
138
+ request["Content-Type"] = "application/json"
139
+ request.body = JSON.generate(body)
140
+ end
141
+ handle(send_request(uri, request))
142
+ end
143
+
144
+ def send_request(uri, request)
145
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
146
+ open_timeout: @open_timeout, read_timeout: @timeout) do |http|
147
+ http.request(request)
148
+ end
149
+ rescue SocketError, SystemCallError, Timeout::Error, IOError, OpenSSL::SSL::SSLError => e
150
+ raise ConnectionError, "#{uri.host}: #{e.class}: #{e.message}"
151
+ end
152
+
153
+ def handle(response)
154
+ status = response.code.to_i
155
+ parsed = response.body.to_s.empty? ? {} : JSON.parse(response.body)
156
+ return parsed if status < 400
157
+
158
+ error = parsed.is_a?(Hash) ? parsed.fetch("error", {}) : {}
159
+ raise ApiError.for(status, error["code"], error["message"] || "HTTP #{status}")
160
+ rescue JSON::ParserError
161
+ raise ApiError.for(status, nil, "HTTP #{status}, not JSON") if status >= 400
162
+
163
+ raise ServerError.new(status, nil, "The Hub answered HTTP #{status} without JSON.")
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iorwerth
4
+ module Hub
5
+ # The environment contract every product shares (architecture doc, Product integration
6
+ # contract). The Hub's admin prints these as a block to paste when it issues credentials.
7
+ class Configuration
8
+ attr_accessor :url, :product_key, :oidc_client_id, :oidc_client_secret, :api_key,
9
+ :webhook_secrets, :entitlements_ttl, :cache, :timeout, :open_timeout
10
+
11
+ def initialize(env = ENV)
12
+ @url = env["HUB_URL"]&.chomp("/")
13
+ @product_key = env["HUB_PRODUCT_KEY"]
14
+ @oidc_client_id = env["HUB_OIDC_CLIENT_ID"]
15
+ @oidc_client_secret = env["HUB_OIDC_CLIENT_SECRET"]
16
+ @api_key = env["HUB_API_KEY"]
17
+ # Comma-separated, so a receiver can hold both secrets through a rotation.
18
+ @webhook_secrets = env.fetch("HUB_WEBHOOK_SECRET", "").split(",").map(&:strip).reject(&:empty?)
19
+ # The architecture doc's 5-15 minutes; webhooks invalidate sooner.
20
+ @entitlements_ttl = 300
21
+ # Anything with read/write/delete like Rails.cache; nil keeps a cache in memory.
22
+ @cache = nil
23
+ @timeout = 10
24
+ @open_timeout = 5
25
+ end
26
+
27
+ def require!(*names)
28
+ missing = names.select { |name| public_send(name).nil? || public_send(name).empty? }
29
+ return if missing.empty?
30
+
31
+ variables = missing.map { |name| "HUB_#{name.to_s.upcase}" }.join(", ")
32
+ raise ConfigurationError, "Set #{variables} (Iorwerth::Hub.configure or the environment)."
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iorwerth
4
+ module Hub
5
+ # Cached entitlement checks, per organization (architecture doc: products cache for a
6
+ # short TTL and treat webhooks as the invalidator).
7
+ #
8
+ # Iorwerth::Hub.entitled?(organization_id, :stats) # a switch, or a limit above 0
9
+ # Iorwerth::Hub.limit(organization_id, :teams) # max_teams, or nil for none
10
+ #
11
+ # If the Hub cannot be reached when an entry is stale, the last known map is used:
12
+ # a Hub outage means stale entitlements, never a locked-out organization.
13
+ class Entitlements
14
+ # Stale entries are kept this long, to answer from while the Hub is down.
15
+ KEEP_STALE = 24 * 60 * 60
16
+
17
+ def initialize(client: nil, ttl: Hub.config.entitlements_ttl, cache: Hub.config.cache)
18
+ @client = client
19
+ @ttl = ttl
20
+ @cache = cache || MemoryCache.new
21
+ end
22
+
23
+ # The flat map, e.g. {"max_teams" => 12, "stats" => true, "past_due" => false}.
24
+ def for(organization_id)
25
+ entry = @cache.read(key(organization_id))
26
+ return entry["entitlements"] if entry && Time.now.to_f - entry["fetched_at"] < @ttl
27
+
28
+ fetch(organization_id)
29
+ rescue ConnectionError, ServerError
30
+ raise unless entry
31
+
32
+ entry["entitlements"]
33
+ end
34
+
35
+ def entitled?(organization_id, name)
36
+ value = self.for(organization_id)[name.to_s]
37
+ value == true || (value.is_a?(Integer) && value.positive?)
38
+ rescue NotFound
39
+ false
40
+ end
41
+
42
+ # The limit `max_<name>` grants, or nil when the plan sets none.
43
+ def limit(organization_id, name)
44
+ value = self.for(organization_id)["max_#{name}"]
45
+ value.is_a?(Integer) ? value : nil
46
+ end
47
+
48
+ def invalidate(organization_id)
49
+ @cache.delete(key(organization_id))
50
+ end
51
+
52
+ private
53
+
54
+ def fetch(organization_id)
55
+ map = client.get_entitlements(organization_id).fetch("entitlements")
56
+ @cache.write(key(organization_id), { "entitlements" => map, "fetched_at" => Time.now.to_f },
57
+ expires_in: KEEP_STALE)
58
+ map
59
+ end
60
+
61
+ def client
62
+ @client ||= Client.new
63
+ end
64
+
65
+ def key(organization_id)
66
+ "iorwerth-hub/entitlements/#{organization_id}"
67
+ end
68
+ end
69
+
70
+ # The read/write/delete subset of Rails.cache, in memory and thread-safe.
71
+ class MemoryCache
72
+ def initialize
73
+ @entries = {}
74
+ @lock = Mutex.new
75
+ end
76
+
77
+ def read(key)
78
+ @lock.synchronize do
79
+ value, expires_at = @entries[key]
80
+ next value if expires_at.nil? || Time.now.to_f < expires_at
81
+
82
+ @entries.delete(key)
83
+ nil
84
+ end
85
+ end
86
+
87
+ def write(key, value, expires_in: nil)
88
+ @lock.synchronize { @entries[key] = [value, expires_in && (Time.now.to_f + expires_in)] }
89
+ true
90
+ end
91
+
92
+ def delete(key)
93
+ @lock.synchronize { !@entries.delete(key).nil? }
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iorwerth
4
+ module Hub
5
+ class Error < StandardError; end
6
+
7
+ class ConfigurationError < Error; end
8
+
9
+ # The Hub could not be reached, or did not answer in time.
10
+ class ConnectionError < Error; end
11
+
12
+ # An answer in the Hub's error envelope: {"error": {"code": ..., "message": ...}}.
13
+ class ApiError < Error
14
+ attr_reader :status, :code
15
+
16
+ def initialize(status, code, message)
17
+ @status = status
18
+ @code = code
19
+ super(message)
20
+ end
21
+
22
+ def self.for(status, code, message)
23
+ klass = case status
24
+ when 400 then InvalidRequest
25
+ when 401 then Unauthorized
26
+ when 403 then Forbidden
27
+ when 404 then NotFound
28
+ when 422 then Refused
29
+ when 429 then RateLimited
30
+ when 500..599 then ServerError
31
+ else ApiError
32
+ end
33
+ klass.new(status, code, message)
34
+ end
35
+ end
36
+
37
+ class InvalidRequest < ApiError; end
38
+ class Unauthorized < ApiError; end
39
+ class Forbidden < ApiError; end
40
+ # Also what an organization this product cannot see looks like.
41
+ class NotFound < ApiError; end
42
+ # The Hub's rules refused it (422), e.g. a different tenant for a linked organization.
43
+ class Refused < ApiError; end
44
+ class RateLimited < ApiError; end
45
+ class ServerError < ApiError; end
46
+
47
+ # A webhook whose signature or timestamp did not check out.
48
+ class SignatureError < Error; end
49
+ end
50
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "iorwerth/hub"
4
+ require "omniauth_openid_connect"
5
+
6
+ module OmniAuth
7
+ module Strategies
8
+ # Sign-in through the Hub: OpenID Connect, authorization code with S256 PKCE, the
9
+ # endpoints discovered from HUB_URL. Needs omniauth_openid_connect in the app's Gemfile.
10
+ #
11
+ # # config/initializers/omniauth.rb
12
+ # require "iorwerth/hub/omniauth"
13
+ # Rails.application.config.middleware.use OmniAuth::Builder do
14
+ # provider :iorwerth_hub, redirect_uri: "https://podium.app/auth/iorwerth_hub/callback"
15
+ # end
16
+ #
17
+ # Link to /auth/iorwerth_hub to sign in, or /auth/iorwerth_hub?prompt=create&plan=<key>
18
+ # for "Start free trial": the Hub shows signup and records the plan. The auth hash's
19
+ # extra.raw_info carries the `organizations` claim (Iorwerth::Hub.organizations(auth)).
20
+ class IorwerthHub < OpenIDConnect
21
+ option :name, "iorwerth_hub"
22
+ option :discovery, true
23
+ option :pkce, true
24
+ option :response_type, "code"
25
+ option :scope, %i[openid email profile]
26
+ option :allow_authorize_params, %i[plan]
27
+
28
+ def initialize(app, *args, &block)
29
+ super
30
+ config = Iorwerth::Hub.config
31
+ config.require!(:url, :oidc_client_id, :oidc_client_secret)
32
+ hub = URI(config.url)
33
+ options.issuer ||= config.url
34
+ options.client_options.identifier ||= config.oidc_client_id
35
+ options.client_options.secret ||= config.oidc_client_secret
36
+ options.client_options.scheme = hub.scheme
37
+ options.client_options.host = hub.host
38
+ options.client_options.port = hub.port
39
+ end
40
+
41
+ def request_phase
42
+ # OpenID Connect Prompt Create: a new customer lands on the Hub's signup.
43
+ options.prompt = "create" if request.params["prompt"] == "create"
44
+ super
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iorwerth
4
+ module Hub
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module Iorwerth
7
+ module Hub
8
+ # The Hub's signed webhooks (architecture doc, Auth design):
9
+ #
10
+ # X-Hub-Signature: t=1759420800,v1=5257a8...[,v1=...]
11
+ #
12
+ # each v1 an HMAC-SHA256 hex of "<t>.<raw body>" under one of the product's live
13
+ # secrets (several during a rotation). Verify over the raw body, before parsing it.
14
+ module Webhook
15
+ HEADER = "HTTP_X_HUB_SIGNATURE"
16
+ # Older than this is refused, as is a timestamp this far in the future.
17
+ TOLERANCE = 5 * 60
18
+
19
+ # The parsed event, or SignatureError.
20
+ def self.verify(payload, header, secrets: Hub.config.webhook_secrets, now: Time.now.to_i,
21
+ tolerance: TOLERANCE)
22
+ raise SignatureError, "No webhook secret: set HUB_WEBHOOK_SECRET." if secrets.empty?
23
+
24
+ timestamp, signatures = parse(header)
25
+ raise SignatureError, "The timestamp is too old or too new." if (now - timestamp).abs > tolerance
26
+
27
+ signed = "#{timestamp}.#{payload}"
28
+ expected = secrets.map { |secret| OpenSSL::HMAC.hexdigest("SHA256", secret, signed) }
29
+ valid = expected.any? do |digest|
30
+ signatures.any? do |given|
31
+ given.bytesize == digest.bytesize && OpenSSL.fixed_length_secure_compare(given, digest)
32
+ end
33
+ end
34
+ raise SignatureError, "The signature does not match." unless valid
35
+
36
+ JSON.parse(payload)
37
+ rescue JSON::ParserError
38
+ raise SignatureError, "The body is not JSON."
39
+ end
40
+
41
+ def self.parse(header)
42
+ parts = header.to_s.split(",").map { |part| part.strip.split("=", 2) }
43
+ timestamp = parts.find { |name, _| name == "t" }&.last
44
+ signatures = parts.select { |name, _| name == "v1" }.map(&:last)
45
+ raise SignatureError, "No X-Hub-Signature, or not t=...,v1=..." if timestamp !~ /\A\d+\z/ || signatures.empty?
46
+
47
+ [timestamp.to_i, signatures]
48
+ end
49
+ private_class_method :parse
50
+
51
+ # A Rack endpoint for POST /hub/webhooks. Mount it, then handle each verified event:
52
+ #
53
+ # # config/routes.rb
54
+ # mount(Iorwerth::Hub::Webhook::Receiver.new { |event| HubEventJob.perform_later(event) },
55
+ # at: "/hub/webhooks")
56
+ #
57
+ # Answers 204 once the handler returns, so do the work in a job and return quickly; an
58
+ # error in the handler answers 500 and the Hub retries. Event ids repeat on a retry:
59
+ # process each once. Organization, member and subscription events drop the
60
+ # organization's cached entitlements before the handler runs.
61
+ class Receiver
62
+ INVALIDATES = /\A(organization|member|subscription)\./
63
+
64
+ def initialize(secrets: nil, entitlements: nil, &handler)
65
+ raise ArgumentError, "Pass a block to handle each event." unless handler
66
+
67
+ @secrets = secrets
68
+ @entitlements = entitlements
69
+ @handler = handler
70
+ end
71
+
72
+ def call(env)
73
+ return respond(405, "POST only") unless env["REQUEST_METHOD"] == "POST"
74
+
75
+ body = env["rack.input"].read
76
+ env["rack.input"].rewind if env["rack.input"].respond_to?(:rewind)
77
+ event = Webhook.verify(body, env[HEADER], secrets: @secrets || Hub.config.webhook_secrets)
78
+ if event["organization_id"] && event["type"].to_s.match?(INVALIDATES)
79
+ (@entitlements || Hub.entitlements).invalidate(event["organization_id"])
80
+ end
81
+ @handler.call(event)
82
+ [204, {}, []]
83
+ rescue SignatureError => e
84
+ respond(400, e.message)
85
+ end
86
+
87
+ private
88
+
89
+ def respond(status, message)
90
+ [status, { "content-type" => "text/plain" }, [message]]
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "iorwerth/hub/version"
4
+ require "iorwerth/hub/errors"
5
+ require "iorwerth/hub/configuration"
6
+ require "iorwerth/hub/client"
7
+ require "iorwerth/hub/entitlements"
8
+ require "iorwerth/hub/webhook"
9
+
10
+ module Iorwerth
11
+ # Connects a Ruby product to the Iorwerth Customer Hub. Reads its settings from the
12
+ # HUB_* environment variables; `configure` overrides them.
13
+ #
14
+ # Sign-in lives in iorwerth/hub/omniauth, required separately: it needs
15
+ # omniauth_openid_connect, which the core does not.
16
+ module Hub
17
+ class << self
18
+ def config
19
+ @config ||= Configuration.new
20
+ end
21
+
22
+ def configure
23
+ yield config
24
+ @client = @entitlements = nil
25
+ config
26
+ end
27
+
28
+ def client
29
+ @client ||= Client.new
30
+ end
31
+
32
+ def entitlements
33
+ @entitlements ||= Entitlements.new
34
+ end
35
+
36
+ def entitled?(organization_id, name)
37
+ entitlements.entitled?(organization_id, name)
38
+ end
39
+
40
+ def limit(organization_id, name)
41
+ entitlements.limit(organization_id, name)
42
+ end
43
+
44
+ # The `organizations` claim from an OmniAuth auth hash: [{"id" =>, "name" =>, "role" =>, ...}].
45
+ def organizations(auth)
46
+ info = auth.dig("extra", "raw_info") || {}
47
+ info = info.to_h if info.respond_to?(:to_h)
48
+ info.fetch("organizations", info.fetch(:organizations, []))
49
+ end
50
+
51
+ # Forget settings and cached state; for tests.
52
+ def reset!
53
+ @config = @client = @entitlements = nil
54
+ end
55
+ end
56
+ end
57
+ end
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: iorwerth-hub
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Iorwerth Technologies
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-02 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Sign-in through the Hub (OmniAuth), its /api/v1 client, cached entitlement
14
+ checks, and a Rack endpoint that verifies the Hub's webhooks.
15
+ email:
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE
22
+ - README.md
23
+ - lib/iorwerth/hub.rb
24
+ - lib/iorwerth/hub/client.rb
25
+ - lib/iorwerth/hub/configuration.rb
26
+ - lib/iorwerth/hub/entitlements.rb
27
+ - lib/iorwerth/hub/errors.rb
28
+ - lib/iorwerth/hub/omniauth.rb
29
+ - lib/iorwerth/hub/version.rb
30
+ - lib/iorwerth/hub/webhook.rb
31
+ homepage: https://github.com/Iorwerth-technologies/platform/tree/main/packages/hub-sdk-ruby
32
+ licenses:
33
+ - MIT
34
+ metadata:
35
+ source_code_uri: https://github.com/Iorwerth-technologies/platform/tree/main/packages/hub-sdk-ruby
36
+ changelog_uri: https://github.com/Iorwerth-technologies/platform/tree/main/packages/hub-sdk-ruby/CHANGELOG.md
37
+ rubygems_mfa_required: 'true'
38
+ post_install_message:
39
+ rdoc_options: []
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '3.1'
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ requirements: []
53
+ rubygems_version: 3.5.22
54
+ signing_key:
55
+ specification_version: 4
56
+ summary: Connect a Ruby product to the Iorwerth Customer Hub
57
+ test_files: []