anyvali 1.0.6 → 1.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 378e1c397c044f64198cc491edaace930f1108ab501afb44808886daf26cba23
4
- data.tar.gz: c583588e3712f050c3e40e1251a67199b98c5f6114ec86a703bc786724d160d3
3
+ metadata.gz: 7d3beeaa37d2d4f268cd4aa46f2f564c64ed4ad43de42901978ddc2949612034
4
+ data.tar.gz: 12f981176518f1084a673fd3c7a434f9911395c31547ecb36df6edfc0b36dfb7
5
5
  SHA512:
6
- metadata.gz: f86105f3d9d2dd6218df69dd9c2cd31041cc91f4eb283c1d2b9b56a245405fc10df37101ecabfd381aa303b07c227bb99e7bc69574d798d2adb6c02969134c22
7
- data.tar.gz: f5f48398440c35948b49cdcbfb09f6c742e8f5e355671a9b8bff022e3ae8bb0c15adc6527ca67c6b012a618fabfc337a76d37aa52778f5dc0e492ff0c8b15811
6
+ metadata.gz: 39292dfec9cd01679ed49cae0832df36b86cf9f3e7c885fe275a4396818029ab99cf2e6725c1e99107418fdebf5b0ee91d5d6aacaae9a900275d024b5c26891a
7
+ data.tar.gz: f8a560d904eeea6d5a17025815d1e82037a1b95c961c2efab179780eea8e30c72d34a08dcada50045afa8fdbe63708925b1c61a50172571e2c28eaab854ae84c
data/README.md CHANGED
@@ -202,6 +202,31 @@ schema = v.import_schema(json.loads(schema_json))
202
202
  result = schema.safe_parse(request_body) # Same validation rules!
203
203
  ```
204
204
 
205
+ ## Sensitive Data
206
+
207
+ Mark secrets with `sensitive: true`, then supply your own encryption function. AnyVali validates before and after transformation but never ships encryption logic or keys.
208
+
209
+ ```typescript
210
+ import { decrypt, encrypt, object, safeParseEncrypted, string } from "anyvali";
211
+
212
+ const Credentials = object({
213
+ username: string(),
214
+ password: string().minLength(12).describe("Password", { sensitive: true }),
215
+ });
216
+
217
+ const stored = encrypt(Credentials, input, (path, value) =>
218
+ `encrypted:${encryptWithYourKms(path, value)}`,
219
+ );
220
+
221
+ safeParseEncrypted(Credentials, stored); // validates the encrypted storage shape
222
+
223
+ const clear = decrypt(Credentials, stored, (path, value) =>
224
+ decryptWithYourKms(path, value.slice("encrypted:".length)),
225
+ );
226
+ ```
227
+
228
+ Sensitive objects and arrays are encrypted as one opaque value. See the [sensitive data guide](docs/sensitive-data.md) for behavior, security boundaries, and every SDK's API names.
229
+
205
230
  ## Forms
206
231
 
207
232
  The JS SDK also ships a small forms layer for browser-native fields, HTML5 attributes, and AnyVali validation.
@@ -25,6 +25,8 @@ module AnyVali
25
25
 
26
26
  def safe_parse(input, path: [], context: nil)
27
27
  context ||= ValidationContext.new
28
+ handled, sensitive = handle_sensitive(input, path, context)
29
+ return sensitive if handled
28
30
  value = input
29
31
  issues = []
30
32
 
@@ -174,6 +176,45 @@ module AnyVali
174
176
 
175
177
  protected
176
178
 
179
+ def handle_sensitive(input, path, context)
180
+ return [false, nil] unless @metadata["sensitive"] == true && !input.nil? && context.sensitive_mode
181
+
182
+ if context.sensitive_mode == :encrypted
183
+ unless input.is_a?(String)
184
+ return [true, ParseResult.new(value: nil, issues: [ValidationIssue.new(
185
+ code: IssueCodes::INVALID_TYPE,
186
+ path: path,
187
+ expected: "encrypted:<value>",
188
+ received: Schema.type_name(input)
189
+ )])]
190
+ end
191
+ unless input.start_with?("encrypted:") && input.length > "encrypted:".length
192
+ return [true, ParseResult.new(value: nil, issues: [ValidationIssue.new(
193
+ code: IssueCodes::INVALID_STRING,
194
+ path: path,
195
+ expected: "encrypted:<value>",
196
+ received: input
197
+ )])]
198
+ end
199
+ return [true, ParseResult.new(value: input, issues: [])]
200
+ end
201
+
202
+ key = path.freeze
203
+ if context.sensitive_cache&.key?(key)
204
+ return [true, ParseResult.new(value: context.sensitive_cache[key], issues: [])]
205
+ end
206
+
207
+ value = input
208
+ if context.sensitive_mode == :encrypt
209
+ checked = safe_parse(input, path: path, context: ValidationContext.new(definitions: context.definitions))
210
+ return [true, checked] if checked.failure?
211
+ value = checked.value
212
+ end
213
+ value = context.sensitive_transform.call(path.dup, value)
214
+ context.sensitive_cache[key] = value if context.sensitive_cache
215
+ [true, ParseResult.new(value: value, issues: [])]
216
+ end
217
+
177
218
  def validate(value, path, issues, context)
178
219
  raise NotImplementedError, "Subclasses must implement validate"
179
220
  end
@@ -17,6 +17,8 @@ module AnyVali
17
17
 
18
18
  def safe_parse(input, path: [], context: nil)
19
19
  context ||= ValidationContext.new
20
+ handled, sensitive = handle_sensitive(input, path, context)
21
+ return sensitive if handled
20
22
  all_issues = []
21
23
  merged_output = nil
22
24
 
@@ -37,6 +37,9 @@ module AnyVali
37
37
  end
38
38
 
39
39
  def safe_parse(input = nil, path: [], context: nil, **kwargs)
40
+ context ||= ValidationContext.new
41
+ handled, sensitive = handle_sensitive(input, path, context)
42
+ return sensitive if handled
40
43
  input = kwargs unless kwargs.empty?
41
44
  context ||= ValidationContext.new
42
45
  issues = []
@@ -19,6 +19,8 @@ module AnyVali
19
19
  # When used in objects, absence is handled by ObjectSchema
20
20
  # If called directly with a value, delegate to inner schema
21
21
  context ||= ValidationContext.new
22
+ handled, sensitive = handle_sensitive(input, path, context)
23
+ return sensitive if handled
22
24
  @inner_schema.safe_parse(input, path: path, context: context)
23
25
  end
24
26
 
@@ -15,6 +15,8 @@ module AnyVali
15
15
 
16
16
  def safe_parse(input, path: [], context: nil)
17
17
  context ||= ValidationContext.new
18
+ handled, sensitive = handle_sensitive(input, path, context)
19
+ return sensitive if handled
18
20
  resolved = resolve(context)
19
21
  if resolved.nil?
20
22
  issues = [ValidationIssue.new(
@@ -3,11 +3,14 @@
3
3
  module AnyVali
4
4
  class ValidationContext
5
5
  attr_reader :definitions
6
- attr_accessor :inherited_unknown_keys
6
+ attr_accessor :inherited_unknown_keys, :sensitive_mode, :sensitive_transform, :sensitive_cache
7
7
 
8
8
  def initialize(definitions: {})
9
9
  @definitions = definitions
10
10
  @inherited_unknown_keys = nil
11
+ @sensitive_mode = nil
12
+ @sensitive_transform = nil
13
+ @sensitive_cache = nil
11
14
  end
12
15
  end
13
16
  end
data/lib/anyvali.rb CHANGED
@@ -165,6 +165,65 @@ module AnyVali
165
165
  RefSchema.new(ref: ref_path)
166
166
  end
167
167
 
168
+ def safe_parse_encrypted(schema, data)
169
+ context = ValidationContext.new
170
+ context.sensitive_mode = :encrypted
171
+ schema.safe_parse(data, context: context)
172
+ end
173
+
174
+ def encrypt(schema, data, transform)
175
+ encrypted = transform_sensitive(schema, schema.parse(data), :encrypt, transform)
176
+ result = safe_parse_encrypted(schema, encrypted)
177
+ raise ValidationError, result.issues if result.failure?
178
+ result.value
179
+ end
180
+
181
+ def decrypt(schema, data, transform)
182
+ encrypted = safe_parse_encrypted(schema, data)
183
+ raise ValidationError, encrypted.issues if encrypted.failure?
184
+ schema.parse(transform_sensitive(schema, encrypted.value, :decrypt, transform))
185
+ end
186
+
187
+ def transform_sensitive(schema, data, mode, transform)
188
+ transform_sensitive_node(schema, data, [], mode, transform, {})
189
+ end
190
+ private_class_method :transform_sensitive
191
+
192
+ def transform_sensitive_node(schema, data, path, mode, transform, cache)
193
+ if schema.metadata["sensitive"] == true && !data.nil?
194
+ return cache[path] if cache.key?(path)
195
+ value = mode == :encrypt ? schema.parse(data) : data
196
+ return cache[path.dup.freeze] = transform.call(path.dup, value)
197
+ end
198
+
199
+ case schema
200
+ when ObjectSchema
201
+ data.merge(schema.properties.each_with_object({}) do |(key, child), output|
202
+ output[key] = transform_sensitive_node(child, data[key], path + [key], mode, transform, cache) if data.key?(key)
203
+ end)
204
+ when ArraySchema
205
+ data.each_with_index.map { |value, index| transform_sensitive_node(schema.items_schema, value, path + [index], mode, transform, cache) }
206
+ when TupleSchema
207
+ data.each_with_index.map { |value, index| transform_sensitive_node(schema.element_schemas[index], value, path + [index], mode, transform, cache) }
208
+ when RecordSchema
209
+ data.each_with_object({}) do |(key, value), output|
210
+ output[key] = transform_sensitive_node(schema.values_schema, value, path + [key], mode, transform, cache)
211
+ end
212
+ when OptionalSchema, NullableSchema
213
+ data.nil? ? nil : transform_sensitive_node(schema.inner_schema, data, path, mode, transform, cache)
214
+ when UnionSchema
215
+ child = schema.variant_schemas.find do |variant|
216
+ mode == :decrypt ? safe_parse_encrypted(variant, data).success? : variant.safe_parse(data).success?
217
+ end
218
+ child ? transform_sensitive_node(child, data, path, mode, transform, cache) : data
219
+ when IntersectionSchema
220
+ schema.all_of_schemas.reduce(data) { |value, child| transform_sensitive_node(child, value, path, mode, transform, cache) }
221
+ else
222
+ data
223
+ end
224
+ end
225
+ private_class_method :transform_sensitive_node
226
+
168
227
  # Interchange
169
228
 
170
229
  def export(schema, mode: :portable, definitions: {})
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: anyvali
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.6
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - AnyVali Contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-20 00:00:00.000000000 Z
11
+ date: 2026-08-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest