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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +35 -0
- data/ext/launch_native/extconf.rb +28 -0
- data/lib/licentivo_launch/vendor/licentivo.rb +262 -0
- data/lib/licentivo_launch.rb +352 -0
- data/vendor/LICENSE +21 -0
- data/vendor/NOTICE +17 -0
- data/vendor/VERSION +1 -0
- data/vendor/c/app.c +33 -0
- data/vendor/c/demo.c +77 -0
- data/vendor/c/feature_tasks.h +72 -0
- data/vendor/c/licentivo.c +382 -0
- data/vendor/c/licentivo.h +67 -0
- data/vendor/c/quickstart.c +51 -0
- data/vendor/c/test_vectors.c +4 -0
- data/vendor/c/vendor/Monocypher-LICENCE.md +173 -0
- data/vendor/c/vendor/cJSON-LICENSE +20 -0
- data/vendor/c/vendor/cJSON.c +3191 -0
- data/vendor/c/vendor/cJSON.h +306 -0
- data/vendor/c/vendor/monocypher-ed25519.c +500 -0
- data/vendor/c/vendor/monocypher-ed25519.h +140 -0
- data/vendor/c/vendor/monocypher.c +2956 -0
- data/vendor/c/vendor/monocypher.h +321 -0
- data/vendor/curl/COPYING.txt +22 -0
- data/vendor/curl/bin/libcurl-x64.dll +0 -0
- data/vendor/curl/include/brotli/decode.h +409 -0
- data/vendor/curl/include/brotli/encode.h +505 -0
- data/vendor/curl/include/brotli/port.h +302 -0
- data/vendor/curl/include/brotli/shared_dictionary.h +100 -0
- data/vendor/curl/include/brotli/types.h +83 -0
- data/vendor/curl/include/curl/curl.h +3374 -0
- data/vendor/curl/include/curl/curlver.h +77 -0
- data/vendor/curl/include/curl/easy.h +123 -0
- data/vendor/curl/include/curl/header.h +74 -0
- data/vendor/curl/include/curl/mprintf.h +85 -0
- data/vendor/curl/include/curl/multi.h +561 -0
- data/vendor/curl/include/curl/options.h +70 -0
- data/vendor/curl/include/curl/stdcheaders.h +35 -0
- data/vendor/curl/include/curl/system.h +399 -0
- data/vendor/curl/include/curl/typecheck-gcc.h +964 -0
- data/vendor/curl/include/curl/urlapi.h +156 -0
- data/vendor/curl/include/curl/websockets.h +98 -0
- data/vendor/curl/include/libpsl.h +207 -0
- data/vendor/curl/include/libssh2.h +1516 -0
- data/vendor/curl/include/libssh2_publickey.h +128 -0
- data/vendor/curl/include/libssh2_sftp.h +382 -0
- data/vendor/curl/include/nghttp2/nghttp2.h +6878 -0
- data/vendor/curl/include/nghttp2/nghttp2ver.h +42 -0
- data/vendor/curl/include/nghttp3/nghttp3.h +3704 -0
- data/vendor/curl/include/nghttp3/version.h +46 -0
- data/vendor/curl/include/ngtcp2/ngtcp2.h +7485 -0
- data/vendor/curl/include/ngtcp2/ngtcp2_crypto.h +1071 -0
- data/vendor/curl/include/ngtcp2/ngtcp2_crypto_quictls.h +147 -0
- data/vendor/curl/include/ngtcp2/version.h +51 -0
- data/vendor/curl/include/openssl/aes.h +120 -0
- data/vendor/curl/include/openssl/asn1.h +1121 -0
- data/vendor/curl/include/openssl/asn1t.h +996 -0
- data/vendor/curl/include/openssl/bio.h +716 -0
- data/vendor/curl/include/openssl/blowfish.h +106 -0
- data/vendor/curl/include/openssl/bn.h +480 -0
- data/vendor/curl/include/openssl/buffer.h +102 -0
- data/vendor/curl/include/openssl/camellia.h +121 -0
- data/vendor/curl/include/openssl/cast.h +99 -0
- data/vendor/curl/include/openssl/chacha.h +54 -0
- data/vendor/curl/include/openssl/cmac.h +81 -0
- data/vendor/curl/include/openssl/cms.h +541 -0
- data/vendor/curl/include/openssl/comp.h +7 -0
- data/vendor/curl/include/openssl/conf.h +189 -0
- data/vendor/curl/include/openssl/crypto.h +458 -0
- data/vendor/curl/include/openssl/ct.h +567 -0
- data/vendor/curl/include/openssl/curve25519.h +104 -0
- data/vendor/curl/include/openssl/des.h +215 -0
- data/vendor/curl/include/openssl/dh.h +245 -0
- data/vendor/curl/include/openssl/dsa.h +263 -0
- data/vendor/curl/include/openssl/dtls1.h +107 -0
- data/vendor/curl/include/openssl/ec.h +675 -0
- data/vendor/curl/include/openssl/ecdh.h +6 -0
- data/vendor/curl/include/openssl/ecdsa.h +6 -0
- data/vendor/curl/include/openssl/engine.h +215 -0
- data/vendor/curl/include/openssl/err.h +325 -0
- data/vendor/curl/include/openssl/evp.h +1288 -0
- data/vendor/curl/include/openssl/hkdf.h +65 -0
- data/vendor/curl/include/openssl/hmac.h +101 -0
- data/vendor/curl/include/openssl/idea.h +99 -0
- data/vendor/curl/include/openssl/kdf.h +137 -0
- data/vendor/curl/include/openssl/lhash.h +179 -0
- data/vendor/curl/include/openssl/md4.h +105 -0
- data/vendor/curl/include/openssl/md5.h +99 -0
- data/vendor/curl/include/openssl/mlkem.h +236 -0
- data/vendor/curl/include/openssl/modes.h +118 -0
- data/vendor/curl/include/openssl/obj_mac.h +4646 -0
- data/vendor/curl/include/openssl/objects.h +137 -0
- data/vendor/curl/include/openssl/ocsp.h +484 -0
- data/vendor/curl/include/openssl/opensslconf.h +16 -0
- data/vendor/curl/include/openssl/opensslfeatures.h +153 -0
- data/vendor/curl/include/openssl/opensslv.h +18 -0
- data/vendor/curl/include/openssl/ossl_typ.h +196 -0
- data/vendor/curl/include/openssl/pem.h +542 -0
- data/vendor/curl/include/openssl/pkcs12.h +284 -0
- data/vendor/curl/include/openssl/pkcs7.h +523 -0
- data/vendor/curl/include/openssl/poly1305.h +45 -0
- data/vendor/curl/include/openssl/posix_time.h +54 -0
- data/vendor/curl/include/openssl/rand.h +118 -0
- data/vendor/curl/include/openssl/rc2.h +101 -0
- data/vendor/curl/include/openssl/rc4.h +88 -0
- data/vendor/curl/include/openssl/ripemd.h +107 -0
- data/vendor/curl/include/openssl/rsa.h +603 -0
- data/vendor/curl/include/openssl/safestack.h +1739 -0
- data/vendor/curl/include/openssl/sha.h +190 -0
- data/vendor/curl/include/openssl/sm3.h +49 -0
- data/vendor/curl/include/openssl/sm4.h +47 -0
- data/vendor/curl/include/openssl/srtp.h +148 -0
- data/vendor/curl/include/openssl/ssl.h +2336 -0
- data/vendor/curl/include/openssl/ssl3.h +441 -0
- data/vendor/curl/include/openssl/stack.h +99 -0
- data/vendor/curl/include/openssl/tls1.h +764 -0
- data/vendor/curl/include/openssl/ts.h +660 -0
- data/vendor/curl/include/openssl/txt_db.h +112 -0
- data/vendor/curl/include/openssl/ui.h +397 -0
- data/vendor/curl/include/openssl/x509.h +1037 -0
- data/vendor/curl/include/openssl/x509_vfy.h +464 -0
- data/vendor/curl/include/openssl/x509v3.h +1039 -0
- data/vendor/curl/include/zconf.h +206 -0
- data/vendor/curl/include/zdict.h +481 -0
- data/vendor/curl/include/zlib.h +1859 -0
- data/vendor/curl/include/zlib_name_mangling.h +8 -0
- data/vendor/curl/include/zstd.h +3198 -0
- data/vendor/curl/include/zstd_errors.h +107 -0
- data/vendor/curl/lib/libcurl.dll.a +0 -0
- data/vendor/curl/licenses/brotli/LICENSE.txt +19 -0
- data/vendor/curl/licenses/certdata/LICENSE.url +2 -0
- data/vendor/curl/licenses/libpsl/COPYING.txt +20 -0
- data/vendor/curl/licenses/libressl/COPYING.txt +133 -0
- data/vendor/curl/licenses/libssh2/COPYING.txt +43 -0
- data/vendor/curl/licenses/nghttp2/COPYING.txt +23 -0
- data/vendor/curl/licenses/nghttp3/COPYING.txt +22 -0
- data/vendor/curl/licenses/ngtcp2/COPYING.txt +22 -0
- data/vendor/curl/licenses/psl/LICENSE.txt +373 -0
- data/vendor/curl/licenses/trurl/COPYING.txt +22 -0
- data/vendor/curl/licenses/zlibng/LICENSE.md +19 -0
- data/vendor/curl/licenses/zstd/LICENSE.txt +30 -0
- 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
|
+
}
|