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.
- checksums.yaml +4 -4
- data/.rspec_status +125 -108
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +3 -0
- data/README.md +151 -4
- data/carddb.gemspec +6 -5
- data/examples/scan_workflow.rb +49 -0
- data/lib/carddb/client.rb +14 -0
- data/lib/carddb/collection.rb +267 -6
- data/lib/carddb/configuration.rb +1 -1
- data/lib/carddb/deck_tokens.rb +197 -0
- data/lib/carddb/filter_builder.rb +4 -4
- data/lib/carddb/query_builder.rb +309 -0
- data/lib/carddb/resources/files.rb +75 -0
- data/lib/carddb/resources/games.rb +19 -0
- data/lib/carddb/resources/records.rb +4 -4
- data/lib/carddb/resources/rules.rb +6 -6
- data/lib/carddb/resources/scan_templates.rb +128 -0
- data/lib/carddb/resources/scans.rb +133 -0
- data/lib/carddb/version.rb +1 -1
- data/lib/carddb.rb +19 -2
- metadata +25 -7
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]
|
data/lib/carddb/collection.rb
CHANGED
|
@@ -30,8 +30,8 @@ module CardDB
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# Iterate over items
|
|
33
|
-
def each(&
|
|
34
|
-
items.each(&
|
|
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(*
|
|
246
|
-
data.to_json(*
|
|
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, &
|
|
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
|
-
&
|
|
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']
|
data/lib/carddb/configuration.rb
CHANGED
|
@@ -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(&
|
|
39
|
+
def self.build(&)
|
|
40
40
|
builder = new
|
|
41
|
-
builder.instance_eval(&
|
|
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(&
|
|
65
|
+
def any(&)
|
|
66
66
|
or_builder = FilterBuilder.new
|
|
67
|
-
or_builder.instance_eval(&
|
|
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
|