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 +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE +21 -0
- data/README.md +226 -0
- data/lib/iorwerth/hub/client.rb +167 -0
- data/lib/iorwerth/hub/configuration.rb +36 -0
- data/lib/iorwerth/hub/entitlements.rb +97 -0
- data/lib/iorwerth/hub/errors.rb +50 -0
- data/lib/iorwerth/hub/omniauth.rb +48 -0
- data/lib/iorwerth/hub/version.rb +7 -0
- data/lib/iorwerth/hub/webhook.rb +95 -0
- data/lib/iorwerth/hub.rb +57 -0
- metadata +57 -0
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,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
|
data/lib/iorwerth/hub.rb
ADDED
|
@@ -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: []
|