keeper_secrets_manager 17.0.4 → 17.2.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -16
  3. data/Gemfile +6 -3
  4. data/README.md +75 -270
  5. data/Rakefile +1 -1
  6. data/bin/console +47 -0
  7. data/keeper_secrets_manager.gemspec +36 -0
  8. data/lib/keeper_secrets_manager/cache.rb +139 -0
  9. data/lib/keeper_secrets_manager/config_keys.rb +4 -2
  10. data/lib/keeper_secrets_manager/core.rb +993 -452
  11. data/lib/keeper_secrets_manager/crypto.rb +101 -116
  12. data/lib/keeper_secrets_manager/dto/payload.rb +7 -6
  13. data/lib/keeper_secrets_manager/dto.rb +374 -38
  14. data/lib/keeper_secrets_manager/errors.rb +13 -2
  15. data/lib/keeper_secrets_manager/field_types.rb +3 -3
  16. data/lib/keeper_secrets_manager/folder_manager.rb +25 -29
  17. data/lib/keeper_secrets_manager/keeper_globals.rb +11 -17
  18. data/lib/keeper_secrets_manager/notation.rb +202 -93
  19. data/lib/keeper_secrets_manager/notation_enhancements.rb +24 -24
  20. data/lib/keeper_secrets_manager/storage.rb +38 -38
  21. data/lib/keeper_secrets_manager/totp.rb +27 -27
  22. data/lib/keeper_secrets_manager/utils.rb +85 -18
  23. data/lib/keeper_secrets_manager/version.rb +2 -2
  24. data/lib/keeper_secrets_manager.rb +11 -3
  25. metadata +39 -22
  26. data/DEVELOPER_SETUP.md +0 -0
  27. data/MANUAL_TESTING_GUIDE.md +0 -332
  28. data/RUBY_SDK_COMPLETE_DOCUMENTATION.md +0 -354
  29. data/RUBY_SDK_COMPREHENSIVE_SUMMARY.md +0 -192
  30. data/examples/01_quick_start.rb +0 -45
  31. data/examples/02_authentication.rb +0 -82
  32. data/examples/03_retrieve_secrets.rb +0 -81
  33. data/examples/04_create_update_delete.rb +0 -104
  34. data/examples/05_field_types.rb +0 -135
  35. data/examples/06_files.rb +0 -137
  36. data/examples/07_folders.rb +0 -145
  37. data/examples/08_notation.rb +0 -103
  38. data/examples/09_totp.rb +0 -100
  39. data/examples/README.md +0 -89
@@ -1,20 +1,23 @@
1
1
  require 'json'
2
- require 'ostruct'
3
2
  require_relative 'dto/payload'
3
+ require_relative 'utils'
4
+ require_relative 'crypto'
4
5
 
5
6
  module KeeperSecretsManager
6
7
  module Dto
7
8
  # Base class for dynamic record handling
8
9
  class KeeperRecord
9
- attr_accessor :uid, :title, :type, :fields, :custom, :notes, :folder_uid, :data, :revision, :files
10
+ attr_accessor :uid, :title, :type, :fields, :custom, :notes, :folder_uid, :inner_folder_uid, :data, :revision, :files, :links, :is_editable
11
+ attr_reader :record_key # Internal - stores decrypted record key (bytes) for file upload operations
10
12
 
11
13
  def initialize(attrs = {})
12
14
  if attrs.is_a?(Hash)
13
15
  # Support both raw API response and user-friendly creation
14
16
  @uid = attrs['recordUid'] || attrs['uid'] || attrs[:uid]
15
17
  @folder_uid = attrs['folderUid'] || attrs['folder_uid'] || attrs[:folder_uid]
18
+ @inner_folder_uid = attrs['innerFolderUid'] || attrs['inner_folder_uid'] || attrs[:inner_folder_uid]
16
19
  @revision = attrs['revision'] || attrs[:revision] || 0
17
-
20
+
18
21
  # Handle encrypted data or direct attributes
19
22
  if attrs['data']
20
23
  data = attrs['data'].is_a?(String) ? JSON.parse(attrs['data']) : attrs['data']
@@ -30,70 +33,115 @@ module KeeperSecretsManager
30
33
  @custom = attrs['custom'] || attrs[:custom] || []
31
34
  @notes = attrs['notes'] || attrs[:notes] || ''
32
35
  end
33
-
36
+
34
37
  @files = attrs['files'] || attrs[:files] || []
38
+ @links = attrs['links'] || attrs[:links] || []
39
+
40
+ # Handle is_editable (can be false, so use has_key? check)
41
+ if attrs.key?('isEditable')
42
+ @is_editable = attrs['isEditable']
43
+ elsif attrs.key?('is_editable')
44
+ @is_editable = attrs['is_editable']
45
+ elsif attrs.key?(:is_editable)
46
+ @is_editable = attrs[:is_editable]
47
+ else
48
+ @is_editable = true # Default to true if not specified
49
+ end
50
+
35
51
  @data = attrs
36
52
  end
37
-
53
+
38
54
  # Ensure fields are always arrays of hashes
39
55
  normalize_fields!
40
56
  end
41
57
 
42
58
  # Convert to hash for API submission
59
+ # This should match the structure of the decrypted 'data' field from server
60
+ # (does NOT include uid, revision, folder_uid - those are in the outer payload)
43
61
  def to_h
44
- {
45
- 'uid' => uid,
62
+ result = {
46
63
  'title' => title,
47
64
  'type' => type,
48
- 'fields' => fields,
49
- 'custom' => custom,
50
- 'notes' => notes,
51
- 'folder_uid' => folder_uid
52
- }.compact
65
+ 'fields' => fields
66
+ }
67
+
68
+ result['custom'] = custom unless custom.nil?
69
+
70
+ # Only include notes if present
71
+ result['notes'] = notes if notes && !notes.empty?
72
+
73
+ result
53
74
  end
54
75
 
55
- # Find field by type or label
56
- def get_field(type_or_label, custom_field = false)
57
- field_array = custom_field ? custom : fields
58
- field_array.find { |f| f['type'] == type_or_label || f['label'] == type_or_label }
76
+ # Find field by type or label (searches both fields and custom arrays)
77
+ def get_field(type_or_label)
78
+ # Search in fields first
79
+ field = fields.find { |f| f['type'] == type_or_label || f['label'] == type_or_label }
80
+ return field if field
81
+
82
+ # Search in custom fields
83
+ custom.find { |f| f['type'] == type_or_label || f['label'] == type_or_label }
59
84
  end
60
85
 
61
86
  # Get field value (always returns array)
62
- def get_field_value(type_or_label, custom_field = false)
63
- field = get_field(type_or_label, custom_field)
87
+ def get_field_value(type_or_label)
88
+ field = get_field(type_or_label)
64
89
  field ? field['value'] || [] : []
65
90
  end
66
91
 
67
92
  # Get single field value (first element)
68
- def get_field_value_single(type_or_label, custom_field = false)
69
- values = get_field_value(type_or_label, custom_field)
93
+ def get_field_value_single(type_or_label)
94
+ values = get_field_value(type_or_label)
70
95
  values.first
71
96
  end
72
97
 
73
98
  # Add or update field
74
- def set_field(type, value, label = nil, custom_field = false)
75
- field_array = custom_field ? @custom : @fields
76
-
99
+ def set_field(type, value, label = nil)
77
100
  # Ensure value is an array
78
101
  value = [value] unless value.is_a?(Array)
79
-
80
- # Find existing field
81
- existing = field_array.find { |f| f['type'] == type || (label && f['label'] == label) }
82
-
102
+
103
+ # Find existing field in both arrays
104
+ existing = @fields.find { |f| f['type'] == type || (label && f['label'] == label) }
105
+ existing ||= @custom.find { |f| f['type'] == type || (label && f['label'] == label) }
106
+
83
107
  if existing
84
108
  existing['value'] = value
85
109
  existing['label'] = label if label
86
110
  else
87
111
  new_field = { 'type' => type, 'value' => value }
88
112
  new_field['label'] = label if label
89
- field_array << new_field
113
+
114
+ # Decide which array to add to:
115
+ # - If it has a label, it's a custom field
116
+ # - If it's not a common field type, it's likely custom
117
+ if label || !common_field_types.include?(type)
118
+ @custom << new_field
119
+ else
120
+ @fields << new_field
121
+ end
122
+ end
123
+ end
124
+
125
+ # Return this record's linked-credential entries as typed KeeperRecordLink objects.
126
+ #
127
+ # Typed view over the raw `links` list (populated when secrets are fetched with
128
+ # QueryOptions(..., request_links: true)). The raw `links` list is left unchanged
129
+ # for backward compatibility; entries without a String recordUid are skipped.
130
+ def get_links
131
+ (links || []).each_with_object([]) do |link_dict, result|
132
+ next unless link_dict.is_a?(Hash)
133
+
134
+ record_uid = link_dict['recordUid']
135
+ next unless record_uid.is_a?(String) && !record_uid.empty?
136
+
137
+ result << KeeperRecordLink.new(link_dict)
90
138
  end
91
139
  end
92
140
 
93
141
  # Dynamic field access methods
94
142
  def method_missing(method, *args, &block)
95
143
  method_name = method.to_s
96
-
144
+
97
145
  # Handle setters
98
146
  if method_name.end_with?('=')
99
147
  field_name = method_name.chomp('=')
@@ -120,22 +168,295 @@ module KeeperSecretsManager
120
168
 
121
169
  def normalize_field_array(fields)
122
170
  return [] unless fields.is_a?(Array)
123
-
171
+
124
172
  fields.map do |field|
125
173
  next field if field.is_a?(Hash)
126
-
174
+
127
175
  # Convert to hash if needed
128
176
  field.to_h
129
177
  end
130
178
  end
131
179
 
132
180
  def common_field_types
133
- %w[login password url fileRef oneTimeCode name phone email address
134
- paymentCard bankAccount birthDate secureNote sshKey host
181
+ %w[login password url fileRef oneTimeCode name phone email address
182
+ paymentCard bankAccount birthDate secureNote sshKey host
135
183
  databaseType script passkey]
136
184
  end
137
185
  end
138
186
 
187
+ # Typed view over a single linked-credential entry of a record (`record.links`).
188
+ #
189
+ # A link entry carries `recordUid`, optional base64 `data`, and an optional `path`
190
+ # discriminator. Observed payload shapes (verified against the live backend):
191
+ #
192
+ # - path "meta" (self-link, recordUid == owning record): plain base64 JSON with
193
+ # `allowedSettings` (rotation, connections, portForwards, sessionRecording,
194
+ # typescriptRecording, aiEnabled, aiSessionTerminate, remoteBrowserIsolation),
195
+ # plus `rotateOnTermination`, `version` and `no_update_services`.
196
+ # - path nil (credential link to another record): plain base64 JSON with
197
+ # `is_admin`, `is_launch_credential`, `is_iam_user`, `belongs_to` and
198
+ # `rotation_settings`; or no data at all (pure record reference).
199
+ # - path "ai_settings" / "jit_settings" (self-links): data is AES-256-GCM
200
+ # encrypted under the owning record's key - see #get_decrypted_data.
201
+ #
202
+ # Accessors never raise: parse, decode or decryption failures yield nil/false.
203
+ # The original link hash is kept untouched in `raw`, and #get_link_data returns the
204
+ # complete parsed payload, so fields unknown to this SDK version are preserved.
205
+ #
206
+ # Naming: Ruby predicates take a trailing `?` and drop the redundant `is_` prefix
207
+ # (house style), so the Python reference's is_admin_user/is_launch_credential/
208
+ # is_iam_user map to admin_user?/launch_credential?/iam_user? here.
209
+ class KeeperRecordLink
210
+ attr_reader :raw, :record_uid, :data, :path
211
+
212
+ def initialize(link_dict = {})
213
+ link_dict = {} unless link_dict.is_a?(Hash)
214
+ @raw = link_dict.dup
215
+ @record_uid = link_dict['recordUid']
216
+ @data = link_dict['data']
217
+ @path = link_dict['path']
218
+ end
219
+
220
+ def to_s
221
+ "[KeeperRecordLink: record_uid=#{@record_uid}, path=#{@path}]"
222
+ end
223
+
224
+ def admin_user?
225
+ boolean_value('is_admin')
226
+ end
227
+
228
+ def launch_credential?
229
+ boolean_value('is_launch_credential')
230
+ end
231
+
232
+ def iam_user?
233
+ boolean_value('is_iam_user')
234
+ end
235
+
236
+ def belongs_to?
237
+ boolean_value('belongs_to')
238
+ end
239
+
240
+ def no_update_services?
241
+ boolean_value('no_update_services')
242
+ end
243
+
244
+ def allows_rotation?
245
+ boolean_value('rotation', true)
246
+ end
247
+
248
+ def allows_connections?
249
+ boolean_value('connections', true)
250
+ end
251
+
252
+ def allows_port_forwards?
253
+ boolean_value('portForwards', true)
254
+ end
255
+
256
+ def allows_session_recording?
257
+ boolean_value('sessionRecording', true)
258
+ end
259
+
260
+ def allows_typescript_recording?
261
+ boolean_value('typescriptRecording', true)
262
+ end
263
+
264
+ def allows_remote_browser_isolation?
265
+ boolean_value('remoteBrowserIsolation', true)
266
+ end
267
+
268
+ def ai_enabled?
269
+ boolean_value('aiEnabled', true)
270
+ end
271
+
272
+ def ai_session_terminate?
273
+ boolean_value('aiSessionTerminate', true)
274
+ end
275
+
276
+ def rotates_on_termination?
277
+ boolean_value('rotateOnTermination')
278
+ end
279
+
280
+ # The link data schema version (`version`) when it is an integer, else nil.
281
+ def get_link_data_version
282
+ int_value('version')
283
+ end
284
+
285
+ # The `allowedSettings` object from the link data (empty hash when absent).
286
+ def get_allowed_settings
287
+ parsed = parse_json_data
288
+ allowed = parsed ? parsed['allowedSettings'] : nil
289
+ allowed.is_a?(Hash) ? allowed : {}
290
+ end
291
+
292
+ # The `rotation_settings` object from the link data, or nil when absent.
293
+ def get_rotation_settings
294
+ parsed = parse_json_data
295
+ rotation = parsed ? parsed['rotation_settings'] : nil
296
+ rotation.is_a?(Hash) ? rotation : nil
297
+ end
298
+
299
+ # Base64-decode `data` to a string (for debugging/advanced use), or nil.
300
+ def get_decoded_data
301
+ return nil if @data.nil?
302
+
303
+ Utils.base64_to_bytes(@data).force_encoding('UTF-8').scrub
304
+ rescue StandardError
305
+ nil
306
+ end
307
+
308
+ # Whether the link has readable JSON data (vs. encrypted/binary data).
309
+ def has_readable_data?
310
+ decoded = get_decoded_data
311
+ !decoded.nil? && (decoded.start_with?('{') || decoded.start_with?('['))
312
+ end
313
+
314
+ # Whether this link's path indicates potentially encrypted data (currently
315
+ # ai_settings / jit_settings; other paths carry plain base64 JSON).
316
+ def might_be_encrypted?
317
+ %w[ai_settings jit_settings].include?(@path)
318
+ end
319
+
320
+ # Whether the data appears encrypted, by inspecting the actual content (non-JSON
321
+ # and mostly non-printable) rather than path naming conventions.
322
+ def has_encrypted_data?
323
+ decoded = get_decoded_data
324
+ return false if decoded.nil?
325
+ return false if decoded.start_with?('{') || decoded.start_with?('[')
326
+
327
+ !printable_text?(decoded)
328
+ end
329
+
330
+ # Decrypt the link data with the owning record's key (AES-256-GCM). record_key is
331
+ # the record's decrypted key bytes (record.record_key). Returns the decrypted
332
+ # string, or nil if data/key is missing or decryption fails.
333
+ def get_decrypted_data(record_key = nil)
334
+ return nil if @data.nil? || record_key.nil?
335
+
336
+ encrypted = Utils.base64_to_bytes(@data)
337
+ Crypto.decrypt_aes_gcm(encrypted, record_key).force_encoding('UTF-8').scrub
338
+ rescue StandardError
339
+ nil
340
+ end
341
+
342
+ # The complete link data payload, handling both plain and encrypted JSON. Plain
343
+ # base64 JSON parses without a key; encrypted data requires the owning record's
344
+ # key. Ciphertext can coincidentally start with "{" or "[", so a failed
345
+ # plain-JSON parse falls through to decryption rather than giving up. The returned
346
+ # hash preserves all fields sent by the server, including ones this SDK version
347
+ # doesn't know about yet.
348
+ def get_link_data(record_key = nil)
349
+ decoded = get_decoded_data
350
+ return nil if decoded.nil?
351
+
352
+ if decoded.start_with?('{') || decoded.start_with?('[')
353
+ parsed = parse_json_to_dict(decoded)
354
+ return parsed unless parsed.nil?
355
+ # Leading {/[ was coincidental ciphertext - fall through to decryption.
356
+ end
357
+
358
+ decrypted = get_decrypted_data(record_key)
359
+ return nil if decrypted.nil?
360
+
361
+ parse_json_to_dict(decrypted)
362
+ end
363
+
364
+ # PAM settings data from this link - only when path == "meta" (plain JSON today;
365
+ # the key is accepted for forward compatibility).
366
+ def get_meta_data(record_key = nil)
367
+ get_settings_for_path('meta', record_key)
368
+ end
369
+
370
+ # AI settings data from this link - only when path == "ai_settings" (encrypted
371
+ # under the owning record's key). Returns nil for any other path.
372
+ def get_ai_settings_data(record_key = nil)
373
+ return nil unless @path == 'ai_settings'
374
+
375
+ get_link_data(record_key)
376
+ end
377
+
378
+ # JIT settings data from this link - only when path == "jit_settings" (encrypted
379
+ # under the owning record's key). Returns nil for any other path.
380
+ def get_jit_settings_data(record_key = nil)
381
+ return nil unless @path == 'jit_settings'
382
+
383
+ get_link_data(record_key)
384
+ end
385
+
386
+ # Settings data for any path, current or future. Automatically detects whether the
387
+ # data is plain or encrypted and handles it appropriately. Returns nil when the
388
+ # path doesn't match or parsing fails.
389
+ def get_settings_for_path(settings_path, record_key = nil)
390
+ return nil unless @path == settings_path
391
+
392
+ get_link_data(record_key)
393
+ end
394
+
395
+ private
396
+
397
+ # Decode `data` and parse it as a JSON object, handling errors gracefully.
398
+ def parse_json_data
399
+ decoded = get_decoded_data
400
+ return nil if decoded.nil? || !(decoded.start_with?('{') || decoded.start_with?('['))
401
+
402
+ parsed = JSON.parse(decoded)
403
+ parsed.is_a?(Hash) ? parsed : nil
404
+ rescue JSON::ParserError
405
+ nil
406
+ end
407
+
408
+ # Read a strict boolean from the link data; missing or non-bool values are false.
409
+ # With check_allowed_settings the nested `allowedSettings` object is consulted when
410
+ # the key is absent at the top level (a top-level boolean wins).
411
+ def boolean_value(key, check_allowed_settings = false)
412
+ parsed = parse_json_data
413
+ return false if parsed.nil?
414
+
415
+ value = parsed[key]
416
+ return value if strict_boolean?(value)
417
+
418
+ if check_allowed_settings
419
+ allowed = parsed['allowedSettings']
420
+ if allowed.is_a?(Hash)
421
+ nested = allowed[key]
422
+ return nested if strict_boolean?(nested)
423
+ end
424
+ end
425
+ false
426
+ end
427
+
428
+ # Read a strict integer from the link data; strings and booleans yield nil.
429
+ def int_value(key)
430
+ parsed = parse_json_data
431
+ value = parsed ? parsed[key] : nil
432
+ return value if value.is_a?(Integer) && !strict_boolean?(value)
433
+
434
+ nil
435
+ end
436
+
437
+ def strict_boolean?(value)
438
+ value == true || value == false
439
+ end
440
+
441
+ # Parse a JSON string, returning a hash only for JSON objects.
442
+ def parse_json_to_dict(json_str)
443
+ parsed = JSON.parse(json_str)
444
+ parsed.is_a?(Hash) ? parsed : nil
445
+ rescue JSON::ParserError
446
+ nil
447
+ end
448
+
449
+ # Whether a string is mostly printable text (>90% of the first 100 chars), used to
450
+ # distinguish encrypted bytes from plain text.
451
+ def printable_text?(text)
452
+ return false if text.nil? || text.empty?
453
+
454
+ sample = text[0, 100]
455
+ printable = sample.each_char.count { |c| (c >= ' ' && c <= '~') || ["\n", "\r", "\t"].include?(c) }
456
+ (printable.to_f / sample.length) > 0.9
457
+ end
458
+ end
459
+
139
460
  # Folder representation
140
461
  class KeeperFolder
141
462
  attr_accessor :uid, :name, :parent_uid, :folder_type, :folder_key, :records
@@ -161,7 +482,7 @@ module KeeperSecretsManager
161
482
 
162
483
  # File attachment representation
163
484
  class KeeperFile
164
- attr_accessor :uid, :name, :title, :mime_type, :size, :data, :url
485
+ attr_accessor :uid, :name, :title, :mime_type, :size, :data, :url, :thumbnail_url, :last_modified, :file_key
165
486
 
166
487
  def initialize(attrs = {})
167
488
  @uid = attrs['fileUid'] || attrs['uid'] || attrs[:uid]
@@ -171,6 +492,9 @@ module KeeperSecretsManager
171
492
  @size = attrs['size'] || attrs[:size]
172
493
  @data = attrs['data'] || attrs[:data]
173
494
  @url = attrs['url'] || attrs[:url]
495
+ @thumbnail_url = attrs['thumbnailUrl'] || attrs['thumbnail_url'] || attrs[:thumbnail_url]
496
+ @last_modified = attrs['lastModified'] || attrs['last_modified'] || attrs[:last_modified]
497
+ @file_key = attrs['fileKey'] || attrs['file_key'] || attrs[:file_key]
174
498
  end
175
499
 
176
500
  def to_h
@@ -186,7 +510,7 @@ module KeeperSecretsManager
186
510
 
187
511
  # Response wrapper
188
512
  class SecretsManagerResponse
189
- attr_accessor :records, :folders, :app_data, :warnings, :errors, :just_bound
513
+ attr_accessor :records, :folders, :app_data, :warnings, :errors, :just_bound, :expires_on
190
514
 
191
515
  def initialize(attrs = {})
192
516
  @records = attrs[:records] || []
@@ -195,16 +519,18 @@ module KeeperSecretsManager
195
519
  @warnings = attrs[:warnings] || []
196
520
  @errors = attrs[:errors] || []
197
521
  @just_bound = attrs[:just_bound] || false
522
+ @expires_on = attrs[:expires_on]
198
523
  end
199
524
  end
200
525
 
201
526
  # Query options
202
527
  class QueryOptions
203
- attr_accessor :records_filter, :folders_filter
528
+ attr_accessor :records_filter, :folders_filter, :request_links
204
529
 
205
- def initialize(records: nil, folders: nil)
530
+ def initialize(records: nil, folders: nil, request_links: nil)
206
531
  @records_filter = records
207
532
  @folders_filter = folders
533
+ @request_links = request_links
208
534
  end
209
535
  end
210
536
 
@@ -217,5 +543,15 @@ module KeeperSecretsManager
217
543
  @subfolder_uid = subfolder_uid
218
544
  end
219
545
  end
546
+
547
+ # Update options
548
+ class UpdateOptions
549
+ attr_accessor :transaction_type, :links_to_remove
550
+
551
+ def initialize(transaction_type: 'general', links_to_remove: nil)
552
+ @transaction_type = transaction_type
553
+ @links_to_remove = links_to_remove || []
554
+ end
555
+ end
220
556
  end
221
- end
557
+ end
@@ -7,6 +7,7 @@ module KeeperSecretsManager
7
7
 
8
8
  # Authentication/authorization errors
9
9
  class AuthenticationError < Error; end
10
+
10
11
  class AccessDeniedError < AuthenticationError; end
11
12
 
12
13
  # API/network errors
@@ -22,7 +23,9 @@ module KeeperSecretsManager
22
23
 
23
24
  # Crypto errors
24
25
  class CryptoError < Error; end
26
+
25
27
  class DecryptionError < CryptoError; end
28
+
26
29
  class EncryptionError < CryptoError; end
27
30
 
28
31
  # Notation errors
@@ -30,7 +33,9 @@ module KeeperSecretsManager
30
33
 
31
34
  # Record errors
32
35
  class RecordError < Error; end
36
+
33
37
  class RecordNotFoundError < RecordError; end
38
+
34
39
  class RecordValidationError < RecordError; end
35
40
 
36
41
  # Server errors
@@ -46,13 +51,19 @@ module KeeperSecretsManager
46
51
 
47
52
  # Specific server error types
48
53
  class InvalidClientVersionError < ServerError; end
54
+
49
55
  class InvalidTokenError < ServerError; end
56
+
50
57
  class BadRequestError < ServerError; end
58
+
51
59
  class RecordUidNotFoundError < ServerError; end
60
+
52
61
  class FolderUidNotFoundError < ServerError; end
62
+
53
63
  class AccessViolationError < ServerError; end
64
+
54
65
  class ThrottledError < ServerError; end
55
-
66
+
56
67
  # Error factory
57
68
  class ErrorFactory
58
69
  def self.from_server_response(result_code, message = nil)
@@ -76,4 +87,4 @@ module KeeperSecretsManager
76
87
  end
77
88
  end
78
89
  end
79
- end
90
+ end
@@ -9,7 +9,7 @@ module KeeperSecretsManager
9
9
  @label = label
10
10
  @required = required
11
11
  @privacy_screen = privacy_screen
12
-
12
+
13
13
  # Ensure value is always an array
14
14
  @value = value.is_a?(Array) ? value : [value]
15
15
  end
@@ -103,7 +103,7 @@ module KeeperSecretsManager
103
103
  when String
104
104
  (Date.parse(date).to_time.to_f * 1000).to_i
105
105
  else
106
- raise ArgumentError, "Invalid date format"
106
+ raise ArgumentError, 'Invalid date format'
107
107
  end
108
108
  Field.new(type: 'birthDate', value: timestamp, label: label)
109
109
  end
@@ -149,4 +149,4 @@ module KeeperSecretsManager
149
149
  end
150
150
  end
151
151
  end
152
- end
152
+ end