licentivo-launch 0.1.1

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.
Files changed (143) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +35 -0
  4. data/ext/launch_native/extconf.rb +28 -0
  5. data/lib/licentivo_launch/vendor/licentivo.rb +262 -0
  6. data/lib/licentivo_launch.rb +352 -0
  7. data/vendor/LICENSE +21 -0
  8. data/vendor/NOTICE +17 -0
  9. data/vendor/VERSION +1 -0
  10. data/vendor/c/app.c +33 -0
  11. data/vendor/c/demo.c +77 -0
  12. data/vendor/c/feature_tasks.h +72 -0
  13. data/vendor/c/licentivo.c +382 -0
  14. data/vendor/c/licentivo.h +67 -0
  15. data/vendor/c/quickstart.c +51 -0
  16. data/vendor/c/test_vectors.c +4 -0
  17. data/vendor/c/vendor/Monocypher-LICENCE.md +173 -0
  18. data/vendor/c/vendor/cJSON-LICENSE +20 -0
  19. data/vendor/c/vendor/cJSON.c +3191 -0
  20. data/vendor/c/vendor/cJSON.h +306 -0
  21. data/vendor/c/vendor/monocypher-ed25519.c +500 -0
  22. data/vendor/c/vendor/monocypher-ed25519.h +140 -0
  23. data/vendor/c/vendor/monocypher.c +2956 -0
  24. data/vendor/c/vendor/monocypher.h +321 -0
  25. data/vendor/curl/COPYING.txt +22 -0
  26. data/vendor/curl/bin/libcurl-x64.dll +0 -0
  27. data/vendor/curl/include/brotli/decode.h +409 -0
  28. data/vendor/curl/include/brotli/encode.h +505 -0
  29. data/vendor/curl/include/brotli/port.h +302 -0
  30. data/vendor/curl/include/brotli/shared_dictionary.h +100 -0
  31. data/vendor/curl/include/brotli/types.h +83 -0
  32. data/vendor/curl/include/curl/curl.h +3374 -0
  33. data/vendor/curl/include/curl/curlver.h +77 -0
  34. data/vendor/curl/include/curl/easy.h +123 -0
  35. data/vendor/curl/include/curl/header.h +74 -0
  36. data/vendor/curl/include/curl/mprintf.h +85 -0
  37. data/vendor/curl/include/curl/multi.h +561 -0
  38. data/vendor/curl/include/curl/options.h +70 -0
  39. data/vendor/curl/include/curl/stdcheaders.h +35 -0
  40. data/vendor/curl/include/curl/system.h +399 -0
  41. data/vendor/curl/include/curl/typecheck-gcc.h +964 -0
  42. data/vendor/curl/include/curl/urlapi.h +156 -0
  43. data/vendor/curl/include/curl/websockets.h +98 -0
  44. data/vendor/curl/include/libpsl.h +207 -0
  45. data/vendor/curl/include/libssh2.h +1516 -0
  46. data/vendor/curl/include/libssh2_publickey.h +128 -0
  47. data/vendor/curl/include/libssh2_sftp.h +382 -0
  48. data/vendor/curl/include/nghttp2/nghttp2.h +6878 -0
  49. data/vendor/curl/include/nghttp2/nghttp2ver.h +42 -0
  50. data/vendor/curl/include/nghttp3/nghttp3.h +3704 -0
  51. data/vendor/curl/include/nghttp3/version.h +46 -0
  52. data/vendor/curl/include/ngtcp2/ngtcp2.h +7485 -0
  53. data/vendor/curl/include/ngtcp2/ngtcp2_crypto.h +1071 -0
  54. data/vendor/curl/include/ngtcp2/ngtcp2_crypto_quictls.h +147 -0
  55. data/vendor/curl/include/ngtcp2/version.h +51 -0
  56. data/vendor/curl/include/openssl/aes.h +120 -0
  57. data/vendor/curl/include/openssl/asn1.h +1121 -0
  58. data/vendor/curl/include/openssl/asn1t.h +996 -0
  59. data/vendor/curl/include/openssl/bio.h +716 -0
  60. data/vendor/curl/include/openssl/blowfish.h +106 -0
  61. data/vendor/curl/include/openssl/bn.h +480 -0
  62. data/vendor/curl/include/openssl/buffer.h +102 -0
  63. data/vendor/curl/include/openssl/camellia.h +121 -0
  64. data/vendor/curl/include/openssl/cast.h +99 -0
  65. data/vendor/curl/include/openssl/chacha.h +54 -0
  66. data/vendor/curl/include/openssl/cmac.h +81 -0
  67. data/vendor/curl/include/openssl/cms.h +541 -0
  68. data/vendor/curl/include/openssl/comp.h +7 -0
  69. data/vendor/curl/include/openssl/conf.h +189 -0
  70. data/vendor/curl/include/openssl/crypto.h +458 -0
  71. data/vendor/curl/include/openssl/ct.h +567 -0
  72. data/vendor/curl/include/openssl/curve25519.h +104 -0
  73. data/vendor/curl/include/openssl/des.h +215 -0
  74. data/vendor/curl/include/openssl/dh.h +245 -0
  75. data/vendor/curl/include/openssl/dsa.h +263 -0
  76. data/vendor/curl/include/openssl/dtls1.h +107 -0
  77. data/vendor/curl/include/openssl/ec.h +675 -0
  78. data/vendor/curl/include/openssl/ecdh.h +6 -0
  79. data/vendor/curl/include/openssl/ecdsa.h +6 -0
  80. data/vendor/curl/include/openssl/engine.h +215 -0
  81. data/vendor/curl/include/openssl/err.h +325 -0
  82. data/vendor/curl/include/openssl/evp.h +1288 -0
  83. data/vendor/curl/include/openssl/hkdf.h +65 -0
  84. data/vendor/curl/include/openssl/hmac.h +101 -0
  85. data/vendor/curl/include/openssl/idea.h +99 -0
  86. data/vendor/curl/include/openssl/kdf.h +137 -0
  87. data/vendor/curl/include/openssl/lhash.h +179 -0
  88. data/vendor/curl/include/openssl/md4.h +105 -0
  89. data/vendor/curl/include/openssl/md5.h +99 -0
  90. data/vendor/curl/include/openssl/mlkem.h +236 -0
  91. data/vendor/curl/include/openssl/modes.h +118 -0
  92. data/vendor/curl/include/openssl/obj_mac.h +4646 -0
  93. data/vendor/curl/include/openssl/objects.h +137 -0
  94. data/vendor/curl/include/openssl/ocsp.h +484 -0
  95. data/vendor/curl/include/openssl/opensslconf.h +16 -0
  96. data/vendor/curl/include/openssl/opensslfeatures.h +153 -0
  97. data/vendor/curl/include/openssl/opensslv.h +18 -0
  98. data/vendor/curl/include/openssl/ossl_typ.h +196 -0
  99. data/vendor/curl/include/openssl/pem.h +542 -0
  100. data/vendor/curl/include/openssl/pkcs12.h +284 -0
  101. data/vendor/curl/include/openssl/pkcs7.h +523 -0
  102. data/vendor/curl/include/openssl/poly1305.h +45 -0
  103. data/vendor/curl/include/openssl/posix_time.h +54 -0
  104. data/vendor/curl/include/openssl/rand.h +118 -0
  105. data/vendor/curl/include/openssl/rc2.h +101 -0
  106. data/vendor/curl/include/openssl/rc4.h +88 -0
  107. data/vendor/curl/include/openssl/ripemd.h +107 -0
  108. data/vendor/curl/include/openssl/rsa.h +603 -0
  109. data/vendor/curl/include/openssl/safestack.h +1739 -0
  110. data/vendor/curl/include/openssl/sha.h +190 -0
  111. data/vendor/curl/include/openssl/sm3.h +49 -0
  112. data/vendor/curl/include/openssl/sm4.h +47 -0
  113. data/vendor/curl/include/openssl/srtp.h +148 -0
  114. data/vendor/curl/include/openssl/ssl.h +2336 -0
  115. data/vendor/curl/include/openssl/ssl3.h +441 -0
  116. data/vendor/curl/include/openssl/stack.h +99 -0
  117. data/vendor/curl/include/openssl/tls1.h +764 -0
  118. data/vendor/curl/include/openssl/ts.h +660 -0
  119. data/vendor/curl/include/openssl/txt_db.h +112 -0
  120. data/vendor/curl/include/openssl/ui.h +397 -0
  121. data/vendor/curl/include/openssl/x509.h +1037 -0
  122. data/vendor/curl/include/openssl/x509_vfy.h +464 -0
  123. data/vendor/curl/include/openssl/x509v3.h +1039 -0
  124. data/vendor/curl/include/zconf.h +206 -0
  125. data/vendor/curl/include/zdict.h +481 -0
  126. data/vendor/curl/include/zlib.h +1859 -0
  127. data/vendor/curl/include/zlib_name_mangling.h +8 -0
  128. data/vendor/curl/include/zstd.h +3198 -0
  129. data/vendor/curl/include/zstd_errors.h +107 -0
  130. data/vendor/curl/lib/libcurl.dll.a +0 -0
  131. data/vendor/curl/licenses/brotli/LICENSE.txt +19 -0
  132. data/vendor/curl/licenses/certdata/LICENSE.url +2 -0
  133. data/vendor/curl/licenses/libpsl/COPYING.txt +20 -0
  134. data/vendor/curl/licenses/libressl/COPYING.txt +133 -0
  135. data/vendor/curl/licenses/libssh2/COPYING.txt +43 -0
  136. data/vendor/curl/licenses/nghttp2/COPYING.txt +23 -0
  137. data/vendor/curl/licenses/nghttp3/COPYING.txt +22 -0
  138. data/vendor/curl/licenses/ngtcp2/COPYING.txt +22 -0
  139. data/vendor/curl/licenses/psl/LICENSE.txt +373 -0
  140. data/vendor/curl/licenses/trurl/COPYING.txt +22 -0
  141. data/vendor/curl/licenses/zlibng/LICENSE.md +19 -0
  142. data/vendor/curl/licenses/zstd/LICENSE.txt +30 -0
  143. metadata +205 -0
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+ require 'uri'
4
+ require 'net/http'
5
+ require 'openssl'
6
+ require 'base64'
7
+ require 'digest'
8
+ require 'securerandom'
9
+ require 'fileutils'
10
+ require 'time'
11
+ require 'monitor'
12
+
13
+ module LicentivoLaunch
14
+ VERSION = '0.1.1'
15
+ LIMIT = 1 << 20
16
+ UUID = /\A[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\z/
17
+ class Error < StandardError
18
+ attr_reader :code, :status, :retry_after_seconds
19
+ def initialize(code, status: 0, retry_after_seconds: 0)
20
+ @code = code.to_s.match?(/\A[A-Z][A-Z0-9_]{0,79}\z/) ? code.to_s : 'REQUEST_FAILED'
21
+ @status, @retry_after_seconds = status, [retry_after_seconds, 0].max
22
+ super("Launch: #{@code}")
23
+ end
24
+ end
25
+ # Cancellation is cooperative. A running native SDK operation finishes before
26
+ # cancellation is observed; it is never reclassified as an offline success.
27
+ class CancellationToken
28
+ def cancel = @cancelled = true
29
+ def check! = (raise Error.new('CANCELLED') if @cancelled)
30
+ end
31
+ class MemoryStore
32
+ def initialize = @values = {}
33
+ def persistent? = false
34
+ def load(scope) = @values.key?(scope) ? JSON.parse(JSON.generate(@values[scope])) : nil
35
+ def save(scope, record) = @values[scope] = JSON.parse(JSON.generate(record))
36
+ def delete(scope) = @values.delete(scope)
37
+ end
38
+ # The application must obtain this encryption key from its OS credential vault.
39
+ # Never save the key beside the encrypted records or inside product.json.
40
+ class EncryptedFileStore
41
+ def initialize(directory, key)
42
+ raise Error.new('STORE_KEY_REQUIRED') unless key.is_a?(String) && key.bytesize == 32
43
+ @root, @key = File.expand_path(directory), key.b.dup
44
+ end
45
+ def persistent? = true
46
+ def secure? = true
47
+ def path(scope) = File.join(@root, "#{Digest::SHA256.hexdigest(scope)}.aesgcm")
48
+ def load(scope)
49
+ return nil unless File.exist?(path(scope))
50
+ raise Error.new('INVALID_SAVED_SESSION') if File.size(path(scope)) > LIMIT
51
+ raw = File.binread(path(scope))
52
+ begin
53
+ raise ArgumentError unless raw.bytesize >= 30 && raw.getbyte(0) == 1
54
+ cipher = OpenSSL::Cipher.new('aes-256-gcm').decrypt
55
+ cipher.key, cipher.iv, cipher.auth_tag = @key, raw.byteslice(1, 12), raw.byteslice(13, 16)
56
+ cipher.auth_data = scope
57
+ plain = cipher.update(raw.byteslice(29..)) + cipher.final
58
+ JSON.parse(plain)
59
+ rescue OpenSSL::Cipher::CipherError, JSON::ParserError, ArgumentError
60
+ raise Error.new('INVALID_SAVED_SESSION')
61
+ ensure
62
+ plain&.replace("\0" * plain.bytesize)
63
+ end
64
+ end
65
+ def save(scope, record)
66
+ FileUtils.mkdir_p(@root, mode: 0o700)
67
+ plain, iv = JSON.generate(record), SecureRandom.random_bytes(12)
68
+ raise Error.new('INVALID_SAVED_SESSION') if plain.bytesize > LIMIT - 64
69
+ cipher = OpenSSL::Cipher.new('aes-256-gcm').encrypt
70
+ cipher.key, cipher.iv, cipher.auth_data = @key, iv, scope
71
+ encrypted = cipher.update(plain) + cipher.final
72
+ temporary = File.join(@root, ".session-#{SecureRandom.uuid}")
73
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |f| f.binmode; f.write("\1" + iv + cipher.auth_tag + encrypted) }
74
+ File.rename(temporary, path(scope))
75
+ ensure
76
+ FileUtils.rm_f(temporary) if temporary
77
+ plain&.replace("\0" * plain.bytesize)
78
+ end
79
+ def delete(scope) = FileUtils.rm_f(path(scope))
80
+ end
81
+
82
+ def self.origin(value, environment)
83
+ uri = URI.parse(value)
84
+ allowed = uri.is_a?(URI::HTTPS) || environment == 'sandbox' && uri.is_a?(URI::HTTP) && %w[localhost 127.0.0.1 [::1]].include?(uri.host)
85
+ raise Error.new('INVALID_CONFIG') unless allowed && uri.host && !uri.userinfo && !uri.query && !uri.fragment && ['', '/'].include?(uri.path)
86
+ uri.path = ''; uri.to_s
87
+ rescue URI::InvalidURIError, TypeError
88
+ raise Error.new('INVALID_CONFIG')
89
+ end
90
+ def self.validate_config(launch, product)
91
+ fields = %w[schema_version merchant_origin launch_product_id licentivo_product_id environment]
92
+ unless launch.is_a?(Hash) && (launch.keys - fields).empty? && launch['schema_version'] == 1 && UUID.match?(launch['launch_product_id'].to_s) && UUID.match?(launch['licentivo_product_id'].to_s) && %w[sandbox live].include?(launch['environment'])
93
+ raise Error.new('INVALID_CONFIG')
94
+ end
95
+ public_fields = %w[product_id trusted_keys base_url app_version license_key cache_path device_name allow_http]
96
+ unless product.is_a?(Hash) && (product.keys - public_fields).empty? && product['product_id'] == launch['licentivo_product_id'] && [nil, ''].include?(product['license_key']) && [nil, ''].include?(product['cache_path']) && [nil, true, false].include?(product['allow_http'])
97
+ raise Error.new('PUBLIC_CONFIG_INVALID')
98
+ end
99
+ %w[app_version device_name].each do |field|
100
+ value = product[field]
101
+ raise Error.new('PUBLIC_CONFIG_INVALID') if value && (!value.is_a?(String) || value.length > (field == 'app_version' ? 80 : 128))
102
+ end
103
+ keys = product['trusted_keys']
104
+ unless keys.is_a?(Hash) && (1..32).cover?(keys.length) && keys.all? { |id, key| id.is_a?(String) && (1..128).cover?(id.length) && key.is_a?(String) && key.match?(/\A[A-Za-z0-9_-]{43}\z/) && Base64.urlsafe_encode64(Base64.urlsafe_decode64(key), padding: false) == key }
105
+ raise Error.new('PUBLIC_CONFIG_INVALID')
106
+ end
107
+ runtime_origin = origin(product['base_url'] || 'https://www.licentivo.com', launch['environment'])
108
+ raise Error.new('PUBLIC_CONFIG_INVALID') if runtime_origin.start_with?('http:') && product['allow_http'] != true
109
+ safe = { 'product_id' => product['product_id'], 'trusted_keys' => keys, 'base_url' => runtime_origin }
110
+ safe['app_version'] = product['app_version'] if product['app_version']
111
+ safe['allow_http'] = true if product['allow_http'] == true
112
+ [JSON.parse(JSON.generate(launch.merge('merchant_origin' => origin(launch['merchant_origin'], launch['environment'])))), JSON.parse(JSON.generate(safe))]
113
+ end
114
+ def self.load_config(launch_path, product_path)
115
+ read = lambda do |path|
116
+ raise Error.new('CONFIG_TOO_LARGE') if File.size(path) > 65_536
117
+ JSON.parse(File.read(path, encoding: 'bom|utf-8'))
118
+ end
119
+ validate_config(read.call(launch_path), read.call(product_path))
120
+ end
121
+
122
+ # Net::HTTP never follows redirects. Proxy inheritance is disabled to avoid
123
+ # accidentally routing merchant credentials through an application proxy.
124
+ def self.default_transport(request)
125
+ request[:cancellation]&.check!
126
+ uri = URI(request.fetch(:url))
127
+ http = Net::HTTP.new(uri.hostname, uri.port, nil)
128
+ http.use_ssl = uri.scheme == 'https'
129
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
130
+ http.open_timeout = 10; http.read_timeout = 15; http.write_timeout = 15
131
+ http.max_retries = 0
132
+ req = (request[:method] == 'POST' ? Net::HTTP::Post : Net::HTTP::Get).new(uri.request_uri, request[:headers])
133
+ req.body = request[:body] if request[:body]
134
+ result = nil
135
+ # Explicit start keeps the connection alive until the response is consumed.
136
+ # Net::HTTP's implicit start adds Connection: close, which can cause a TCP
137
+ # reset on Windows when an endpoint ignores a small request body (logout).
138
+ http.start do |connection|
139
+ connection.request(req) do |response|
140
+ status = response.code.to_i
141
+ raise Error.new('REDIRECT_REJECTED', status: status) if (300..399).cover?(status)
142
+ body = +''
143
+ response.read_body do |chunk|
144
+ request[:cancellation]&.check!
145
+ body << chunk
146
+ raise Error.new('RESPONSE_TOO_LARGE') if body.bytesize > LIMIT
147
+ end
148
+ result = { status: status, headers: response.to_hash.transform_values(&:first), body: body }
149
+ end
150
+ end
151
+ request[:cancellation]&.check!
152
+ result
153
+ rescue IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError
154
+ request[:cancellation]&.check!
155
+ raise Error.new('NETWORK_UNAVAILABLE')
156
+ end
157
+
158
+ class Application
159
+ def initialize(launch, product, store: MemoryStore.new, transport: nil, runtime_factory: nil, cache_dir: nil, sleep: nil, pending_attempts: 5)
160
+ @launch, @product = LicentivoLaunch.validate_config(launch, product)
161
+ raise Error.new('SECURE_STORE_REQUIRED') unless store.respond_to?(:persistent?) && (!store.persistent? || store.respond_to?(:secure?) && store.secure?)
162
+ raise Error.new('INVALID_RETRY_POLICY') unless pending_attempts.is_a?(Integer) && (1..5).cover?(pending_attempts)
163
+ @store, @transport = store, transport || LicentivoLaunch.method(:default_transport)
164
+ @factory = runtime_factory || lambda do |config|
165
+ require_relative 'licentivo_launch/vendor/licentivo'
166
+ raise Error.new('SDK_VERSION_MISMATCH') unless Licentivo::VERSION == '0.11.0'
167
+ Licentivo::Client.new(config)
168
+ end
169
+ @root = File.expand_path(cache_dir || File.join(Dir.home, '.cache', 'LicentivoLaunch'))
170
+ @scope = ['launch-session-v1', @launch['merchant_origin'], @launch['launch_product_id'], @launch['licentivo_product_id'], @launch['environment']].join("\n")
171
+ @lock, @closed, @in_callback, @attempts = Monitor.new, false, false, pending_attempts
172
+ @wait = sleep || lambda { |seconds, cancellation| (seconds * 10).ceil.times { cancellation&.check!; Kernel.sleep(0.1) }; cancellation&.check! }
173
+ end
174
+ def self.connect(merchant_origin, product_id, licentivo_product_id, trusted_keys, environment: 'live', **options)
175
+ new({ 'schema_version' => 1, 'merchant_origin' => merchant_origin, 'launch_product_id' => product_id, 'licentivo_product_id' => licentivo_product_id, 'environment' => environment },
176
+ { 'base_url' => 'https://www.licentivo.com', 'product_id' => licentivo_product_id, 'trusted_keys' => trusted_keys }, **options)
177
+ end
178
+ def self.from_files(launch_path, product_path, **options) = new(*LicentivoLaunch.load_config(launch_path, product_path), **options)
179
+ def login(email, password, cancellation: nil)
180
+ serial do
181
+ cancellation&.check!; logout_internal(cancellation)
182
+ grant = call('/auth/login', method: 'POST', body: { email: email, password: password, kind: 'desktop' }, cancellation: cancellation)
183
+ @record = { 'version' => 1, 'scope' => @scope, 'user' => grant['user'], 'token' => grant['access_token'], 'expires_at' => grant['expires_at'] }
184
+ begin
185
+ raise Error.new('INVALID_SESSION') unless valid_identity?(@record) && expiry(@record) > Time.now
186
+ @record['credential'] = credential(cancellation)
187
+ state = start_runtime(false, cancellation); @store.save(@scope, @record); state
188
+ rescue StandardError
189
+ logout_internal(nil); raise
190
+ end
191
+ end
192
+ end
193
+ def restore(cancellation: nil)
194
+ serial do
195
+ cancellation&.check!; stop; @record = saved
196
+ next nil unless @record
197
+ if expiry(@record) <= Time.now
198
+ clear; raise Error.new('SESSION_EXPIRED')
199
+ end
200
+ begin
201
+ me = call('/me', cancellation: cancellation)
202
+ raise Error.new('SESSION_MISMATCH', status: 401) unless me.dig('user', 'id') == @record.dig('user', 'id')
203
+ @record['credential'] = credential(cancellation); @record['user'] = me['user']
204
+ rescue Error => e
205
+ cancellation&.check!
206
+ next start_runtime(true, cancellation) if e.code == 'NETWORK_UNAVAILABLE' || (500..599).cover?(e.status)
207
+ clear if e.code != 'ENTITLEMENT_PENDING' && ([401, 403, 404].include?(e.status) || %w[SESSION_EXPIRED SESSION_MISMATCH ENTITLEMENT_EXPIRED ENTITLEMENT_SUSPENDED ENTITLEMENT_NOT_PURCHASED].include?(e.code))
208
+ raise
209
+ end
210
+ begin
211
+ state = start_runtime(false, cancellation); @store.save(@scope, @record); state
212
+ rescue StandardError
213
+ clear; raise
214
+ end
215
+ end
216
+ end
217
+ def run_feature(feature, &action)
218
+ serial do
219
+ raise Error.new('LOGIN_REQUIRED') unless @runtime
220
+ @runtime.run_feature(feature) do
221
+ begin
222
+ @in_callback = true; action.call
223
+ ensure
224
+ @in_callback = false
225
+ end
226
+ end
227
+ end
228
+ end
229
+ def logout(cancellation: nil) = serial { logout_internal(cancellation) }
230
+ def release_device(cancellation: nil)
231
+ serial do
232
+ cancellation&.check!
233
+ raise Error.new('LOGIN_REQUIRED') unless @runtime
234
+ begin
235
+ @runtime.deactivate; cancellation&.check!
236
+ ensure
237
+ logout_internal(cancellation)
238
+ end
239
+ end
240
+ end
241
+ def close
242
+ @lock.synchronize do
243
+ raise Error.new('WORKFLOW_BUSY') if @in_callback
244
+ return if @closed
245
+ @closed = true
246
+ begin; stop; ensure; @record = nil; end
247
+ end
248
+ end
249
+
250
+ private
251
+ def serial
252
+ @lock.synchronize do
253
+ raise Error.new('APPLICATION_CLOSED') if @closed
254
+ raise Error.new('WORKFLOW_BUSY') if @in_callback
255
+ yield
256
+ end
257
+ end
258
+ def cache(record) = File.join(@root, Digest::SHA256.hexdigest(@scope), Digest::SHA256.hexdigest(record.dig('user', 'id')), 'lease.json')
259
+ def expiry(record) = Time.iso8601(record['expires_at'])
260
+ def valid_identity?(record)
261
+ record.is_a?(Hash) && record['version'] == 1 && record['scope'] == @scope && record['user'].is_a?(Hash) && UUID.match?(record.dig('user', 'id').to_s) && record['token'].is_a?(String) && record['token'].match?(/\A[A-Za-z0-9_-]{43}\z/) && record['expires_at'].is_a?(String) && record['expires_at'].match?(/(?:Z|[+-]\d\d:\d\d)\z/) && expiry(record)
262
+ rescue ArgumentError, TypeError
263
+ false
264
+ end
265
+ def saved
266
+ record = @store.load(@scope)
267
+ if record && !(valid_identity?(record) && record['credential'].is_a?(Hash) && record.dig('credential', 'product_id') == @launch['launch_product_id'] && record.dig('credential', 'license_key').is_a?(String) && !record.dig('credential', 'license_key').empty?)
268
+ clear(record); raise Error.new('INVALID_SAVED_SESSION')
269
+ end
270
+ record
271
+ rescue StandardError
272
+ @store.delete(@scope); raise
273
+ end
274
+ def call(path, method: 'GET', body: nil, cancellation: nil, token: @record&.fetch('token', nil))
275
+ cancellation&.check!
276
+ headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
277
+ headers['Authorization'] = "Bearer #{token}" if token
278
+ response = @transport.call(url: @launch['merchant_origin'] + '/api/v1' + path, method: method, headers: headers, body: body && JSON.generate(body), cancellation: cancellation)
279
+ cancellation&.check!
280
+ status = response[:status]
281
+ raise Error.new('INVALID_RESPONSE') unless status.is_a?(Integer) && response[:body].is_a?(String) && response[:body].bytesize <= LIMIT
282
+ raise Error.new('REDIRECT_REJECTED', status: status) if (300..399).cover?(status)
283
+ begin
284
+ data = JSON.parse(response[:body])
285
+ rescue JSON::ParserError
286
+ raise Error.new((500..599).cover?(status) ? 'SERVICE_UNAVAILABLE' : 'INVALID_RESPONSE', status: status)
287
+ end
288
+ raise Error.new('INVALID_RESPONSE', status: status) unless data.is_a?(Hash)
289
+ if status != 200
290
+ retry_header = response.fetch(:headers, {})['retry-after'].to_s
291
+ seconds = retry_header.match?(/\A\d+\z/) ? retry_header.to_i : (Time.httpdate(retry_header) - Time.now).ceil rescue 0
292
+ raise Error.new(data.dig('error', 'code') || 'REQUEST_FAILED', status: status, retry_after_seconds: seconds)
293
+ end
294
+ raise Error.new('INVALID_RESPONSE') unless data['data'].is_a?(Hash)
295
+ data['data']
296
+ end
297
+ def credential(cancellation)
298
+ @attempts.times do |attempt|
299
+ begin
300
+ value = call("/software/products/#{@launch['launch_product_id']}/credential", method: 'POST', body: { environment: @launch['environment'] }, cancellation: cancellation)
301
+ raise Error.new('CREDENTIAL_MISMATCH') unless value['product_id'] == @launch['launch_product_id'] && value['license_key'].is_a?(String) && !value['license_key'].empty? && value['version'].is_a?(Integer) && value['version'] >= 1
302
+ return value
303
+ rescue Error => e
304
+ raise unless e.status == 403 && e.code == 'ENTITLEMENT_PENDING' && attempt + 1 < @attempts && e.retry_after_seconds <= 30
305
+ @wait.call([3, e.retry_after_seconds].max, cancellation); cancellation&.check!
306
+ end
307
+ end
308
+ end
309
+ def start_runtime(offline, cancellation)
310
+ cancellation&.check!
311
+ path = cache(@record); FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
312
+ runtime = @factory.call(@product.merge('license_key' => @record.dig('credential', 'license_key'), 'cache_path' => path))
313
+ begin
314
+ cancellation&.check!
315
+ if offline
316
+ state = runtime.status
317
+ raise Error.new('OFFLINE_UNAVAILABLE') unless state['allowed'] == true && state['state'] == 'offline_valid'
318
+ end
319
+ runtime.start; cancellation&.check!; @runtime = runtime
320
+ { user: JSON.parse(JSON.generate(@record['user'])), expires_at: @record['expires_at'], offline_fallback: offline }
321
+ rescue StandardError
322
+ runtime.close; raise
323
+ end
324
+ end
325
+ def stop
326
+ runtime, @runtime = @runtime, nil
327
+ runtime&.close
328
+ end
329
+ def clear(record = @record)
330
+ begin
331
+ stop
332
+ ensure
333
+ @record = nil
334
+ begin
335
+ @store.delete(@scope)
336
+ ensure
337
+ FileUtils.rm_rf(File.dirname(cache(record))) if record.is_a?(Hash) && record['user'].is_a?(Hash) && UUID.match?(record.dig('user', 'id').to_s)
338
+ end
339
+ end
340
+ end
341
+ def logout_internal(cancellation)
342
+ record = @record || saved
343
+ clear(record)
344
+ return true unless record
345
+ begin
346
+ call('/auth/logout', method: 'POST', body: {}, cancellation: cancellation, token: record['token']); true
347
+ rescue StandardError
348
+ false
349
+ end
350
+ end
351
+ end
352
+ end
data/vendor/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/vendor/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/vendor/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.11.0
data/vendor/c/app.c ADDED
@@ -0,0 +1,33 @@
1
+ /* A customer application's activation screen and protected report operation. */
2
+ #include "licentivo.h"
3
+ #include <stdio.h>
4
+ #include <stdlib.h>
5
+ #include <string.h>
6
+ #include <time.h>
7
+
8
+ /* BEGIN LICENTIVO PRODUCT */
9
+ /* Copy Product ID and all key ID/public key pairs from Console > Products > Signing public keys. */
10
+ static const char *PRODUCT_ID = "YOUR_PRODUCT_ID";
11
+ static const char *TRUSTED_KEYS_JSON = "{\"YOUR_KEY_ID\":\"YOUR_PUBLIC_KEY\"}";
12
+ /* END LICENTIVO PRODUCT */
13
+
14
+ static void line(char *value,size_t capacity){if(!fgets(value,(int)capacity,stdin))value[0]=0;value[strcspn(value,"\r\n")]=0;}
15
+ static int write_report(void *context){const char *path=(const char*)context;FILE *file=fopen(path,"w");if(!file)return 0;int ok=fputs("Item,Quantity,Price\nNotebook,2,12.50\nPen,5,3.00\n",file)>=0;return fclose(file)==0&&ok;}
16
+ int main(void){
17
+ char key[80],choice[16];printf("License key (empty to restore this device): ");line(key,sizeof(key));
18
+ ln_client *client=ln_init_product(PRODUCT_ID,TRUSTED_KEYS_JSON,key);
19
+ if(!client){fputs("Check product ID, public keys and activation input.\n",stderr);return 2;}
20
+ if(!ln_start(client)){fprintf(stderr,"%s\n",ln_last_error(client));ln_destroy(client);return 2;}
21
+ for(;;){
22
+ puts("1 Status 2 Export CSV 3 Pro report 4 Refresh license 5 Release device 0 Exit");line(choice,sizeof(choice));
23
+ if(!choice[0]||!strcmp(choice,"0"))break;
24
+ int ok=1;
25
+ if(!strcmp(choice,"1")){char *status=ln_status(client);puts(status?status:"unavailable");ln_free_string(status);}
26
+ if(!strcmp(choice,"2"))ok=ln_run_feature(client,"export",write_report,"licensed-report.csv");
27
+ if(!strcmp(choice,"3"))ok=ln_run_feature(client,"pro",write_report,"pro-report.csv");
28
+ if(!strcmp(choice,"4"))ok=ln_refresh_online(client);
29
+ if(!strcmp(choice,"5"))ok=ln_deactivate(client);
30
+ if(!ok)fprintf(stderr,"Operation blocked or failed: %s\n",ln_last_error(client));
31
+ }
32
+ ln_destroy(client);return 0;
33
+ }
data/vendor/c/demo.c ADDED
@@ -0,0 +1,77 @@
1
+ #define _POSIX_C_SOURCE 200809L
2
+ #ifdef _WIN32
3
+ #ifndef _WIN32_WINNT
4
+ #define _WIN32_WINNT 0x0600
5
+ #endif
6
+ #endif
7
+ #include "licentivo.h"
8
+ #include <stdio.h>
9
+ #include <string.h>
10
+ #include <stdlib.h>
11
+ #include <time.h>
12
+ #ifdef _WIN32
13
+ #include <windows.h>
14
+ static double clock_seconds(void){return GetTickCount64()/1000.0;}
15
+ #else
16
+ static double clock_seconds(void){struct timespec t;clock_gettime(CLOCK_MONOTONIC,&t);return t.tv_sec+t.tv_nsec/1e9;}
17
+ #endif
18
+ #include "vendor/cJSON.h"
19
+ static char *read_text(const char *path) { FILE*f=fopen(path,"rb");if(!f)return NULL;char*b=calloc(32769,1);size_t n=fread(b,1,32768,f);int bad=ferror(f)||!feof(f);fclose(f);if(bad||!n){free(b);return NULL;}return b;}
20
+ static int used_is(const char*s,double used,double over){cJSON*j=s?cJSON_Parse(s):NULL;const cJSON*u=cJSON_GetObjectItemCaseSensitive(j,"used"),*o=cJSON_GetObjectItemCaseSensitive(j,"overage");int ok=cJSON_IsNumber(u)&&u->valuedouble==used&&cJSON_IsNumber(o)&&o->valuedouble==over;cJSON_Delete(j);return ok;}
21
+
22
+ static cJSON*receipt(char*value){if(!value)return NULL;cJSON*j=ln_json(value);ln_free_string(value);return j;}
23
+ static int reservation_is(const cJSON*j,const char*status,double used){const cJSON*s=cJSON_GetObjectItemCaseSensitive(j,"status"),*cons=cJSON_GetObjectItemCaseSensitive(j,"consumption"),*u=cJSON_GetObjectItemCaseSensitive(cons,"used");return cJSON_IsString(s)&&!strcmp(s->valuestring,status)&&cJSON_IsNumber(u)&&u->valuedouble==used;}
24
+ static int improvements(ln_client*c,const char*path){int ok=ln_start(c);cJSON*one=ok?receipt(ln_reserve(c,"export",1,"cancel-job")):NULL;ok=ok&&reservation_is(one,"pending",0);const cJSON*id=cJSON_GetObjectItemCaseSensitive(one,"reservation_id");cJSON*cancel=ok?receipt(ln_cancel(c,id->valuestring,"cancel-job")):NULL;ok=ok&&reservation_is(cancel,"canceled",0);cJSON_Delete(cancel);cJSON_Delete(one);cJSON*two=ok?receipt(ln_reserve(c,"export",1,"commit-job")):NULL;id=cJSON_GetObjectItemCaseSensitive(two,"reservation_id");for(int i=0;i<2&&ok;i++){cJSON*done=receipt(ln_commit(c,id->valuestring,"commit-job"));ok=reservation_is(done,"committed",1);cJSON_Delete(done);}cJSON_Delete(two);cJSON*replay=ok?receipt(ln_reserve(c,"export",1,"commit-job")):NULL;ok=ok&&reservation_is(replay,"committed",1);cJSON_Delete(replay);char*over=ok?ln_consume(c,"export",1000,"quota-job"):NULL;ln_error_info e={0};ln_get_error_info(c,&e);ok=ok&&!over&&!strcmp(e.code,"FEATURE_QUOTA_EXCEEDED")&&e.request_id[0]&&!e.retryable&&ln_check_feature(c,"export");ln_free_string(over);ln_client*restored=ok?ln_init_file_with_license(path,""):NULL;ok=ok&&restored&&ln_start(restored)&&ln_validate(restored)&&ln_check_feature(restored,"export")&&ln_deactivate(restored)&&!ln_check_feature(restored,"export");ln_destroy(restored);return ok;}
25
+ int main(int argc, char **argv) {
26
+ const char *path = argc > 2 && !strcmp(argv[1], "--config") ? argv[2] : ".local/protocol-fixture.json";
27
+ int cache_test = 0;
28
+ for (int i = 1; i < argc; i++) if (!strcmp(argv[i], "--cache-test")) cache_test = 1;
29
+ ln_client *client = ln_init_file(path);
30
+ if (!client) { fprintf(stderr, "C: invalid configuration\n"); return 1; }
31
+ for(int i=1;i<argc;i++)if(!strcmp(argv[i],"--improvements-test")){int ok=improvements(client,path);if(!ok)fprintf(stderr,"C: %s\n",ln_last_error(client));ln_destroy(client);if(ok)puts("C: credential / reservation / typed status OK");return ok?0:1;}
32
+ for(int i=1;i<argc;i++)if(!strcmp(argv[i],"--startup-test")){double start=clock_seconds();int ok=ln_start(client)&&clock_seconds()-start<.3;start=clock_seconds();ok=ok&&ln_check_feature(client,"export")&&clock_seconds()-start<.3;ln_destroy(client);if(ok)puts("C: cached startup and local feature checks stay responsive");return ok?0:1;}
33
+ int advanced=0,offline_export=0,offline_import=0,floating=0;
34
+ for(int i=1;i<argc;i++){if(!strcmp(argv[i],"--advanced-test"))advanced=1;if(!strcmp(argv[i],"--offline-export"))offline_export=1;if(!strcmp(argv[i],"--offline-import"))offline_import=1;if(!strcmp(argv[i],"--floating-test"))floating=1;}
35
+ if(offline_export){char*request=ln_offline_request(client,"activate");char file[4096];snprintf(file,sizeof(file),"%s.request.json",path);FILE*f=request?fopen(file,"wb"):NULL;int ok=f&&fwrite(request,1,strlen(request),f)==strlen(request);if(f)fclose(f);ln_free_string(request);ln_destroy(client);if(ok)puts("C: offline request exported");return ok?0:1;}
36
+ for(int i=1;i<argc;i++)if(!strcmp(argv[i],"--transfer-test")||!strcmp(argv[i],"--transfer-import")){
37
+ int ok=1;if(!strcmp(argv[i],"--transfer-import")){char file[4096];snprintf(file,sizeof(file),"%s.request.json",path);char*req=read_text(file);snprintf(file,sizeof(file),"%s.response.json",path);char*res=read_text(file);ok=req&&res&&ln_import_offline(client,req,res);free(req);free(res);}else ok=ln_start(client);
38
+ ok=ok&&ln_validate(client)&&ln_heartbeat(client)&&ln_check_feature(client,"export");if(!ok)fprintf(stderr,"C: %s\n",ln_last_error(client));ln_destroy(client);if(ok)puts("C: transferred device credential OK");return ok?0:1;
39
+ }
40
+ if(floating){int ok=ln_start(client);ln_client*other=ln_init_file(path);ok=ok&&other&&!ln_check_feature(other,"export")&&!ln_start(other);ln_destroy(client);ok=ok&&ln_start(other)&&ln_check_feature(other,"export");ln_destroy(other);if(ok)puts("C: floating seat release OK");return ok?0:1;}
41
+ if(advanced||offline_import){int ok=1;
42
+ if(offline_import){char file[4096];snprintf(file,sizeof(file),"%s.request.json",path);char*req=read_text(file);snprintf(file,sizeof(file),"%s.response.json",path);char*res=read_text(file);ok=req&&res&&ln_import_offline(client,req,res);free(req);free(res);}else ok=ln_start(client);
43
+ ok=ok&&ln_check_feature(client,"export");char*r=ok?ln_consume(client,"export",1,"business-job-1"):NULL;ok=ok&&used_is(r,1,0);ln_free_string(r);
44
+ r=ok?ln_consume(client,"export",1,"business-job-1"):NULL;ok=ok&&used_is(r,1,0);ln_free_string(r);
45
+ r=ok?ln_consume(client,"export",3,"business-job-2"):NULL;ok=ok&&used_is(r,4,1);ln_free_string(r);
46
+ r=ok?ln_consume(client,"export",1,"business-job-3"):NULL;ok=ok&&!r&&strstr(ln_last_error(client),"FEATURE_QUOTA_EXCEEDED")&&ln_check_feature(client,"export");ln_free_string(r);
47
+ ok=ok&&ln_deactivate(client);if(!ok)fprintf(stderr,"C: %s\n",ln_last_error(client));ln_destroy(client);if(ok)puts("C: offline/metered feature checks OK");return ok?0:1;
48
+ }
49
+ for(int i=1;i<argc;i++)if(!strcmp(argv[i],"--refresh-test")){
50
+ int ok=!ln_refresh_online(client)&&ln_check_feature(client,"export");
51
+ ok=ok&&ln_refresh_online(client)&&ln_check_feature(client,"updated")&&!ln_check_feature(client,"export");
52
+ ok=ok&&!ln_refresh_online(client)&&!ln_check_feature(client,"updated");
53
+ ln_destroy(client);if(ok)puts("C: online refresh OK");return ok?0:1;
54
+ }
55
+ int policy_test=0,deny=0;
56
+ for(int i=1;i<argc;i++){if(!strcmp(argv[i],"--policy-test"))policy_test=1;if(!strcmp(argv[i],"--expect-denied"))deny=1;}
57
+ if(policy_test){int ok=ln_check_feature(client,"export")!=deny;if(ok&&!deny)ok=ln_activate(client)&&ln_validate(client)&&ln_heartbeat(client);ln_destroy(client);if(ok)puts("C: policy cache OK");return ok?0:1;}
58
+ int ok = ln_activate(client);
59
+ if (ok && cache_test) {
60
+ ln_destroy(client); client = ln_init_file(path);
61
+ ok = client && ln_validate(client) && ln_check_feature(client, "export");
62
+ if (ok) ok = !ln_validate(client) && !ln_check_feature(client, "export");
63
+ ln_destroy(client); client = ln_init_file(path);
64
+ ok = ok && client && !ln_check_feature(client, "export");
65
+ if (ok) puts("C: cache restored on 503; 403 invalidated access and persisted cache");
66
+ } else if (ok) {
67
+ ok = ln_validate(client) && ln_heartbeat(client);
68
+ if (ok) {
69
+ printf("C: activated, validated, heartbeat OK; export enabled %d\n", ln_check_feature(client, "export"));
70
+ ok = ln_deactivate(client);
71
+ }
72
+ if (ok) puts("C: deactivated");
73
+ }
74
+ if (!ok) fprintf(stderr, "C: %s\n", ln_last_error(client));
75
+ ln_destroy(client);
76
+ return ok ? 0 : 1;
77
+ }
@@ -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
+ }