anyvali 1.0.7 → 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 +4 -4
- data/README.md +25 -0
- data/lib/anyvali/schema.rb +41 -0
- data/lib/anyvali/schemas/intersection_schema.rb +2 -0
- data/lib/anyvali/schemas/object_schema.rb +3 -0
- data/lib/anyvali/schemas/optional_schema.rb +2 -0
- data/lib/anyvali/schemas/ref_schema.rb +2 -0
- data/lib/anyvali/validation_context.rb +4 -1
- data/lib/anyvali.rb +59 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d3beeaa37d2d4f268cd4aa46f2f564c64ed4ad43de42901978ddc2949612034
|
|
4
|
+
data.tar.gz: 12f981176518f1084a673fd3c7a434f9911395c31547ecb36df6edfc0b36dfb7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/lib/anyvali/schema.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
|
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-08-
|
|
11
|
+
date: 2026-08-31 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|