carddb 0.3.10 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/carddb/client.rb CHANGED
@@ -126,6 +126,20 @@ module CardDB
126
126
  @files ||= Resources::Files.new(self, connection, config)
127
127
  end
128
128
 
129
+ # Access the Scans resource
130
+ #
131
+ # @return [Resources::Scans]
132
+ def scans
133
+ @scans ||= Resources::Scans.new(self, connection, config)
134
+ end
135
+
136
+ # Access the Scan Templates resource
137
+ #
138
+ # @return [Resources::ScanTemplates]
139
+ def scan_templates
140
+ @scan_templates ||= Resources::ScanTemplates.new(self, connection, config)
141
+ end
142
+
129
143
  # Access the Decks resource
130
144
  #
131
145
  # @return [Resources::Decks]
@@ -30,8 +30,8 @@ module CardDB
30
30
  end
31
31
 
32
32
  # Iterate over items
33
- def each(&block)
34
- items.each(&block)
33
+ def each(&)
34
+ items.each(&)
35
35
  end
36
36
 
37
37
  # Check if there are more pages
@@ -242,8 +242,8 @@ module CardDB
242
242
  data
243
243
  end
244
244
 
245
- def to_json(*args)
246
- data.to_json(*args)
245
+ def to_json(*)
246
+ data.to_json(*)
247
247
  end
248
248
 
249
249
  private
@@ -409,6 +409,16 @@ module CardDB
409
409
  end
410
410
  end
411
411
 
412
+ # Fetch publisher-defined scan regions for this game.
413
+ def scan_regions(include_inactive: nil)
414
+ raise Error, 'No client available to fetch scan regions' unless client
415
+
416
+ publisher_slug = publisher&.[]('slug')
417
+ raise Error, 'Publisher slug not available on this game' unless publisher_slug
418
+
419
+ client.games.get_regions(publisher_slug: publisher_slug, game_key: key, include_inactive: include_inactive)
420
+ end
421
+
412
422
  private
413
423
 
414
424
  def fetch_datasets(purpose: nil, search: nil, first: nil, after: nil)
@@ -581,7 +591,7 @@ module CardDB
581
591
  # @yield [FilterBuilder] Optional block for filter DSL
582
592
  # @return [Collection<Record>] Collection of records
583
593
  # @raise [CardDB::Error] If no client is available
584
- def records(first: nil, filter: nil, include_pricing: false, &block)
594
+ def records(first: nil, filter: nil, include_pricing: false, &)
585
595
  raise Error, 'No client available to fetch records' unless client
586
596
 
587
597
  publisher_slug = publisher&.[]('slug')
@@ -597,7 +607,7 @@ module CardDB
597
607
  first: first,
598
608
  filter: filter,
599
609
  include_pricing: include_pricing,
600
- &block
610
+ &
601
611
  )
602
612
  end
603
613
 
@@ -932,6 +942,257 @@ module CardDB
932
942
  end
933
943
  end
934
944
 
945
+ class ScanJobPayload < Resource
946
+ def created? = !!data['created']
947
+
948
+ def job
949
+ @job ||= data['job'] ? ScanJob.new(data['job'], client: client) : nil
950
+ end
951
+ end
952
+
953
+ class ScanBestMatch < Resource
954
+ def record_id = data['recordId']
955
+ def score = data['score']
956
+ def confidence = data['confidence']
957
+ end
958
+
959
+ class ScanCandidate < Resource
960
+ def record_id = data['recordId']
961
+ def rank = data['rank']
962
+ def score = data['score']
963
+ def confidence = data['confidence']
964
+ def reasons = data['reasons'] || []
965
+ end
966
+
967
+ class ScanJobError < Resource
968
+ def code = data['code']
969
+ def message = data['message']
970
+ end
971
+
972
+ class ScanWebhookState < Resource
973
+ def configured? = !!data['configured']
974
+ def url = data['url']
975
+ def latest_delivery_id = data['latestDeliveryId']
976
+ def status = data['status']
977
+ def attempt_count = data['attemptCount']
978
+ def next_attempt_at = parse_time(data['nextAttemptAt'])
979
+ def last_attempt_at = parse_time(data['lastAttemptAt'])
980
+ def delivered_at = parse_time(data['deliveredAt'])
981
+ def last_status_code = data['lastStatusCode']
982
+ def last_error = data['lastError']
983
+ end
984
+
985
+ class ScanStageMetrics < Resource
986
+ def name = data['name']
987
+ def status = data['status']
988
+ def duration_ms = data['durationMs']
989
+ def completed_at = parse_time(data['completedAt'])
990
+ def details = data['details'] || {}
991
+ end
992
+
993
+ class ScanJobMetrics < Resource
994
+ def feature_version = data['featureVersion']
995
+ def candidate_count = data['candidateCount']
996
+ def confidence_score = data['confidenceScore']
997
+ def confidence_label = data['confidenceLabel']
998
+ def timings = data['timings'] || {}
999
+
1000
+ def stages
1001
+ @stages ||= (data['stages'] || []).map { |stage| ScanStageMetrics.new(stage, client: client) }
1002
+ end
1003
+ end
1004
+
1005
+ class ScanJob < Resource
1006
+ def job_id = data['jobId']
1007
+ def status = data['status']
1008
+ def publisher_slug = data['publisherSlug']
1009
+ def game_key = data['gameKey']
1010
+ def dataset_key = data['datasetKey']
1011
+ def file_id = data['fileId']
1012
+ def client_request_id = data['clientRequestId']
1013
+ def template_id = data['templateId']
1014
+ def template_version_id = data['templateVersionId']
1015
+ def extracted = data['extracted'] || {}
1016
+ def created_at = parse_time(data['createdAt'])
1017
+ def updated_at = parse_time(data['updatedAt'])
1018
+ def started_at = parse_time(data['startedAt'])
1019
+ def completed_at = parse_time(data['completedAt'])
1020
+ def completed? = status.to_s.downcase == 'completed'
1021
+ def failed? = status.to_s.downcase == 'failed'
1022
+ def cancelled? = status.to_s.downcase == 'cancelled'
1023
+
1024
+ def best_match
1025
+ @best_match ||= data['bestMatch'] ? ScanBestMatch.new(data['bestMatch'], client: client) : nil
1026
+ end
1027
+
1028
+ def candidates
1029
+ @candidates ||= (data['candidates'] || []).map { |candidate| ScanCandidate.new(candidate, client: client) }
1030
+ end
1031
+
1032
+ def metrics
1033
+ @metrics ||= data['metrics'] ? ScanJobMetrics.new(data['metrics'], client: client) : nil
1034
+ end
1035
+
1036
+ def webhook
1037
+ @webhook ||= data['webhook'] ? ScanWebhookState.new(data['webhook'], client: client) : nil
1038
+ end
1039
+
1040
+ def error
1041
+ @error ||= data['error'] ? ScanJobError.new(data['error'], client: client) : nil
1042
+ end
1043
+ end
1044
+
1045
+ class ScanFeedbackPayload < Resource
1046
+ def feedback_id = data['feedbackId']
1047
+ def job_id = data['jobId']
1048
+ def status = data['status']
1049
+ def persisted? = !!data['persisted']
1050
+ def correct = data['correct']
1051
+ def predicted_record_id = data['predictedRecordId']
1052
+ def selected_record_id = data['selectedRecordId']
1053
+ def feature_version = data['featureVersion']
1054
+ def message = data['message']
1055
+ end
1056
+
1057
+ class ScanFeedbackMetrics < Resource
1058
+ def total = data['total']
1059
+ def correct = data['correct']
1060
+ def corrected = data['corrected']
1061
+ def unknown = data['unknown']
1062
+ def accuracy = data['accuracy']
1063
+ end
1064
+
1065
+ class ScanFeatureVersionMetrics < Resource
1066
+ def feature_version = data['featureVersion']
1067
+ def job_count = data['jobCount']
1068
+ def completed_jobs = data['completedJobs']
1069
+ def failed_jobs = data['failedJobs']
1070
+ def average_confidence = data['averageConfidence']
1071
+ def average_shortlist_size = data['averageShortlistSize']
1072
+ def feedback_count = data['feedbackCount']
1073
+ def correct_feedback = data['correctFeedback']
1074
+ def corrected_feedback = data['correctedFeedback']
1075
+ def feedback_accuracy = data['feedbackAccuracy']
1076
+ end
1077
+
1078
+ class ScanMetrics < Resource
1079
+ def publisher_slug = data['publisherSlug']
1080
+ def game_key = data['gameKey']
1081
+ def dataset_key = data['datasetKey']
1082
+ def since = parse_time(data['since'])
1083
+ def until = parse_time(data['until'])
1084
+ def generated_at = parse_time(data['generatedAt'])
1085
+ def total_jobs = data['totalJobs']
1086
+ def completed_jobs = data['completedJobs']
1087
+ def failed_jobs = data['failedJobs']
1088
+ def failure_rate = data['failureRate']
1089
+ def average_shortlist_size = data['averageShortlistSize']
1090
+ def confidence_distribution = data['confidenceDistribution'] || {}
1091
+
1092
+ def feedback
1093
+ @feedback ||= data['feedback'] ? ScanFeedbackMetrics.new(data['feedback'], client: client) : nil
1094
+ end
1095
+
1096
+ def feature_versions
1097
+ @feature_versions ||= (data['featureVersions'] || []).map do |version|
1098
+ ScanFeatureVersionMetrics.new(version, client: client)
1099
+ end
1100
+ end
1101
+ end
1102
+
1103
+ class ScanTemplateWarning < Resource
1104
+ def code = data['code']
1105
+ def region = data['region']
1106
+ def message = data['message']
1107
+ end
1108
+
1109
+ class ScanTemplateRegion < Resource
1110
+ def id = data['id']
1111
+ def key = data['key']
1112
+ def label = data['label']
1113
+ def sort_order = data['sortOrder']
1114
+ def shape_type = data['shapeType']
1115
+ def geometry = data['geometry'] || {}
1116
+ def semantic_type = data['semanticType']
1117
+ def extraction_mode = data['extractionMode']
1118
+ def lookup_mode = data['lookupMode']
1119
+ def required? = !!data['isRequired']
1120
+ def weight = data['weight']
1121
+ def config = data['config'] || {}
1122
+ end
1123
+
1124
+ class ScanTemplate < Resource
1125
+ def id = data['id']
1126
+ def key = data['key']
1127
+ def name = data['name']
1128
+ def status = data['status']
1129
+ def version = data['version']
1130
+ def default? = !!data['isDefault']
1131
+ def publisher_slug = data['publisherSlug']
1132
+ def game_key = data['gameKey']
1133
+ def dataset_key = data['datasetKey']
1134
+ def example_file_id = data['exampleFileId']
1135
+ def coordinate_space = data['coordinateSpace']
1136
+ def card_aspect_ratio = data['cardAspectRatio']
1137
+ def notes = data['notes']
1138
+ def config = data['config'] || {}
1139
+ def created_at = parse_time(data['createdAt'])
1140
+ def updated_at = parse_time(data['updatedAt'])
1141
+
1142
+ def regions
1143
+ @regions ||= (data['regions'] || []).map { |region| ScanTemplateRegion.new(region, client: client) }
1144
+ end
1145
+ end
1146
+
1147
+ class ScanTemplateResolution < Resource
1148
+ def template
1149
+ @template ||= data['template'] ? ScanTemplate.new(data['template'], client: client) : nil
1150
+ end
1151
+
1152
+ def warnings
1153
+ @warnings ||= (data['warnings'] || []).map { |warning| ScanTemplateWarning.new(warning, client: client) }
1154
+ end
1155
+ end
1156
+
1157
+ class ScanTemplatePayload < ScanTemplateResolution; end
1158
+
1159
+ class ScanTemplateRegionPreview < Resource
1160
+ def key = data['key']
1161
+ def label = data['label']
1162
+ def semantic_type = data['semanticType']
1163
+ def extraction_mode = data['extractionMode']
1164
+ def lookup_mode = data['lookupMode']
1165
+ def raw_text = data['rawText']
1166
+ def normalized_value = data['normalizedValue']
1167
+ def source = data['source']
1168
+ def status = data['status']
1169
+ def confidence = data['confidence']
1170
+ end
1171
+
1172
+ class ScanTemplatePreview < Resource
1173
+ def message = data['message']
1174
+
1175
+ def warnings
1176
+ @warnings ||= (data['warnings'] || []).map { |warning| ScanTemplateWarning.new(warning, client: client) }
1177
+ end
1178
+
1179
+ def regions
1180
+ @regions ||= (data['regions'] || []).map { |region| ScanTemplateRegionPreview.new(region, client: client) }
1181
+ end
1182
+ end
1183
+
1184
+ class GameScanRegion < Resource
1185
+ def id = data['id']
1186
+ def key = data['key']
1187
+ def name = data['name']
1188
+ def description = data['description']
1189
+ def sort_order = data['sortOrder']
1190
+ def active? = !!data['isActive']
1191
+ def metadata = data['metadata'] || {}
1192
+ def created_at = parse_time(data['createdAt'])
1193
+ def updated_at = parse_time(data['updatedAt'])
1194
+ end
1195
+
935
1196
  class ObjectField < Resource
936
1197
  def id = data['id']
937
1198
  def object_type_id = data['objectTypeId']
@@ -32,7 +32,7 @@ module CardDB
32
32
  # end
33
33
  class Configuration
34
34
  # Default API endpoint
35
- DEFAULT_ENDPOINT = 'https://carddb.xtda.org/query'
35
+ DEFAULT_ENDPOINT = 'https://carddb.dev/query'
36
36
 
37
37
  # Default timeouts in seconds
38
38
  DEFAULT_TIMEOUT = 30
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'ed25519'
5
+ require 'json'
6
+ require 'openssl'
7
+
8
+ module CardDB
9
+ # Server-side helpers for minting direct signed deck access tokens.
10
+ module DeckTokens
11
+ TOKEN_USE = 'carddb.deck_access.v1'
12
+ AUDIENCE = 'carddb.deck_access'
13
+ ED25519_PKCS8_PREFIX = [
14
+ '302e020100300506032b657004220420'
15
+ ].pack('H*').freeze
16
+ READ_MODES = {
17
+ 'PUBLIC' => 'public',
18
+ 'EMBED' => 'embed',
19
+ 'FULL' => 'full'
20
+ }.freeze
21
+ private_constant :READ_MODES
22
+
23
+ class << self
24
+ # Generate an Ed25519 keypair for CardDB direct deck access token signing.
25
+ #
26
+ # @return [Hash{Symbol => String}] Includes a PEM private key and the base64-encoded raw public key
27
+ def generate_direct_access_key_pair
28
+ signing_key = Ed25519::SigningKey.generate
29
+
30
+ {
31
+ private_key_pem: seed_to_pem(signing_key.to_bytes),
32
+ public_key_base64: Base64.strict_encode64(signing_key.verify_key.to_bytes)
33
+ }
34
+ end
35
+
36
+ # Sign a direct deck access token that CardDB can verify with the issuer public key.
37
+ #
38
+ # @param issuer_id [String]
39
+ # @param deck_id [String]
40
+ # @param api_application_id [String]
41
+ # @param key_id [String]
42
+ # @param private_key [OpenSSL::PKey::PKey, String] Ed25519 private key as an OpenSSL key or PEM/DER string
43
+ # @param read_mode [String] PUBLIC, EMBED, or FULL
44
+ # @param subject [String, nil]
45
+ # @param token_id [String, nil]
46
+ # @param issued_at [Time]
47
+ # @param expires_at [Time]
48
+ # @return [String]
49
+ # @raise [ArgumentError]
50
+ def sign_direct_access_token(issuer_id:, deck_id:, api_application_id:, key_id:, private_key:, read_mode:,
51
+ expires_at:, subject: nil, token_id: nil, issued_at: Time.now)
52
+ issued_at, expires_at = validate_signing_input!(
53
+ issuer_id: issuer_id,
54
+ deck_id: deck_id,
55
+ api_application_id: api_application_id,
56
+ key_id: key_id,
57
+ issued_at: issued_at,
58
+ expires_at: expires_at
59
+ )
60
+ normalized_read_mode = normalize_read_mode!(read_mode)
61
+ key = normalize_private_key!(private_key)
62
+ header = { alg: 'EdDSA', kid: key_id.strip }
63
+ payload = build_payload(
64
+ issuer_id: issuer_id,
65
+ deck_id: deck_id,
66
+ api_application_id: api_application_id,
67
+ read_mode: normalized_read_mode,
68
+ subject: subject,
69
+ token_id: token_id,
70
+ issued_at: issued_at,
71
+ expires_at: expires_at
72
+ )
73
+
74
+ encoded_header = base64url_encode(JSON.generate(header))
75
+ encoded_payload = base64url_encode(JSON.generate(payload))
76
+ signing_input = "#{encoded_header}.#{encoded_payload}"
77
+ signature = sign_with_private_key(key, signing_input)
78
+
79
+ [encoded_header, encoded_payload, base64url_encode(signature)].join('.')
80
+ end
81
+
82
+ private
83
+
84
+ def validate_signing_input!(issuer_id:, deck_id:, api_application_id:, key_id:, issued_at:, expires_at:)
85
+ validate_required_string!(issuer_id, 'issuer_id')
86
+ validate_required_string!(deck_id, 'deck_id')
87
+ validate_required_string!(api_application_id, 'api_application_id')
88
+ validate_required_string!(key_id, 'key_id')
89
+
90
+ issued_at = normalize_time!(issued_at, 'issued_at')
91
+ expires_at = normalize_time!(expires_at, 'expires_at')
92
+ raise ArgumentError, 'expires_at must be after issued_at' unless expires_at > issued_at
93
+
94
+ [issued_at, expires_at]
95
+ end
96
+
97
+ def build_payload(issuer_id:, deck_id:, api_application_id:, read_mode:, subject:, token_id:, issued_at:,
98
+ expires_at:)
99
+ payload = {
100
+ token_use: TOKEN_USE,
101
+ deck_id: deck_id.strip,
102
+ app_id: api_application_id.strip,
103
+ read_mode: read_mode,
104
+ iss: issuer_id.strip,
105
+ aud: AUDIENCE,
106
+ iat: issued_at.to_i,
107
+ exp: expires_at.to_i
108
+ }
109
+ add_optional_claim!(payload, :sub, subject)
110
+ add_optional_claim!(payload, :jti, token_id)
111
+ payload
112
+ end
113
+
114
+ def add_optional_claim!(payload, key, value)
115
+ trimmed = value&.strip
116
+ payload[key] = trimmed if trimmed&.length&.positive?
117
+ end
118
+
119
+ def validate_required_string!(value, name)
120
+ raise ArgumentError, "#{name} is required" unless value.is_a?(String) && !value.strip.empty?
121
+ end
122
+
123
+ def normalize_time!(value, name)
124
+ raise ArgumentError, "#{name} must be a Time" unless value.is_a?(Time)
125
+
126
+ value
127
+ end
128
+
129
+ def normalize_read_mode!(read_mode)
130
+ READ_MODES.fetch(read_mode.to_s.upcase) do
131
+ raise ArgumentError, 'read_mode must be PUBLIC, EMBED, or FULL'
132
+ end
133
+ end
134
+
135
+ def normalize_private_key!(private_key)
136
+ return private_key if private_key.is_a?(OpenSSL::PKey::PKey)
137
+
138
+ if private_key.is_a?(String)
139
+ key = try_read_openssl_private_key(private_key)
140
+ return key if key
141
+
142
+ seed = try_extract_seed(private_key)
143
+ return Ed25519::SigningKey.new(seed) if seed
144
+ end
145
+
146
+ raise ArgumentError, 'private_key must be an Ed25519 private key'
147
+ end
148
+
149
+ def try_read_openssl_private_key(value)
150
+ key = OpenSSL::PKey.read(value)
151
+ key.sign(nil, 'carddb-test')
152
+ key
153
+ rescue OpenSSL::OpenSSLError, ArgumentError
154
+ nil
155
+ end
156
+
157
+ def try_extract_seed(value)
158
+ der = decode_private_key_der(value)
159
+ return nil unless der.bytesize == ED25519_PKCS8_PREFIX.bytesize + Ed25519::KEY_SIZE
160
+ return nil unless der.start_with?(ED25519_PKCS8_PREFIX)
161
+
162
+ der.byteslice(ED25519_PKCS8_PREFIX.bytesize, Ed25519::KEY_SIZE)
163
+ end
164
+
165
+ def decode_private_key_der(value)
166
+ if value.include?('BEGIN PRIVATE KEY')
167
+ Base64.decode64(value.gsub(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----|\s+/, ''))
168
+ else
169
+ value
170
+ end
171
+ end
172
+
173
+ def sign_with_private_key(private_key, message)
174
+ case private_key
175
+ when OpenSSL::PKey::PKey
176
+ private_key.sign(nil, message)
177
+ when Ed25519::SigningKey
178
+ private_key.sign(message)
179
+ else
180
+ raise ArgumentError, 'private_key must be an Ed25519 private key'
181
+ end
182
+ rescue OpenSSL::OpenSSLError, Ed25519::Error => e
183
+ raise ArgumentError, "private_key must be an Ed25519 private key: #{e.message}"
184
+ end
185
+
186
+ def seed_to_pem(seed)
187
+ der = ED25519_PKCS8_PREFIX + seed
188
+ encoded = Base64.strict_encode64(der).scan(/.{1,64}/).join("\n")
189
+ "-----BEGIN PRIVATE KEY-----\n#{encoded}\n-----END PRIVATE KEY-----\n"
190
+ end
191
+
192
+ def base64url_encode(value)
193
+ Base64.urlsafe_encode64(value, padding: false)
194
+ end
195
+ end
196
+ end
197
+ end
@@ -36,9 +36,9 @@ module CardDB
36
36
  #
37
37
  # @yield Block to define filters
38
38
  # @return [Hash] The filter hash
39
- def self.build(&block)
39
+ def self.build(&)
40
40
  builder = new
41
- builder.instance_eval(&block)
41
+ builder.instance_eval(&)
42
42
  builder.to_filter
43
43
  end
44
44
 
@@ -62,9 +62,9 @@ module CardDB
62
62
  #
63
63
  # @yield Block to define OR conditions
64
64
  # @return [self]
65
- def any(&block)
65
+ def any(&)
66
66
  or_builder = FilterBuilder.new
67
- or_builder.instance_eval(&block)
67
+ or_builder.instance_eval(&)
68
68
  or_conditions = or_builder.conditions_array
69
69
  @or_groups << { '$or' => or_conditions } if or_conditions.any?
70
70
  self