licentivo-sdk 0.10.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: 998ed5c517528b8aa65ef3c83722dbcf39ad178e5c0a0008983ccd9837bfaf3a
4
+ data.tar.gz: ca64986426a30cfa1c96baeba82af59b8ad1b24b6051d22f5e192d8f886b0930
5
+ SHA512:
6
+ metadata.gz: 13f47cb98e670d8c315359af3290721410b733258fbbe0fce5d65b253eac574500276373be935649ff5b14826f51d1860cebb9663da500bf2ab302a10dcd5c61
7
+ data.tar.gz: 694ec4cf2631aef6abe1596ded72a0201b480f64235a13f12f750bca4c211397a7c0dabd62ed85e4a4c831fe95f40acf16d627ef7956c0a78f0a683b008b9b43
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 spf86 and Licentivo contributors
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/NOTICE ADDED
@@ -0,0 +1,17 @@
1
+ Licentivo client SDKs
2
+ Copyright (c) 2026 spf86 and Licentivo contributors
3
+ Project: https://github.com/spf86/licentivo-sdk
4
+
5
+ The MIT license applies to the Licentivo client SDK code, examples and
6
+ installation tools. The licensing server and administration application are
7
+ separate software and are not included in this SDK distribution.
8
+
9
+ Bundled third-party source retains its original license:
10
+ - cJSON: see sdk/c/vendor/cJSON-LICENSE (or c/vendor/cJSON-LICENSE).
11
+ - Monocypher and its Ed25519 extension: see
12
+ sdk/c/vendor/Monocypher-LICENCE.md (or c/vendor/Monocypher-LICENCE.md).
13
+ Native packages include these files in the licenses directory.
14
+
15
+ External dependencies such as libcurl, OpenSSL, cryptography, undici and
16
+ BouncyCastle retain their own licenses. Their installation is handled by
17
+ the corresponding package manager or the target system.
data/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # Licentivo Ruby SDK
2
+
3
+ Add software licensing to a Ruby application with a block-based interface and a bundled native licensing core.
4
+
5
+ ## Features
6
+
7
+ - Activates or restores a license with a private machine-scoped device credential.
8
+ - Verifies pinned signatures, checks expiry and feature access, and protects your business block.
9
+ - Runs policy heartbeats, supports permitted offline use and floating-seat cleanup.
10
+ - Handles metered-feature reserve/commit/cancel and journals pending settlement across restarts.
11
+ - Supports refresh, explicit device deactivation, offline file activation and structured error diagnostics.
12
+
13
+ Your application provides the activation interface, the actual business block and durable business results. The SDK manages licensing; customer credentials and vendor management keys do not belong in distributed packages.
14
+
15
+ ## Installation and runtime requirements
16
+
17
+ Start with the repository's `QUICKSTART.md`. The application in
18
+ `examples/quickstart.rb` activates/restores a device and actually exports a file.
19
+ It also supports `LICENTIVO_METERED=1` with a stable `LICENTIVO_OPERATION_ID`.
20
+
21
+ Prerequisites: Ruby 3.1+ with Fiddle, a C11 compiler and libcurl development files.
22
+ Linux also needs OpenSSL development files. Installing a source gem requires make
23
+ (RubyInstaller Devkit/MSYS2 on Windows). A matching precompiled platform gem
24
+ includes the core and installs without a C compiler; Windows also includes the
25
+ matching libcurl DLL and its license. The platform gem still requires Fiddle.
26
+ The source launcher builds the bundled
27
+ core automatically. For a source gem, RubyGems builds it during installation.
28
+ Windows uses MinGW and a matching libcurl package; set `LICENTIVO_CURL_ROOT` and add
29
+ its `bin` directory to the process's PATH. Physical macOS acceptance is pending.
30
+
31
+ Install the pinned RubyGems release, or add it to your application's Gemfile:
32
+
33
+ ```bash
34
+ gem install licentivo-sdk --version 0.10.0
35
+ ```
36
+
37
+ ```ruby
38
+ gem 'licentivo-sdk', '= 0.10.0'
39
+ ```
40
+
41
+ The [common website installer](https://www.licentivo.com/en/docs.html#sdk) with `ruby` also selects and installs a verified matching platform gem. It prints the per-terminal GEM_PATH command; platform gems avoid a local C build. A complete local source bundle can also be used:
42
+
43
+ ```ruby
44
+ # Gemfile
45
+ gem 'licentivo-sdk', path: 'SDK_ROOT/sdk/ruby'
46
+ ```
47
+
48
+ For a path gem, run `ruby SDK_ROOT/sdk/ruby/build_native.rb` before starting your
49
+ application. A built `.gem` runs this compilation automatically during `gem install`.
50
+ The SDK does not download executable code when you require it.
51
+
52
+ ```ruby
53
+ require 'licentivo'
54
+
55
+ Licentivo::Client.open_with_license('product.json', customer_key) do |client|
56
+ client.run_feature('export') { File.write('report.txt', "Your result\n") }
57
+ # A real application keeps client alive through its main loop.
58
+ end
59
+ ```
60
+
61
+ `run_metered_feature('export', 1, job_id) { your_operation }` handles reserve,
62
+ business execution, commit and cancellation. It returns an `Execution` with
63
+ `executed` and `value`; `replay?` means the business block was skipped and you
64
+ should load its existing result using the job ID. Exceptions are re-raised after
65
+ the core handles cancellation. If the process dies during the block, check the
66
+ real business outcome and call `resolve_metered_feature(job_id, true_or_false)`.
67
+
68
+ Use one distinct stable ID per job; separate processes must not execute the same
69
+ ID. Do not close or start another workflow from inside a business block. Status
70
+ reads are available there. The client serializes native access; policy heartbeats
71
+ run inside the core without entering Ruby. Poll `status` or `feature_status` for
72
+ your UI and marshal updates to its thread.
73
+
74
+ `Licentivo::Error` exposes `code`, `http_status`, `request_id`, `retryable` and
75
+ `retry_after_seconds`. Online refresh, explicit deactivate, offline exchange and
76
+ low-level metering methods are also provided. Use `close` or the owning block's
77
+ ensure cleanup on shutdown; normal exits retain node-locked device registration.
78
+
79
+ Do not ship test keys, `sdk-demo.json`, credentials, caches or management API keys.
80
+ TLS and pinned signatures stay enabled. Licenses accompany the vendored core.
data/build_native.rb ADDED
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+ # Compiles the shared licensing core without downloading anything or modifying
3
+ # system settings. Source bundles ship canonical copies under native/.
4
+ require 'fileutils'
5
+ require 'open3'
6
+ require 'rbconfig'
7
+ require 'shellwords'
8
+
9
+ package = File.expand_path(__dir__)
10
+ bundled = File.join(package, 'native')
11
+ core = File.file?(File.join(bundled, 'licentivo.c')) ? bundled : File.expand_path('../c', package)
12
+ windows = RbConfig::CONFIG['host_os'].match?(/mswin|mingw/)
13
+ mac = RbConfig::CONFIG['host_os'].include?('darwin')
14
+ extension = windows ? 'dll' : mac ? 'dylib' : 'so'
15
+ output = ARGV[0] || File.join(package, 'lib/licentivo/native', "licentivo_native.#{extension}")
16
+ FileUtils.mkdir_p(File.dirname(output))
17
+ compiler = Shellwords.split(ENV.fetch('CC', windows ? 'gcc' : 'cc'))
18
+ command = compiler + ['-std=c11', '-O2', "-I#{core}"]
19
+ command << '-fPIC' unless windows
20
+ command << (mac ? '-dynamiclib' : '-shared')
21
+ command << '-Wl,--export-all-symbols' if windows
22
+ curl = ENV['LICENTIVO_CURL_ROOT']
23
+ if curl && !curl.empty?
24
+ command << "-I#{File.join(curl, 'include')}"
25
+ command << "-L#{File.join(curl, 'lib')}"
26
+ elsif windows
27
+ abort 'Set LICENTIVO_CURL_ROOT to your matching libcurl development folder. See README.md.'
28
+ elsif system('pkg-config', '--exists', 'libcurl', 'openssl', out: File::NULL, err: File::NULL)
29
+ flags, status = Open3.capture2('pkg-config', '--cflags', '--libs', 'libcurl', 'openssl')
30
+ abort 'pkg-config failed' unless status.success?
31
+ # Library options belong after the object/source files in the linker command.
32
+ includes, libraries = Shellwords.split(flags).partition { |flag| flag.start_with?('-I', '-D') }
33
+ command.concat(includes)
34
+ end
35
+ command.concat(%w[licentivo.c vendor/cJSON.c vendor/monocypher.c vendor/monocypher-ed25519.c].map { |file| File.join(core, file) })
36
+ command.concat(libraries || ['-lcurl'])
37
+ command.concat(windows ? %w[-lbcrypt -ladvapi32] : %w[-lcrypto -lpthread -lm])
38
+ command.concat(['-o', output])
39
+ abort 'Native core build failed. Install a C11 compiler and libcurl/OpenSSL development files.' unless system(*command)
40
+ if windows && curl
41
+ dlls = Dir[File.join(File.expand_path(curl), 'bin', 'libcurl*.dll')]
42
+ abort 'The matching runtime libcurl DLL is missing' if dlls.empty?
43
+ dlls.each { |dll| FileUtils.cp(dll, File.dirname(output)) }
44
+ license = [File.join(curl, 'COPYING'), File.join(curl, 'COPYING.txt')].find { |file| File.file?(file) }
45
+ abort 'The libcurl license is missing' unless license
46
+ FileUtils.cp(license, File.join(File.dirname(output), 'libcurl-LICENSE'))
47
+ end
48
+ puts "Native licensing core built: #{output}"
data/examples/app.rb ADDED
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+ require_relative '../lib/licentivo'
3
+
4
+ def ask(label)
5
+ print label
6
+ $stdout.flush
7
+ $stdin.gets&.strip || ''
8
+ end
9
+
10
+ def export_report
11
+ File.write('licensed-report.txt', "A report generated by the licensed application.\n")
12
+ puts 'Saved licensed-report.txt'
13
+ end
14
+
15
+ config = ARGV[0] || 'product.json'
16
+ key = ENV.fetch('LICENTIVO_LICENSE_KEY', '')
17
+ client = nil
18
+ begin
19
+ begin
20
+ client = Licentivo::Client.open_with_license(config, key)
21
+ rescue Licentivo::Error => error
22
+ warn error.message
23
+ key = ask('Customer activation key (not a management API key): ')
24
+ client = Licentivo::Client.open_with_license(config, key)
25
+ end
26
+ loop do
27
+ puts "\n1 Status | 2 Export | 3 Metered export | 4 Refresh | 5 Transfer device | 0 Quit"
28
+ choice = ask('> ')
29
+ break if choice == '0' || choice.empty?
30
+ begin
31
+ case choice
32
+ when '1' then puts JSON.pretty_generate(client.status)
33
+ when '2' then client.run_feature('export') { export_report }
34
+ when '3'
35
+ id = ask('Stable job ID (reuse only to recover this same job): ')
36
+ result = client.run_metered_feature('export', 1, id) { export_report }
37
+ puts 'Existing job confirmed; export was not repeated' if result.replay?
38
+ when '4' then client.refresh_online
39
+ when '5'
40
+ if ask('Type TRANSFER to explicitly unbind this device: ') == 'TRANSFER'
41
+ client.deactivate
42
+ puts 'Device unbound; reactivate on the destination device'
43
+ break
44
+ end
45
+ else puts 'Choose a menu number'
46
+ end
47
+ rescue StandardError => error
48
+ warn error.message
49
+ end
50
+ end
51
+ rescue StandardError => error
52
+ warn error.message
53
+ exit 2
54
+ ensure
55
+ client&.close # Always stop heartbeat; normal exit retains device registration.
56
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+ require_relative '../lib/licentivo'
3
+
4
+ def setting(name, fallback)
5
+ value = ENV[name]
6
+ value.nil? || value.empty? ? fallback : value
7
+ end
8
+
9
+ def export_report(path)
10
+ # Replace this write with your existing export operation.
11
+ File.write(path, "A report generated by the licensed application.\n")
12
+ end
13
+
14
+ begin
15
+ # A GUI supplies the activation input. Later starts use an empty key.
16
+ Licentivo::Client.open_with_license(
17
+ setting('LICENTIVO_CONFIG', 'product.json'),
18
+ setting('LICENTIVO_LICENSE_KEY', '')
19
+ ) do |client|
20
+ feature = setting('LICENTIVO_FEATURE', 'export')
21
+ path = setting('LICENTIVO_OUTPUT', 'licensed-report.txt')
22
+ if setting('LICENTIVO_METERED', '0') == '1'
23
+ client.run_metered_feature(feature, 1, setting('LICENTIVO_OPERATION_ID', '')) { export_report(path) }
24
+ else
25
+ client.run_feature(feature) { export_report(path) }
26
+ end
27
+ puts "License OK: #{feature} is available\nExport completed: #{path}"
28
+ end # Real app: keep the owning block/client alive until shutdown.
29
+ rescue StandardError => error
30
+ warn error.message
31
+ exit 2
32
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ require_relative '../lib/licentivo'
3
+
4
+ fixture = JSON.parse(File.read(ARGV[0] || 'tests/lease-vectors.json'))
5
+ fixture.fetch('vectors').each do |vector|
6
+ actual = Licentivo.verify_lease(vector['envelope'], fixture['trusted_keys'],
7
+ vector['product_id'], vector['device_id'], vector['now'])
8
+ abort "Ruby vector failed: #{vector['name']}" unless actual == vector['valid']
9
+ end
10
+ puts "Ruby security core: #{fixture['vectors'].length} lease vectors passed"
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ require 'mkmf'
3
+
4
+ package = File.expand_path('../..', __dir__)
5
+ core = File.join(package, 'native')
6
+ abort 'Bundled native core is missing' unless File.file?(File.join(core, 'licentivo.c'))
7
+ $INCFLAGS << " -I#{Shellwords.escape(core)}"
8
+ $VPATH.concat([core, File.join(core, 'vendor')])
9
+ $srcs = %w[licentivo.c cJSON.c monocypher.c monocypher-ed25519.c]
10
+ $CFLAGS << ' -std=c11'
11
+ curl = ENV['LICENTIVO_CURL_ROOT']
12
+ if curl && !curl.empty?
13
+ dir_config('curl', File.join(curl, 'include'), File.join(curl, 'lib'))
14
+ end
15
+ abort 'libcurl development files are required' unless have_header('curl/curl.h') && have_library('curl', 'curl_easy_init')
16
+ if RbConfig::CONFIG['host_os'].match?(/mswin|mingw/)
17
+ $DLDFLAGS << ' -Wl,--export-all-symbols'
18
+ have_library('bcrypt')
19
+ have_library('advapi32')
20
+ else
21
+ abort 'OpenSSL development files are required' unless have_header('openssl/evp.h') && have_library('crypto', 'EVP_sha256')
22
+ have_library('pthread')
23
+ have_library('m')
24
+ end
25
+ # Fiddle loads this library directly. It contains no Ruby C API calls or Init_*
26
+ # hook; the target path is also used by the standalone source build script.
27
+ create_makefile('licentivo/native/licentivo_native')
data/lib/licentivo.rb ADDED
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+ require 'fiddle'
3
+ require 'json'
4
+ require 'monitor'
5
+
6
+ module Licentivo
7
+ VERSION = '0.10.0'
8
+
9
+ class Error < StandardError
10
+ attr_reader :code, :http_status, :request_id, :retryable, :retry_after_seconds
11
+ def initialize(message, code: 'SDK_ERROR', http_status: 0, request_id: '', retryable: false, retry_after_seconds: 0)
12
+ @code, @http_status, @request_id = code, http_status, request_id
13
+ @retryable, @retry_after_seconds = retryable, retry_after_seconds
14
+ super("#{code}: #{message}#{request_id.empty? ? '' : " (request #{request_id})"}")
15
+ end
16
+ end
17
+
18
+ Execution = Struct.new(:value, :executed, keyword_init: true) do
19
+ def replay? = !executed
20
+ end
21
+
22
+ # This library does not register Ruby callbacks on the native heartbeat thread.
23
+ # Local status reads can be marshalled by the application to its own UI thread.
24
+ module Native
25
+ module_function
26
+ def library
27
+ @library ||= begin
28
+ suffix = case RUBY_PLATFORM
29
+ when /mswin|mingw/ then 'dll'
30
+ when /darwin/ then 'dylib'
31
+ else 'so'
32
+ end
33
+ folder = File.expand_path('licentivo/native', __dir__)
34
+ path = ENV['LICENTIVO_NATIVE_LIBRARY'] || [suffix, 'so', 'dll', 'dylib'].uniq.map { |ext| File.join(folder, "licentivo_native.#{ext}") }.find { |file| File.file?(file) }
35
+ path ||= File.join(folder, "licentivo_native.#{suffix}")
36
+ unless File.file?(path)
37
+ raise LoadError, 'Licentivo native core is missing. Run ruby sdk/ruby/build_native.rb for a source bundle, or install the compiled gem. See sdk/ruby/README.md.'
38
+ end
39
+ if suffix == 'dll'
40
+ # Ruby's Windows loader does not search process PATH for dependent
41
+ # DLLs. Load the matching libcurl explicitly and keep its handle alive.
42
+ candidates = Dir[File.join(File.dirname(path), 'libcurl*.dll')]
43
+ if ENV['LICENTIVO_CURL_ROOT']
44
+ candidates.concat(Dir[File.join(File.expand_path(ENV['LICENTIVO_CURL_ROOT']), 'bin', 'libcurl*.dll')])
45
+ end
46
+ @curl_library = Fiddle.dlopen(candidates.first) unless candidates.empty?
47
+ end
48
+ Fiddle.dlopen(path)
49
+ end
50
+ end
51
+ def bind(name, arguments, result)
52
+ # Release the GVL while the core uses the network. Fiddle closures acquire
53
+ # it again before entering a Ruby block; native heartbeats never enter Ruby.
54
+ Fiddle::Function.new(library[name], arguments, result, need_gvl: false)
55
+ end
56
+ def functions
57
+ @functions ||= begin
58
+ p, i, q = Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_LONG_LONG
59
+ signatures = {
60
+ init: ['ln_init_file_with_license', [p,p], p],
61
+ start: ['ln_start', [p], i], destroy: ['ln_destroy', [p], Fiddle::TYPE_VOID],
62
+ last_error: ['ln_last_error', [p], p], error_info: ['ln_get_error_info', [p,p], i],
63
+ status: ['ln_status', [p], p], feature_status: ['ln_feature_status', [p,p], p],
64
+ run_feature: ['ln_run_feature', [p,p,p,p], i],
65
+ run_metered_feature: ['ln_run_metered_feature', [p,p,q,p,p,p], i],
66
+ resolve_metered_feature: ['ln_resolve_metered_feature', [p,p,i], i],
67
+ validate: ['ln_validate', [p], i], heartbeat: ['ln_heartbeat', [p], i],
68
+ refresh_online: ['ln_refresh_online', [p], i], deactivate: ['ln_deactivate', [p], i],
69
+ reserve: ['ln_reserve', [p,p,q,p], p], consume: ['ln_consume', [p,p,q,p], p],
70
+ commit: ['ln_commit', [p,p,p], p], cancel: ['ln_cancel', [p,p,p], p],
71
+ offline_request: ['ln_offline_request', [p,p], p], import_offline: ['ln_import_offline', [p,p,p], i],
72
+ free_string: ['ln_free_string', [p], Fiddle::TYPE_VOID],
73
+ json: ['ln_json', [p], p], delete_json: ['cJSON_Delete', [p], Fiddle::TYPE_VOID],
74
+ verify: ['ln_verify', [p,p,p,p,q], i]
75
+ }
76
+ signatures.transform_values { |name, args, result| bind(name, args, result) }
77
+ end
78
+ end
79
+ def call(name, *arguments) = functions.fetch(name).call(*arguments)
80
+ def input(value)
81
+ value = String(value)
82
+ raise ArgumentError, 'Input must not contain NUL bytes' if value.include?("\0")
83
+ value + "\0"
84
+ end
85
+ def text(pointer) = pointer.null? ? '' : pointer.to_s.force_encoding(Encoding::UTF_8)
86
+ def owned_json(pointer)
87
+ raise Error.new('Native core returned no result', code: 'INVALID_RESPONSE') if pointer.null?
88
+ begin
89
+ JSON.parse(pointer.to_s)
90
+ ensure
91
+ call(:free_string, pointer)
92
+ end
93
+ end
94
+ end
95
+
96
+ class Client
97
+ def self.open_with_license(path, customer_key = '')
98
+ client = new(path, customer_key)
99
+ begin
100
+ client.start
101
+ rescue Exception
102
+ client.close
103
+ raise
104
+ end
105
+ return client unless block_given?
106
+ begin
107
+ yield client
108
+ ensure
109
+ client.close
110
+ end
111
+ end
112
+
113
+ def initialize(path, customer_key = '')
114
+ @lock, @in_callback = Monitor.new, false
115
+ @native = Native.call(:init, Native.input(path), Native.input(customer_key))
116
+ if @native.null?
117
+ @native = nil
118
+ raise Error.new('Check product.json, pinned keys and local storage', code: 'CONFIGURATION_ERROR')
119
+ end
120
+ end
121
+
122
+ def start = checked(:start)
123
+ def validate = checked(:validate)
124
+ def heartbeat = checked(:heartbeat)
125
+ def refresh_online = checked(:refresh_online)
126
+ def deactivate = checked(:deactivate)
127
+ def status = read_json(:status)
128
+ def feature_status(feature) = read_json(:feature_status, Native.input(feature))
129
+ def reserve(feature, quantity, job_id) = read_json(:reserve, Native.input(feature), integer(quantity), Native.input(job_id))
130
+ def consume(feature, quantity, job_id) = read_json(:consume, Native.input(feature), integer(quantity), Native.input(job_id))
131
+ def commit(reservation_id, job_id) = read_json(:commit, Native.input(reservation_id), Native.input(job_id))
132
+ def cancel(reservation_id, job_id) = read_json(:cancel, Native.input(reservation_id), Native.input(job_id))
133
+ def offline_request(operation = 'activate') = read_json(:offline_request, Native.input(operation))
134
+ def import_offline(request, response) = checked(:import_offline, Native.input(JSON.generate(request)), Native.input(JSON.generate(response)))
135
+ def resolve_metered_feature(job_id, business_succeeded)
136
+ unless business_succeeded == true || business_succeeded == false
137
+ raise ArgumentError, 'Determine the real business outcome; pass true or false'
138
+ end
139
+ checked(:resolve_metered_feature, Native.input(job_id), business_succeeded ? 1 : 0)
140
+ end
141
+
142
+ def run_feature(feature, &action)
143
+ execution = run_callback(:run_feature, Native.input(feature), &action)
144
+ execution.value
145
+ end
146
+ def run_metered_feature(feature, quantity, job_id, &action)
147
+ run_callback(:run_metered_feature, Native.input(feature), integer(quantity), Native.input(job_id), &action)
148
+ end
149
+
150
+ def close
151
+ @lock.synchronize do
152
+ raise Error.new('Do not close this client inside its business callback', code: 'WORKFLOW_BUSY') if @in_callback
153
+ if @native
154
+ Native.call(:destroy, @native)
155
+ @native = nil
156
+ end
157
+ end
158
+ nil
159
+ end
160
+
161
+ private
162
+ def integer(value)
163
+ raise ArgumentError, 'Quantity must be a positive signed 64-bit integer' unless value.is_a?(Integer) && value.positive? && value <= 0x7fff_ffff_ffff_ffff
164
+ value
165
+ end
166
+ def active
167
+ raise Error.new('Client is closed', code: 'CLIENT_CLOSED') unless @native
168
+ @native
169
+ end
170
+ def error
171
+ # ln_error_info: int, char[80], char[128], int, int; the shared ABI uses
172
+ # 32-bit ints on supported Windows/Linux/macOS targets.
173
+ info = Fiddle::Pointer.malloc(220, Fiddle::RUBY_FREE)
174
+ info[0,220] = "\0" * 220
175
+ Native.call(:error_info, @native, info)
176
+ Error.new('The licensing operation did not complete',
177
+ code: info[4,80].split("\0", 2).first,
178
+ http_status: info[0,4].unpack1('i'), request_id: info[84,128].split("\0", 2).first,
179
+ retryable: info[212,4].unpack1('i') != 0, retry_after_seconds: info[216,4].unpack1('i'))
180
+ end
181
+ def checked(name, *arguments)
182
+ @lock.synchronize { raise error if Native.call(name, active, *arguments) == 0 }
183
+ true
184
+ end
185
+ def read_json(name, *arguments)
186
+ @lock.synchronize do
187
+ pointer = Native.call(name, active, *arguments)
188
+ raise error if pointer.null?
189
+ Native.owned_json(pointer)
190
+ end
191
+ end
192
+ def run_callback(name, *arguments)
193
+ raise ArgumentError, 'Supply your business operation as a block' unless block_given?
194
+ @lock.synchronize do
195
+ raise Error.new('A business callback is already running', code: 'WORKFLOW_BUSY') if @in_callback
196
+ execution = Execution.new(executed: false)
197
+ business_error = nil
198
+ callback = Fiddle::Closure::BlockCaller.new(Fiddle::TYPE_INT, [Fiddle::TYPE_VOIDP]) do |_context|
199
+ @in_callback = true
200
+ begin
201
+ execution.executed = true
202
+ execution.value = yield
203
+ 1
204
+ rescue Exception => exception
205
+ # Exceptions never cross the C boundary. The native workflow settles
206
+ # cancellation (or journals a pending cancellation) before re-raising.
207
+ business_error = exception
208
+ 0
209
+ ensure
210
+ @in_callback = false
211
+ end
212
+ end
213
+ ok = Native.call(name, active, *arguments, callback, 0)
214
+ raise business_error if business_error
215
+ raise error if ok == 0
216
+ execution
217
+ end
218
+ end
219
+ end
220
+
221
+ def self.verify_lease(envelope, pins, product, device, now)
222
+ parsed_envelope = Native.call(:json, Native.input(JSON.generate(envelope)))
223
+ parsed_pins = Native.call(:json, Native.input(JSON.generate(pins)))
224
+ begin
225
+ Native.call(:verify, parsed_envelope, parsed_pins, Native.input(product), Native.input(device), Integer(now)) != 0
226
+ ensure
227
+ Native.call(:delete_json, parsed_envelope)
228
+ Native.call(:delete_json, parsed_pins)
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,72 @@
1
+ /* Included by licentivo.c only. Private task IDs/outcome, never business data. */
2
+ static int task_error(ln_client*c,const char*code){lock(c);snprintf(c->error,sizeof(c->error),"%s",code);c->http_status=0;c->retryable=0;c->request_id[0]=0;view_lock(c);int online=c->view_online;view_unlock(c);publish(c,online);unlock(c);return 0;}
3
+ static int task_enter(ln_client*c){view_lock(c);int busy=c->task_busy;if(!busy)c->task_busy=1;view_unlock(c);return !busy;}
4
+ static void task_leave(ln_client*c){view_lock(c);c->task_busy=0;view_unlock(c);}
5
+ static char*task_path(ln_client*c,const char*id){
6
+ if(!id||!id[0]||strlen(id)>80)return NULL;
7
+ for(const unsigned char*p=(const unsigned char*)id;*p;p++)if(*p<33||*p>126)return NULL;
8
+ char source[65],digest[65],input[256];lock(c);
9
+ const char*key=text(c->cfg,"license_key");int ok=key&&key[0]?hash(key,source):text(c->auth,"source_hash")!=NULL;
10
+ if(ok&&(!key||!key[0]))snprintf(source,sizeof(source),"%s",text(c->auth,"source_hash"));unlock(c);
11
+ if(!ok)return NULL;snprintf(input,sizeof(input),"%s\n%s",source,id);if(!hash(input,digest))return NULL;
12
+ const char*cache=text(c->cfg,"cache_path");if(!cache)return NULL;
13
+ char*path=malloc(strlen(cache)+78);if(path)sprintf(path,"%s.task-%s.json",cache,digest);return path;
14
+ }
15
+ static cJSON*task_read(const char*path,const char*id){
16
+ struct stat info;if(stat(path,&info)==0){if(info.st_size>4096)return NULL;char*raw=readfile(path,4096);cJSON*task=raw?ln_json(raw):NULL;free(raw);int64_t version;
17
+ int64_t quantity;
18
+ if(!task||!num(task,"version",&version)||version!=1||!text(task,"operation_id")||strcmp(text(task,"operation_id"),id)||!text(task,"reservation_id")||!text(task,"reservation_id")[0]||!text(task,"state")||!text(task,"feature")||!text(task,"feature")[0]||!num(task,"quantity",&quantity)||quantity<=0){cJSON_Delete(task);return NULL;}return task;
19
+ }
20
+ if(errno!=ENOENT)return NULL;
21
+ cJSON*task=cJSON_CreateObject();cJSON_AddNumberToObject(task,"version",1);cJSON_AddStringToObject(task,"operation_id",id);cJSON_AddStringToObject(task,"reservation_id","");cJSON_AddStringToObject(task,"state","");return task;
22
+ }
23
+ static void task_state(cJSON*task,const char*state){cJSON_ReplaceItemInObjectCaseSensitive(task,"state",cJSON_CreateString(state));}
24
+ static int task_finish(ln_client*c,const char*path,cJSON*task,int succeeded){
25
+ char*raw=succeeded?ln_commit(c,text(task,"reservation_id"),text(task,"operation_id")):ln_cancel(c,text(task,"reservation_id"),text(task,"operation_id"));
26
+ cJSON*receipt=raw?ln_json(raw):NULL;ln_free_string(raw);if(!receipt)return 0;
27
+ const char*state=text(receipt,"status"),*expected=succeeded?"committed":"canceled";
28
+ int ok=state&&!strcmp(state,expected);cJSON_Delete(receipt);
29
+ if(!ok)return task_error(c,"RESERVATION_CLOSED");task_state(task,expected);
30
+ return write_state(path,task)?1:task_error(c,"TASK_JOURNAL_ERROR");
31
+ }
32
+ int ln_run_metered_feature(ln_client*c,const char*feature,int64_t quantity,const char*id,ln_feature_action action,void*context){
33
+ if(!c||!feature||!action||quantity<=0)return c?task_error(c,"TASK_INPUT_INVALID"):0;
34
+ if(!task_enter(c))return task_error(c,"TASK_BUSY");
35
+ int ok=0;char*path=task_path(c,id);cJSON*task=path?task_read(path,id):NULL;
36
+ if(!task){task_error(c,"TASK_JOURNAL_ERROR");goto done;}
37
+ const char*state=text(task,"state");
38
+ if(state[0]){int64_t old_quantity;if(!text(task,"feature")||strcmp(text(task,"feature"),feature)||!num(task,"quantity",&old_quantity)||old_quantity!=quantity){task_error(c,"OPERATION_ID_CONFLICT");goto done;}}
39
+ if(!state[0]){cJSON_AddStringToObject(task,"feature",feature);cJSON_AddNumberToObject(task,"quantity",(double)quantity);}
40
+ if(!strcmp(state,"committed")){ok=1;goto done;}
41
+ if(!strcmp(state,"commit_pending")){ok=task_finish(c,path,task,1);goto done;}
42
+ if(!strcmp(state,"cancel_pending")){if(!task_finish(c,path,task,0))goto done;task_error(c,"OPERATION_CANCELED");goto done;}
43
+ if(!strcmp(state,"canceled")){task_error(c,"OPERATION_CANCELED");goto done;}
44
+ if(!strcmp(state,"business_running")){task_error(c,"BUSINESS_OUTCOME_UNKNOWN");goto done;}
45
+ if(state[0]){task_error(c,"TASK_JOURNAL_ERROR");goto done;}
46
+ if(!ln_check_feature(c,feature)){task_error(c,"FEATURE_NOT_ENTITLED");goto done;}
47
+ char*raw=ln_reserve(c,feature,quantity,id);cJSON*hold=raw?ln_json(raw):NULL;ln_free_string(raw);
48
+ if(!hold)goto done;
49
+ const char*held=text(hold,"status");
50
+ if(held&&!strcmp(held,"committed")){cJSON_ReplaceItemInObjectCaseSensitive(task,"reservation_id",cJSON_CreateString(text(hold,"reservation_id")));task_state(task,"committed");cJSON_Delete(hold);ok=write_state(path,task)?1:task_error(c,"TASK_JOURNAL_ERROR");goto done;}
51
+ if(!held||strcmp(held,"pending")||!text(hold,"reservation_id")){cJSON_Delete(hold);task_error(c,"RESERVATION_CLOSED");goto done;}
52
+ cJSON_ReplaceItemInObjectCaseSensitive(task,"reservation_id",cJSON_CreateString(text(hold,"reservation_id")));cJSON_Delete(hold);task_state(task,"business_running");
53
+ if(!write_state(path,task)){raw=ln_cancel(c,text(task,"reservation_id"),id);ln_free_string(raw);task_error(c,"TASK_JOURNAL_ERROR");goto done;}
54
+ if(!action(context)){
55
+ task_state(task,"cancel_pending");if(write_state(path,task))task_finish(c,path,task,0);task_error(c,"BUSINESS_ACTION_FAILED");goto done;
56
+ }
57
+ task_state(task,"commit_pending");if(!write_state(path,task)){task_error(c,"BUSINESS_COMPLETED_JOURNAL_ERROR");goto done;}
58
+ ok=task_finish(c,path,task,1);
59
+ done:
60
+ cJSON_Delete(task);free(path);task_leave(c);return ok;
61
+ }
62
+ int ln_resolve_metered_feature(ln_client*c,const char*id,int succeeded){
63
+ if(!c)return 0;if(!task_enter(c))return task_error(c,"TASK_BUSY");
64
+ int ok=0;char*path=task_path(c,id);cJSON*task=path?task_read(path,id):NULL;
65
+ if(!task){task_error(c,"TASK_JOURNAL_ERROR");goto done;}
66
+ const char*state=text(task,"state");if(!strcmp(state,succeeded?"committed":"canceled")){ok=1;goto done;}
67
+ if(!text(task,"reservation_id")[0]||(strcmp(state,"business_running")&&strcmp(state,"commit_pending")&&strcmp(state,"cancel_pending"))||(!succeeded&&!strcmp(state,"commit_pending"))||(succeeded&&!strcmp(state,"cancel_pending"))){task_error(c,"TASK_OUTCOME_CONFLICT");goto done;}
68
+ task_state(task,succeeded?"commit_pending":"cancel_pending");if(!write_state(path,task)){task_error(c,"TASK_JOURNAL_ERROR");goto done;}
69
+ ok=task_finish(c,path,task,succeeded);
70
+ done:
71
+ cJSON_Delete(task);free(path);task_leave(c);return ok;
72
+ }