licensekit-ruby 0.1.0.alpha.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.
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "yaml"
6
+
7
+ ROOT = File.expand_path("..", __dir__)
8
+ SPEC_PATH = File.join(ROOT, "openapi", "openapi.yaml")
9
+ GENERATED_DIR = File.join(ROOT, "lib", "licensekit", "generated")
10
+
11
+ def infer_auth(security)
12
+ return "none" unless security.is_a?(Array) && !security.empty?
13
+
14
+ security.each do |entry|
15
+ next unless entry.is_a?(Hash)
16
+ return "bearer" if entry.key?("bearerAuth")
17
+ return "license" if entry.key?("licenseAuth")
18
+ end
19
+
20
+ "none"
21
+ end
22
+
23
+ def response_kind(response)
24
+ return "empty" unless response.is_a?(Hash)
25
+
26
+ content = response["content"]
27
+ return "empty" unless content.is_a?(Hash)
28
+ return "json" if content.key?("application/json")
29
+ return "text" if content.key?("text/plain")
30
+
31
+ "empty"
32
+ end
33
+
34
+ def error_response?(response)
35
+ return false unless response.is_a?(Hash)
36
+ return true if response["$ref"].is_a?(String)
37
+
38
+ schema = response.dig("content", "application/json", "schema")
39
+ schema.is_a?(Hash) && schema["$ref"] == "#/components/schemas/ErrorEnvelope"
40
+ end
41
+
42
+ def extract_success_responses(responses)
43
+ entries = []
44
+ return entries unless responses.is_a?(Hash)
45
+
46
+ responses.each do |raw_status, response|
47
+ status = Integer(raw_status, exception: false)
48
+ next if status.nil?
49
+
50
+ kind = response_kind(response)
51
+ if status >= 200 && status < 300
52
+ entries << [status, kind]
53
+ next
54
+ end
55
+
56
+ next if error_response?(response)
57
+
58
+ entries << [status, kind]
59
+ end
60
+
61
+ raise "operation is missing a successful response" if entries.empty?
62
+
63
+ entries.sort_by(&:first)
64
+ end
65
+
66
+ def camel_to_snake(value)
67
+ value.gsub(/(.)([A-Z][a-z]+)/, '\1_\2').gsub(/([a-z0-9])([A-Z])/, '\1_\2').downcase
68
+ end
69
+
70
+ def render_metadata(entries, surfaces)
71
+ lines = [
72
+ "# Generated by scripts/generate_from_openapi.rb. Do not edit manually.",
73
+ "",
74
+ "module LicenseKit",
75
+ " module Generated",
76
+ " OPERATION_METADATA = {"
77
+ ]
78
+
79
+ entries.each do |entry|
80
+ success_pairs = entry[:success].map { |status, kind| "#{status} => #{kind.inspect}" }.join(", ")
81
+ lines.concat(
82
+ [
83
+ " #{entry[:operation_id].inspect} => {",
84
+ " method: #{entry[:method].inspect},",
85
+ " path: #{entry[:path].inspect},",
86
+ " auth: #{entry[:auth].inspect},",
87
+ " summary: #{entry[:summary].inspect},",
88
+ " success: { #{success_pairs} }",
89
+ " },"
90
+ ]
91
+ )
92
+ end
93
+
94
+ lines.concat(
95
+ [
96
+ " }.freeze",
97
+ " MANAGEMENT_OPERATION_IDS = #{surfaces.fetch("management").map { |entry| entry[:operation_id] }.inspect}.freeze",
98
+ " RUNTIME_OPERATION_IDS = #{surfaces.fetch("runtime").map { |entry| entry[:operation_id] }.inspect}.freeze",
99
+ " SYSTEM_OPERATION_IDS = #{surfaces.fetch("system").map { |entry| entry[:operation_id] }.inspect}.freeze",
100
+ " end",
101
+ "end",
102
+ ""
103
+ ]
104
+ )
105
+ lines.join("\n")
106
+ end
107
+
108
+ def render_scopes(entries)
109
+ scopes = entries.flat_map { |entry| entry[:scopes] }.uniq.sort
110
+ lines = [
111
+ "# Generated by scripts/generate_from_openapi.rb. Do not edit manually.",
112
+ "",
113
+ "module LicenseKit",
114
+ " module Generated",
115
+ " OPERATION_SCOPES = {"
116
+ ]
117
+
118
+ entries.each do |entry|
119
+ lines.concat(
120
+ [
121
+ " #{entry[:operation_id].inspect} => {",
122
+ " method: #{entry[:method].inspect},",
123
+ " path: #{entry[:path].inspect},",
124
+ " scopes: #{entry[:scopes].inspect}.freeze",
125
+ " },"
126
+ ]
127
+ )
128
+ end
129
+
130
+ lines.concat(
131
+ [
132
+ " }.freeze",
133
+ " MANAGEMENT_SCOPES = #{scopes.inspect}.freeze",
134
+ " end",
135
+ "end",
136
+ ""
137
+ ]
138
+ )
139
+ lines.join("\n")
140
+ end
141
+
142
+ def render_surface_raw(class_name, entries)
143
+ lines = [
144
+ " class #{class_name}",
145
+ " def initialize(client)",
146
+ " @client = client",
147
+ " end",
148
+ ""
149
+ ]
150
+
151
+ entries.each do |entry|
152
+ lines.concat(
153
+ [
154
+ " # #{entry[:summary]}",
155
+ " def #{entry[:method_name]}(request = nil, options: nil, **kwargs)",
156
+ " @client.send(:perform_request_raw, #{entry[:operation_id].inspect}, request, options: options, **kwargs)",
157
+ " end",
158
+ ""
159
+ ]
160
+ )
161
+ end
162
+
163
+ lines.concat([" end", ""])
164
+ lines
165
+ end
166
+
167
+ def render_surface(class_name, raw_class_name, entries, auth_type:, auth_value_name:)
168
+ auth_line = auth_type == "none" ? ' auth_value: nil,' : " auth_value: #{auth_value_name},"
169
+
170
+ lines = [
171
+ " class #{class_name} < BaseClient",
172
+ " attr_reader :raw",
173
+ "",
174
+ " def initialize(base_url:, #{auth_value_name}: nil, headers: nil, timeout: nil, user_agent: nil, retry_options: nil, transport: nil)"
175
+ ]
176
+
177
+ if auth_type == "none"
178
+ lines[-1] = " def initialize(base_url:, headers: nil, timeout: nil, user_agent: nil, retry_options: nil, transport: nil)"
179
+ end
180
+
181
+ lines.concat(
182
+ [
183
+ " super(",
184
+ " base_url: base_url,",
185
+ " auth_type: #{auth_type.inspect},",
186
+ auth_line,
187
+ " headers: headers,",
188
+ " timeout: timeout,",
189
+ " user_agent: user_agent,",
190
+ " retry_options: retry_options,",
191
+ " transport: transport",
192
+ " )",
193
+ " @raw = #{raw_class_name}.new(self)",
194
+ " end",
195
+ ""
196
+ ]
197
+ )
198
+
199
+ entries.each do |entry|
200
+ lines.concat(
201
+ [
202
+ " # #{entry[:summary]}",
203
+ " def #{entry[:method_name]}(request = nil, options: nil, **kwargs)",
204
+ " perform_request(#{entry[:operation_id].inspect}, request, options: options, **kwargs)",
205
+ " end",
206
+ ""
207
+ ]
208
+ )
209
+ end
210
+
211
+ lines.concat([" end", ""])
212
+ lines
213
+ end
214
+
215
+ def render_clients(surfaces)
216
+ lines = [
217
+ "# Generated by scripts/generate_from_openapi.rb. Do not edit manually.",
218
+ "",
219
+ "module LicenseKit",
220
+ ""
221
+ ]
222
+
223
+ lines.concat(render_surface_raw("ManagementRawClient", surfaces.fetch("management")))
224
+ lines.concat(render_surface("ManagementClient", "ManagementRawClient", surfaces.fetch("management"), auth_type: "bearer", auth_value_name: "token"))
225
+ lines.concat(render_surface_raw("RuntimeRawClient", surfaces.fetch("runtime")))
226
+ lines.concat(render_surface("RuntimeClient", "RuntimeRawClient", surfaces.fetch("runtime"), auth_type: "license", auth_value_name: "license_key"))
227
+ lines.concat(render_surface_raw("SystemRawClient", surfaces.fetch("system")))
228
+ lines.concat(render_surface("SystemClient", "SystemRawClient", surfaces.fetch("system"), auth_type: "none", auth_value_name: "unused"))
229
+ lines.concat(["end", ""])
230
+ lines.join("\n")
231
+ end
232
+
233
+ document = YAML.safe_load(File.read(SPEC_PATH), aliases: true)
234
+ paths = document.fetch("paths", {})
235
+
236
+ metadata_entries = []
237
+ scope_entries = []
238
+ surface_entries = {
239
+ "management" => [],
240
+ "runtime" => [],
241
+ "system" => []
242
+ }
243
+
244
+ paths.each do |pathname, path_item|
245
+ next unless path_item.is_a?(Hash)
246
+
247
+ path_item.each do |method, operation|
248
+ next unless operation.is_a?(Hash)
249
+
250
+ operation_id = operation["operationId"]
251
+ next if operation_id.nil? || operation_id.empty?
252
+
253
+ auth = infer_auth(operation["security"])
254
+ summary = operation["summary"] || operation_id
255
+ success_entries = extract_success_responses(operation["responses"])
256
+ surface = auth == "bearer" ? "management" : auth == "license" ? "runtime" : "system"
257
+ method_name = camel_to_snake(operation_id)
258
+
259
+ metadata_entries << {
260
+ operation_id: operation_id,
261
+ method: method.to_s.upcase,
262
+ path: pathname,
263
+ auth: auth,
264
+ summary: summary,
265
+ success: success_entries,
266
+ method_name: method_name
267
+ }
268
+
269
+ surface_entries.fetch(surface) << {
270
+ operation_id: operation_id,
271
+ method_name: method_name,
272
+ summary: summary
273
+ }
274
+
275
+ required_scopes = operation["x-required-scopes"]
276
+ next unless required_scopes.is_a?(Array)
277
+
278
+ scope_entries << {
279
+ operation_id: operation_id,
280
+ method: method.to_s.upcase,
281
+ path: pathname,
282
+ scopes: required_scopes.map(&:to_s).sort
283
+ }
284
+ end
285
+ end
286
+
287
+ metadata_entries.sort_by! { |entry| entry[:operation_id] }
288
+ scope_entries.sort_by! { |entry| entry[:operation_id] }
289
+ surface_entries.each_value { |entries| entries.sort_by! { |entry| entry[:operation_id] } }
290
+
291
+ FileUtils.mkdir_p(GENERATED_DIR)
292
+ File.write(File.join(GENERATED_DIR, "metadata.rb"), render_metadata(metadata_entries, surface_entries))
293
+ File.write(File.join(GENERATED_DIR, "operation_scopes.rb"), render_scopes(scope_entries))
294
+ File.write(File.join(GENERATED_DIR, "clients.rb"), render_clients(surface_entries))
@@ -0,0 +1,156 @@
1
+ require_relative "test_helper"
2
+
3
+ class ClientTest < Minitest::Test
4
+ def test_management_client_normalizes_base_url_and_injects_bearer_auth
5
+ transport = lambda do |request|
6
+ assert_equal "https://api.licensekit.dev/api/v1/products?limit=25", request.url
7
+ assert_equal "Bearer mgmt_test_token", request.headers["Authorization"]
8
+ assert_equal "application/json", request.headers["Accept"]
9
+
10
+ LicenseKit::TransportResponse.new(
11
+ status: 200,
12
+ headers: { "Content-Type" => "application/json" },
13
+ body: JSON.generate(
14
+ "data" => [],
15
+ "meta" => {
16
+ "request_id" => "req_123",
17
+ "timestamp" => "2026-03-24T00:00:00Z"
18
+ }
19
+ )
20
+ )
21
+ end
22
+
23
+ client = LicenseKit::ManagementClient.new(
24
+ base_url: "https://api.licensekit.dev///",
25
+ token: "mgmt_test_token",
26
+ transport: transport
27
+ )
28
+
29
+ response = client.list_products(query: { limit: 25 })
30
+
31
+ assert_equal [], response["data"]
32
+ end
33
+
34
+ def test_runtime_client_injects_license_auth_and_idempotency_key
35
+ transport = lambda do |request|
36
+ assert_equal "License lic_test_key", request.headers["Authorization"]
37
+ assert_equal "idem_123", request.headers["Idempotency-Key"]
38
+ assert_equal "application/json", request.headers["Content-Type"]
39
+ assert_equal "POST", request.method
40
+ assert_equal({ "fingerprint" => "host-123" }, JSON.parse(request.body))
41
+
42
+ LicenseKit::TransportResponse.new(
43
+ status: 200,
44
+ headers: { "Content-Type" => "application/json" },
45
+ body: JSON.generate(
46
+ "data" => {
47
+ "license_id" => "lic_1",
48
+ "status" => "active",
49
+ "license_type" => "subscription",
50
+ "entitlement_version" => 1,
51
+ "issued_at" => "2026-03-24T00:00:00Z",
52
+ "next_check_at" => "2026-03-25T00:00:00Z",
53
+ "device_id" => "dev_1",
54
+ "features" => []
55
+ },
56
+ "signature" => {
57
+ "alg" => "Ed25519",
58
+ "kid" => "kid_1",
59
+ "value" => "AQID"
60
+ },
61
+ "meta" => {
62
+ "request_id" => "req_runtime",
63
+ "timestamp" => "2026-03-24T00:00:00Z"
64
+ }
65
+ )
66
+ )
67
+ end
68
+
69
+ client = LicenseKit::RuntimeClient.new(
70
+ base_url: "https://api.licensekit.dev",
71
+ license_key: "lic_test_key",
72
+ transport: transport
73
+ )
74
+
75
+ response = client.activate_license(
76
+ body: { "fingerprint" => "host-123" },
77
+ idempotency_key: "idem_123"
78
+ )
79
+
80
+ assert_equal "lic_1", response["data"]["license_id"]
81
+ end
82
+
83
+ def test_system_client_health_alias_and_readyz_503_are_successes
84
+ transport = lambda do |request|
85
+ body =
86
+ if request.url.end_with?("/health")
87
+ {
88
+ "data" => { "status" => "ok" },
89
+ "meta" => {
90
+ "request_id" => "req_health",
91
+ "timestamp" => "2026-03-24T00:00:00Z"
92
+ }
93
+ }
94
+ elsif request.url.end_with?("/readyz")
95
+ {
96
+ "data" => { "status" => "not_ready", "db" => "down" },
97
+ "meta" => {
98
+ "request_id" => "req_ready",
99
+ "timestamp" => "2026-03-24T00:00:00Z"
100
+ }
101
+ }
102
+ else
103
+ raise "Unexpected path: #{request.url}"
104
+ end
105
+
106
+ status = request.url.end_with?("/readyz") ? 503 : 200
107
+ LicenseKit::TransportResponse.new(
108
+ status: status,
109
+ headers: { "Content-Type" => "application/json" },
110
+ body: JSON.generate(body)
111
+ )
112
+ end
113
+
114
+ client = LicenseKit::SystemClient.new(
115
+ base_url: "https://api.licensekit.dev",
116
+ transport: transport
117
+ )
118
+
119
+ health = client.health
120
+ ready = client.raw.readyz
121
+
122
+ assert_equal "ok", health["data"]["status"]
123
+ assert_equal 503, ready.status
124
+ assert_equal "not_ready", ready.data["data"]["status"]
125
+ end
126
+
127
+ def test_error_envelopes_raise_api_error
128
+ transport = lambda do |_request|
129
+ LicenseKit::TransportResponse.new(
130
+ status: 403,
131
+ headers: { "Content-Type" => "application/json" },
132
+ body: JSON.generate(
133
+ "error" => {
134
+ "code" => "TOKEN_SCOPE_DENIED",
135
+ "message" => "scope denied"
136
+ },
137
+ "meta" => {
138
+ "request_id" => "req_forbidden",
139
+ "timestamp" => "2026-03-24T00:00:00Z"
140
+ }
141
+ )
142
+ )
143
+ end
144
+
145
+ client = LicenseKit::ManagementClient.new(
146
+ base_url: "https://api.licensekit.dev",
147
+ token: "mgmt_test_token",
148
+ transport: transport
149
+ )
150
+
151
+ error = assert_raises(LicenseKit::ApiError) { client.list_products }
152
+ assert_equal 403, error.status
153
+ assert_equal "TOKEN_SCOPE_DENIED", error.code
154
+ assert_equal "req_forbidden", error.request_id
155
+ end
156
+ end
@@ -0,0 +1,6 @@
1
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "minitest/autorun"
6
+ require "licensekit"
@@ -0,0 +1,14 @@
1
+ require_relative "test_helper"
2
+
3
+ class ScopesTest < Minitest::Test
4
+ def test_get_required_scopes
5
+ scopes = LicenseKit.get_required_scopes("createProduct")
6
+ assert_equal ["product:write"], scopes
7
+ end
8
+
9
+ def test_has_required_scopes
10
+ assert_equal true, LicenseKit.has_required_scopes("createProduct", ["product:write"])
11
+ assert_equal true, LicenseKit.has_required_scopes("createProduct", ["admin"])
12
+ assert_equal false, LicenseKit.has_required_scopes("createProduct", ["product:read"])
13
+ end
14
+ end
@@ -0,0 +1,44 @@
1
+ require_relative "test_helper"
2
+ require "ed25519"
3
+
4
+ class VerificationTest < Minitest::Test
5
+ def test_verify_runtime_payload_and_tamper_detection
6
+ signing_key = Ed25519::SigningKey.generate
7
+ verify_key = signing_key.verify_key
8
+ payload = {
9
+ "license_id" => "lic_1",
10
+ "status" => "active"
11
+ }
12
+ payload_bytes = '{"license_id":"lic_1","status":"active"}'
13
+ signature_bytes = signing_key.sign(payload_bytes)
14
+ key_store = LicenseKit::PublicKeyStore.new(
15
+ [
16
+ {
17
+ "kid" => "kid_live",
18
+ "algorithm" => "Ed25519",
19
+ "public_key" => Base64.strict_encode64(verify_key.to_bytes),
20
+ "status" => "active",
21
+ "created_at" => "2026-03-24T00:00:00Z"
22
+ }
23
+ ]
24
+ )
25
+ signature = {
26
+ "alg" => "Ed25519",
27
+ "kid" => "kid_live",
28
+ "value" => Base64.strict_encode64(signature_bytes)
29
+ }
30
+
31
+ verified = LicenseKit.verify_runtime_payload(payload, signature, key_store)
32
+ tampered = LicenseKit.verify_runtime_result(
33
+ {
34
+ "data" => payload.merge("status" => "revoked"),
35
+ "signature" => signature
36
+ },
37
+ key_store
38
+ )
39
+
40
+ assert_equal true, verified.ok
41
+ assert_equal "kid_live", verified.key["kid"]
42
+ assert_equal false, tampered.ok
43
+ end
44
+ end
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: licensekit-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.alpha.0
5
+ platform: ruby
6
+ authors:
7
+ - David Main
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-03-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: ed25519
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 1.3.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 1.3.0
27
+ description: Typed-ish Ruby SDK for the LicenseKit licensing API with management,
28
+ runtime, and system clients plus Ed25519 verification helpers.
29
+ email:
30
+ - dtmain@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - ".gitignore"
36
+ - LICENSE
37
+ - README.md
38
+ - examples/01_create_scoped_api_key.rb
39
+ - examples/02_runtime_validate_and_verify.rb
40
+ - lib/licensekit.rb
41
+ - lib/licensekit/client.rb
42
+ - lib/licensekit/errors.rb
43
+ - lib/licensekit/generated/clients.rb
44
+ - lib/licensekit/generated/metadata.rb
45
+ - lib/licensekit/generated/operation_scopes.rb
46
+ - lib/licensekit/scopes.rb
47
+ - lib/licensekit/types.rb
48
+ - lib/licensekit/verification.rb
49
+ - lib/licensekit/version.rb
50
+ - openapi/openapi.yaml
51
+ - scripts/generate_from_openapi.rb
52
+ - test/test_client.rb
53
+ - test/test_helper.rb
54
+ - test/test_scopes.rb
55
+ - test/test_verification.rb
56
+ homepage: https://licensekit.dev
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ homepage_uri: https://licensekit.dev
61
+ documentation_uri: https://licensekit.dev/docs/agent-quickstart
62
+ changelog_uri: https://github.com/drmain1/licensekit-ruby/releases
63
+ bug_tracker_uri: https://github.com/drmain1/licensekit-ruby/issues
64
+ source_code_uri: https://github.com/drmain1/licensekit-ruby
65
+ rubygems_mfa_required: 'true'
66
+ post_install_message:
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '2.6'
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">"
78
+ - !ruby/object:Gem::Version
79
+ version: 1.3.1
80
+ requirements: []
81
+ rubygems_version: 3.0.3.1
82
+ signing_key:
83
+ specification_version: 4
84
+ summary: Ruby SDK for the LicenseKit licensing API
85
+ test_files: []