mailschema 0.2.0 → 0.3.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/CHANGELOG.md +4 -7
- data/README.md +29 -88
- data/artifacts/context.jsonld +50 -0
- data/artifacts/contract.schema.json +206 -0
- data/artifacts/core.schema.json +210 -0
- data/artifacts/implementation.schema.json +173 -0
- data/artifacts/profile.json +27 -0
- data/lib/mailschema/artifacts.rb +32 -75
- data/lib/mailschema/authentication.rb +77 -0
- data/lib/mailschema/contract.rb +184 -281
- data/lib/mailschema/description.rb +56 -0
- data/lib/mailschema/formats.rb +77 -0
- data/lib/mailschema/header.rb +75 -0
- data/lib/mailschema/implementation.rb +27 -0
- data/lib/mailschema/jcs.rb +11 -10
- data/lib/mailschema/json.rb +190 -0
- data/lib/mailschema/message.rb +127 -12
- data/lib/mailschema/origin.rb +18 -0
- data/lib/mailschema/patterns.rb +173 -0
- data/lib/mailschema/subschemas.rb +70 -0
- data/lib/mailschema/version.rb +1 -1
- data/lib/mailschema.rb +16 -26
- data/sig/mailschema.rbs +76 -68
- metadata +29 -54
- data/contexts/map-0.2.jsonld +0 -91
- data/lib/mailschema/capability.rb +0 -41
- data/lib/mailschema/document.rb +0 -79
- data/lib/mailschema/documents.rb +0 -127
- data/lib/mailschema/forms.rb +0 -41
- data/lib/mailschema/http.rb +0 -25
- data/lib/mailschema/limits.rb +0 -56
- data/lib/mailschema/pointer.rb +0 -46
- data/lib/mailschema/references.rb +0 -62
- data/lib/mailschema/schema_walk.rb +0 -29
- data/schemas/contribution.schema.json +0 -874
- data/schemas/forms-0.1.schema.json +0 -329
- data/schemas/map-0.2.schema.json +0 -1033
- data/schemas/type-contract-0.2.schema.json +0 -179
data/lib/mailschema/contract.rb
CHANGED
|
@@ -1,331 +1,234 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# A type contract: the digest-bound definition of an interaction's details, operations,
|
|
4
|
+
# effects and permitted bindings.
|
|
4
5
|
module Mailschema
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
6
|
+
# The capability binding's kinds: the exact effects each declares and its longest lifetime.
|
|
7
|
+
CAPABILITY_KINDS = {
|
|
8
|
+
"refusal" => { effects: %w[refusal state].freeze, max_lifetime_seconds: 604_800 }.freeze,
|
|
9
|
+
"protective-report" => { effects: %w[protection state].freeze, max_lifetime_seconds: 259_200 }.freeze,
|
|
10
|
+
"address-confirmation" => { effects: %w[assertion state].freeze, max_lifetime_seconds: 86_400 }.freeze
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
# The schemas a contract supplies, held to what every implementation enforces alike.
|
|
14
|
+
module ContractSchemas
|
|
15
|
+
# The JSON Schema 2020-12 keywords a contract's schemas may use. Every implementation
|
|
16
|
+
# enforces all of them alike; scope-resolved references and content annotations are absent.
|
|
17
|
+
KEYWORDS = Set.new(
|
|
18
|
+
%w[
|
|
19
|
+
$schema $ref $defs $comment
|
|
20
|
+
allOf anyOf oneOf not if then else dependentSchemas
|
|
21
|
+
prefixItems items contains properties patternProperties
|
|
22
|
+
additionalProperties propertyNames unevaluatedItems unevaluatedProperties
|
|
23
|
+
type enum const multipleOf maximum exclusiveMaximum minimum
|
|
24
|
+
exclusiveMinimum maxLength minLength pattern maxItems minItems
|
|
25
|
+
uniqueItems maxContains minContains maxProperties minProperties required
|
|
26
|
+
dependentRequired format
|
|
27
|
+
title description default deprecated readOnly writeOnly examples
|
|
28
|
+
]
|
|
29
|
+
).freeze
|
|
30
|
+
DIALECT = "https://json-schema.org/draft/2020-12/schema"
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# Why a schema a contract supplies cannot be enforced alike everywhere, if it cannot.
|
|
34
|
+
def problems(schema, pointer)
|
|
35
|
+
within = Subschemas.all(schema, pointer)
|
|
36
|
+
schemas = within.to_set { |at, _node| at.delete_prefix(pointer) }
|
|
37
|
+
found = within.flat_map do |at, node|
|
|
38
|
+
node.is_a?(Hash) ? node_problems(at, node, at == pointer, schemas) : []
|
|
39
|
+
end
|
|
40
|
+
return found if found.any?
|
|
41
|
+
return ["#{pointer}: $ref leads back without moving into the value"] if Subschemas.endless?(schema)
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
[compile_problem(schema, within, pointer)].compact
|
|
44
|
+
end
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
def type_reference = { "id" => id, "version" => version, "contractDigest" => digest }
|
|
46
|
+
private
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
def node_problems(at, node, root, schemas)
|
|
49
|
+
problems = node.keys.reject { |keyword| KEYWORDS.include?(keyword) }
|
|
50
|
+
.map { |keyword| "#{keyword} is not a MAP schema keyword" }
|
|
51
|
+
problems << "$schema names JSON Schema 2020-12, at the schema's root only" if stray_schema?(node, root)
|
|
52
|
+
problems << "$ref is a JSON Pointer to a schema within this schema" if unresolved?(node, schemas)
|
|
53
|
+
problems << "format is one of #{FORMATS.join(", ")}" if node.key?("format") && !FORMATS.include?(node["format"])
|
|
54
|
+
problems << "multipleOf is an integer" if node["multipleOf"].is_a?(Float) && node["multipleOf"] % 1 != 0
|
|
55
|
+
(problems + pattern_problems(node)).map { |problem| "#{at}: #{problem}" }
|
|
56
|
+
end
|
|
49
57
|
|
|
50
|
-
|
|
51
|
-
def decision?(id)
|
|
52
|
-
found = operation(id) or raise ArgumentError, "#{id} is not an operation of this type"
|
|
53
|
-
!found.fetch("repeatable", false)
|
|
54
|
-
end
|
|
58
|
+
def stray_schema?(node, root) = node.key?("$schema") && !(root && node["$schema"] == DIALECT)
|
|
55
59
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return ["The description names another type contract."] unless description.fetch("type") == type_reference
|
|
60
|
+
# A $ref other than # and the exact RFC 6901 pointer Subschemas gives a schema (the root or a
|
|
61
|
+
# subschema, object or boolean) in the same root. Any other value or spelling is refused,
|
|
62
|
+
# and every $ref is checked, in a definition nothing uses too.
|
|
63
|
+
def unresolved?(node, schemas)
|
|
64
|
+
return false unless node.key?("$ref")
|
|
62
65
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
unless Time.iso8601(description.fetch("describedAt")) < Time.iso8601(description.fetch("expiresAt"))
|
|
66
|
-
problems << "The interaction expires before it was described."
|
|
66
|
+
ref = node["$ref"]
|
|
67
|
+
!(ref.is_a?(String) && ref.start_with?("#") && schemas.include?(ref[1..]))
|
|
67
68
|
end
|
|
68
|
-
problems.concat(Capability.problems(description)) if description.dig("service", "authority") == "possession"
|
|
69
|
-
problems
|
|
70
|
-
end
|
|
71
69
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
end
|
|
82
|
-
unless offered?(description, request["operation"])
|
|
83
|
-
return Problem.new("unsupported-operation", "Unsupported operation",
|
|
84
|
-
"The operation was not offered in this interaction.")
|
|
70
|
+
def pattern_problems(node)
|
|
71
|
+
patterns = [
|
|
72
|
+
*(node["pattern"] if node["pattern"].is_a?(String)),
|
|
73
|
+
*(node["patternProperties"].keys if node["patternProperties"].is_a?(Hash))
|
|
74
|
+
]
|
|
75
|
+
patterns.filter_map do |pattern|
|
|
76
|
+
problem = Patterns.unportable(pattern)
|
|
77
|
+
"pattern #{JCS.quote(pattern)} uses #{problem}" if problem
|
|
78
|
+
end
|
|
85
79
|
end
|
|
86
|
-
return unless Mailschema.reached?(now, description.fetch("expiresAt"))
|
|
87
|
-
|
|
88
|
-
Problem.new("expired-interaction", "Interaction expired",
|
|
89
|
-
"The interaction expired before the request was processed.")
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# Input problems for one request, each with a detail and a JSON Pointer into its
|
|
93
|
-
# input: the operation's input schema, then its field bindings against the
|
|
94
|
-
# description's details. Type rules a contract cannot express are the caller's.
|
|
95
|
-
def input_errors(description, request)
|
|
96
|
-
found = operation(request["operation"])
|
|
97
|
-
schema = @inputs[request["operation"]]
|
|
98
|
-
return [problem("The operation is not part of this type.", "")] unless found && schema
|
|
99
|
-
|
|
100
|
-
errors = pointer_errors(schema, request["input"])
|
|
101
|
-
return errors.first(100) if errors.any?
|
|
102
|
-
|
|
103
|
-
found.fetch("fieldBindings", [])
|
|
104
|
-
.flat_map { |binding| binding_errors(binding, description["details"], request["input"]) }
|
|
105
|
-
.first(100)
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# The core result definition, then the output schema and reason this contract
|
|
109
|
-
# declares for the result's operation and state.
|
|
110
|
-
def result_errors(result)
|
|
111
|
-
errors = Mailschema.result_errors(result)
|
|
112
|
-
return errors if errors.any?
|
|
113
|
-
return ["The result names another type contract."] unless result.fetch("type") == type_reference
|
|
114
80
|
|
|
115
|
-
|
|
116
|
-
|
|
81
|
+
# Why the schema would not compile, or nil: it is not valid JSON Schema 2020-12, or a
|
|
82
|
+
# reference cannot be resolved now, so validation never fails on one later.
|
|
83
|
+
def compile_problem(schema, nodes, pointer)
|
|
84
|
+
invalid = JSONSchemer.validate_schema(schema).first
|
|
85
|
+
return "#{pointer}: #{invalid.fetch("error")}" if invalid
|
|
117
86
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
problems << "#{found["id"]} does not declare the reason #{result["reason"]}."
|
|
87
|
+
compiled = Schemas.compile(schema)
|
|
88
|
+
nodes.filter_map { |_at, node| node["$ref"] if node.is_a?(Hash) }.each { |ref| compiled.ref(ref) }
|
|
89
|
+
nil
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
"#{pointer}: #{e.message}"
|
|
124
92
|
end
|
|
125
|
-
problems
|
|
126
93
|
end
|
|
94
|
+
end
|
|
95
|
+
private_constant :ContractSchemas
|
|
96
|
+
|
|
97
|
+
# Why the value is not a MAP 0.3 type contract, or no reasons.
|
|
98
|
+
def self.contract_errors(value)
|
|
99
|
+
errors = Schemas.errors(Schemas::CONTRACT, value)
|
|
100
|
+
return errors if errors.any?
|
|
101
|
+
|
|
102
|
+
# The canonical form has JSON.stringify's bytes in another order.
|
|
103
|
+
errors << "/: exceeds #{CONTRACT_MAX_BYTES} bytes" if canonicalize(value).bytesize > CONTRACT_MAX_BYTES
|
|
104
|
+
ids = value["operations"].map { |operation| operation["id"] }
|
|
105
|
+
errors << "/operations: identifiers must be unique" if ids.uniq.size != ids.size
|
|
106
|
+
errors.concat(ContractSchemas.problems(value["detailsSchema"], "/detailsSchema"))
|
|
107
|
+
value["operations"].each_with_index do |operation, index|
|
|
108
|
+
at = "/operations/#{index}"
|
|
109
|
+
errors.concat(ContractSchemas.problems(operation["inputSchema"], "#{at}/inputSchema")) if operation["inputSchema"]
|
|
110
|
+
errors.concat(capability_errors(operation, at)) if operation["capability"]
|
|
111
|
+
end
|
|
112
|
+
errors
|
|
113
|
+
end
|
|
127
114
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
when Array then value.each { |item| deep_freeze(item) }
|
|
136
|
-
end
|
|
137
|
-
value.freeze
|
|
115
|
+
def self.capability_errors(operation, at)
|
|
116
|
+
name = operation.dig("capability", "kind")
|
|
117
|
+
kind = CAPABILITY_KINDS.fetch(name)
|
|
118
|
+
expected = kind[:effects].map { |effect| "#{EFFECTS}#{effect}" }.sort
|
|
119
|
+
errors = []
|
|
120
|
+
if operation["effects"].sort != expected
|
|
121
|
+
errors << "#{at}/effects: a #{name} capability declares exactly #{expected.join(" and ")}"
|
|
138
122
|
end
|
|
123
|
+
if operation.dig("capability", "maxLifetimeSeconds") > kind[:max_lifetime_seconds]
|
|
124
|
+
errors << "#{at}/capability/maxLifetimeSeconds: at most #{kind[:max_lifetime_seconds]}"
|
|
125
|
+
end
|
|
126
|
+
errors
|
|
127
|
+
end
|
|
128
|
+
private_class_method :capability_errors
|
|
139
129
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
raise InvalidContract, "the pinned digest of #{url} differs"
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
[url, schema.frozen? ? schema : frozen_copy(schema)]
|
|
150
|
-
end
|
|
151
|
-
raise InvalidContract, "the core schema must be pinned" unless references.key?(CORE_SCHEMA)
|
|
130
|
+
# A valid contract with its digest and compiled schemas.
|
|
131
|
+
class Contract
|
|
132
|
+
# The contract's frozen document.
|
|
133
|
+
attr_reader :document
|
|
134
|
+
# `sha-256:` and the SHA-256 of the contract's RFC 8785 form.
|
|
135
|
+
attr_reader :digest
|
|
152
136
|
|
|
153
|
-
|
|
154
|
-
|
|
137
|
+
# The contract in the bytes or text, read as MAP JSON within the contract limit.
|
|
138
|
+
def self.parse(input) = new(Mailschema.parse(input, CONTRACT_MAX_BYTES))
|
|
155
139
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
References.verify(inline_schemas, request_schema, @references, unbundled)
|
|
161
|
-
end
|
|
140
|
+
# The contract, or InvalidDocument with every reason the value is not one.
|
|
141
|
+
def initialize(value)
|
|
142
|
+
errors = Mailschema.contract_errors(value)
|
|
143
|
+
raise InvalidDocument.new("The value is not a MAP 0.3 type contract.", errors) if errors.any?
|
|
162
144
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
145
|
+
@document = frozen(value)
|
|
146
|
+
@digest = Mailschema.digest(@document)
|
|
147
|
+
@details = Schemas.compile(@document["detailsSchema"])
|
|
148
|
+
@inputs = @document["operations"].filter_map do |operation|
|
|
149
|
+
[operation["id"], Schemas.compile(operation["inputSchema"])] if operation["inputSchema"]
|
|
150
|
+
end.to_h
|
|
169
151
|
end
|
|
170
152
|
|
|
171
|
-
|
|
172
|
-
# request arrives.
|
|
173
|
-
def verify_schemas
|
|
174
|
-
[*inline_schemas, request_schema, *unbundled].each do |schema|
|
|
175
|
-
problem = Validation.invalid_schema(schema)
|
|
176
|
-
raise InvalidContract, "a schema is not valid JSON Schema 2020-12: #{problem}" if problem
|
|
177
|
-
end
|
|
178
|
-
end
|
|
153
|
+
def id = document["id"]
|
|
179
154
|
|
|
180
|
-
def
|
|
181
|
-
reference = document.fetch("requestSchema")
|
|
182
|
-
unless request_schema["$schema"] == "https://json-schema.org/draft/2020-12/schema"
|
|
183
|
-
raise InvalidContract, "the request schema must declare JSON Schema 2020-12"
|
|
184
|
-
end
|
|
185
|
-
unless request_schema["$id"] == reference["url"]
|
|
186
|
-
raise InvalidContract, "the request schema $id differs from the contract"
|
|
187
|
-
end
|
|
188
|
-
unless Mailschema.digest(request_schema) == reference["canonicalDigest"]
|
|
189
|
-
raise InvalidContract, "the request schema digest differs from the contract"
|
|
190
|
-
end
|
|
155
|
+
def version = document["version"]
|
|
191
156
|
|
|
192
|
-
|
|
193
|
-
[id, version, *operation_ids].each do |expected|
|
|
194
|
-
raise InvalidContract, "the request schema does not bind #{expected}" unless constants.include?(expected)
|
|
195
|
-
end
|
|
196
|
-
end
|
|
157
|
+
def operation(id) = document["operations"].find { |operation| operation["id"] == id }
|
|
197
158
|
|
|
198
|
-
|
|
199
|
-
|
|
159
|
+
# Why the details do not satisfy the contract's details schema, or no reasons.
|
|
160
|
+
def details_errors(details) = Schemas.errors(@details, details, "/details")
|
|
200
161
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
end
|
|
162
|
+
# Why the input is not acceptable for the operation, or no reasons. An operation whose
|
|
163
|
+
# inputSchema is null accepts no input: pass nil.
|
|
164
|
+
def input_errors(operation_id, input = nil)
|
|
165
|
+
return ["/operation: #{operation_id} is not an operation of this contract"] unless operation(operation_id)
|
|
206
166
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
# Every validator now, so a pattern that does not compile refuses the contract
|
|
210
|
-
# rather than a request.
|
|
211
|
-
def compile
|
|
212
|
-
references = @references.merge(request_schema.fetch("$id") => request_schema)
|
|
213
|
-
@schema = ->(value) { Validation.schema(value, references) }
|
|
214
|
-
compile_deferred
|
|
215
|
-
@details = @schema.call(document["detailsSchema"]) if document.key?("detailsSchema")
|
|
216
|
-
@inputs = branches.to_h { |operation, input| [operation, @schema.call(input)] }
|
|
217
|
-
@outputs = document.fetch("operations").flat_map do |operation|
|
|
218
|
-
operation.fetch("results").map do |result|
|
|
219
|
-
[[operation["id"], result["state"]], @schema.call(result.fetch("outputSchema"))]
|
|
220
|
-
end
|
|
221
|
-
end.to_h
|
|
222
|
-
resolve_references
|
|
223
|
-
@fields = {}
|
|
224
|
-
rescue RegexpError, JSONSchemer::InvalidEcmaRegexp => e
|
|
225
|
-
raise InvalidContract, "a pattern does not compile: #{e.message}"
|
|
226
|
-
rescue JSONSchemer::InvalidRefPointer, JSONSchemer::InvalidRefResolution, JSONSchemer::UnknownRef => e
|
|
227
|
-
raise InvalidContract, "a reference does not resolve: #{e.message}"
|
|
228
|
-
end
|
|
167
|
+
schema = @inputs[operation_id]
|
|
168
|
+
return input.nil? ? [] : ["/input: this operation accepts no input"] unless schema
|
|
229
169
|
|
|
230
|
-
|
|
231
|
-
def unbundled = @references.reject { |url, _| BUNDLED.key?(url) }.values
|
|
232
|
-
|
|
233
|
-
# What the validator would otherwise compile at first use: pinned schemas the gem
|
|
234
|
-
# does not bundle, and the names of patternProperties.
|
|
235
|
-
def compile_deferred
|
|
236
|
-
unbundled.each { |schema| @schema.call(schema) }
|
|
237
|
-
[document, request_schema, *unbundled]
|
|
238
|
-
.flat_map { |schema| SchemaWalk.values(schema, "patternProperties").grep(Hash).flat_map(&:keys) }
|
|
239
|
-
.each { |pattern| @schema.call({ "pattern" => pattern }) }
|
|
170
|
+
Schemas.errors(schema, input, "/input")
|
|
240
171
|
end
|
|
241
172
|
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
|
|
173
|
+
# Why a description does not use this contract as it allows, or no reasons: it is a
|
|
174
|
+
# description; it names this contract by identifier, version and digest; it offers only
|
|
175
|
+
# declared operations; a capability appears only where the contract permits one, at the
|
|
176
|
+
# service's origin and within the kind's lifetime; and its details satisfy the details
|
|
177
|
+
# schema.
|
|
178
|
+
def description_errors(description)
|
|
179
|
+
errors = Mailschema.description_errors(description)
|
|
180
|
+
return errors if errors.any?
|
|
245
181
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
[properties.dig("operation", "const"), properties.fetch("input", {})]
|
|
182
|
+
errors.concat(type_errors(description))
|
|
183
|
+
lifetime = Time.iso8601(description["expiresAt"]) - Time.iso8601(description["issuedAt"])
|
|
184
|
+
origin = authority(description.dig("service", "id"))
|
|
185
|
+
description["operations"].each_with_index do |offered, index|
|
|
186
|
+
errors.concat(offer_errors(offered, "/operations/#{index}", lifetime, origin))
|
|
252
187
|
end
|
|
188
|
+
errors.concat(details_errors(description["details"]))
|
|
253
189
|
end
|
|
254
190
|
|
|
255
|
-
|
|
256
|
-
authority = description.dig("service", "authority")
|
|
257
|
-
description.fetch("operations").any? { |offered| offered["id"] == id } &&
|
|
258
|
-
operation(id)&.fetch("authority")&.include?(authority)
|
|
259
|
-
end
|
|
191
|
+
private
|
|
260
192
|
|
|
261
|
-
def
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
unless operation(offered)&.fetch("authority")&.include?(authority)
|
|
267
|
-
problems << "#{offered} is not a #{authority} operation of this type."
|
|
268
|
-
end
|
|
193
|
+
def type_errors(description)
|
|
194
|
+
type = description["type"]
|
|
195
|
+
errors = []
|
|
196
|
+
if type["id"] != id || type["version"] != version
|
|
197
|
+
errors << "/type: names #{type["id"]} #{type["version"]}, not this contract"
|
|
269
198
|
end
|
|
270
|
-
|
|
199
|
+
errors << "/type/contractDigest: does not match" if type["contractDigest"] != digest
|
|
200
|
+
errors << "/profile: differs from the contract profile" if description["profile"] != document["profile"]
|
|
201
|
+
errors
|
|
271
202
|
end
|
|
272
203
|
|
|
273
|
-
def
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
return ["The details do not satisfy the type contract."] unless valid
|
|
204
|
+
def offer_errors(offered, at, lifetime, origin)
|
|
205
|
+
declared = operation(offered["id"])
|
|
206
|
+
return ["#{at}/id: #{offered["id"]} is not an operation of this contract"] unless declared
|
|
207
|
+
return [] unless offered["capability"]
|
|
278
208
|
|
|
279
|
-
|
|
280
|
-
|
|
209
|
+
errors = []
|
|
210
|
+
if !declared["capability"]
|
|
211
|
+
errors << "#{at}/capability: the contract permits no capability for #{offered["id"]}"
|
|
212
|
+
elsif lifetime > declared.dig("capability", "maxLifetimeSeconds")
|
|
213
|
+
errors << "/expiresAt: a #{offered["id"]} capability lasts at most " \
|
|
214
|
+
"#{declared.dig("capability", "maxLifetimeSeconds")} seconds"
|
|
281
215
|
end
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
fields.equal?(Pointer::MISSING) ? [] : Forms.problems(fields).map { |found| "#{pointer}: #{found}" }
|
|
216
|
+
if authority(offered.dig("capability", "url")) != origin
|
|
217
|
+
errors << "#{at}/capability/url: must have the service's origin"
|
|
285
218
|
end
|
|
219
|
+
errors
|
|
286
220
|
end
|
|
287
221
|
|
|
288
|
-
#
|
|
289
|
-
def
|
|
290
|
-
fields = Pointer.member(details, binding.fetch("fields"))
|
|
291
|
-
values = Pointer.member(input, binding.fetch("input"))
|
|
292
|
-
if fields.equal?(Pointer::MISSING)
|
|
293
|
-
return [] if values.equal?(Pointer::MISSING)
|
|
294
|
-
|
|
295
|
-
[problem("This interaction defines no fields for these values.", binding["input"])]
|
|
296
|
-
elsif values.equal?(Pointer::MISSING)
|
|
297
|
-
return [] if fields.fetch("required", []).empty?
|
|
222
|
+
# An HTTPS URL's host and port as an origin compares them: lowercase, without :443.
|
|
223
|
+
def authority(url) = url[%r{\Ahttps://([^/?#]*)}i, 1]&.downcase&.delete_suffix(":443")
|
|
298
224
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
225
|
+
# A frozen copy of a JSON value, so the document keeps its digest.
|
|
226
|
+
def frozen(value)
|
|
227
|
+
case value
|
|
228
|
+
when Hash then value.to_h { |name, member| [name, frozen(member)] }.freeze
|
|
229
|
+
when Array then value.map { |item| frozen(item) }.freeze
|
|
230
|
+
else value.dup.freeze
|
|
302
231
|
end
|
|
303
232
|
end
|
|
304
|
-
|
|
305
|
-
# The compiled schema of a fields block's values. Least recently used blocks leave
|
|
306
|
-
# first, so a long-running service stays bounded.
|
|
307
|
-
def fields_schema(fields)
|
|
308
|
-
key = Mailschema.digest(fields)
|
|
309
|
-
schema = @fields.delete(key) || @schema.call(Forms.values_schema(fields))
|
|
310
|
-
@fields[key] = schema
|
|
311
|
-
@fields.shift while @fields.size > FIELD_VALIDATORS
|
|
312
|
-
schema
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
# Validation errors as a detail and a JSON Pointer, naming a missing member itself.
|
|
316
|
-
def pointer_errors(schema, value, prefix = "")
|
|
317
|
-
schema.validate(value).flat_map do |error|
|
|
318
|
-
at = error.fetch("data_pointer")
|
|
319
|
-
if error["type"] == "required"
|
|
320
|
-
error.dig("details", "missing_keys").map do |name|
|
|
321
|
-
problem(error["error"], "#{prefix}#{at}/#{Pointer.escape(name)}")
|
|
322
|
-
end
|
|
323
|
-
else
|
|
324
|
-
[problem(error["error"], "#{prefix}#{at}")]
|
|
325
|
-
end
|
|
326
|
-
end.uniq
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
def problem(detail, pointer) = Limits.input_error(detail, pointer)
|
|
330
233
|
end
|
|
331
234
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A MAP description: the JSON-LD a message carries beside its readable content.
|
|
4
|
+
module Mailschema
|
|
5
|
+
# Whitespace, controls, bidirectional and invisible formatting characters.
|
|
6
|
+
UNSAFE = /\p{White_Space}|\p{Cc}|\p{Cf}/
|
|
7
|
+
private_constant :UNSAFE
|
|
8
|
+
|
|
9
|
+
# Why the value is not a MAP 0.3 description, or no reasons. Beyond the core schema: expiry
|
|
10
|
+
# follows issuance, operation identifiers are unique, and no security-sensitive identifier
|
|
11
|
+
# contains whitespace, a control or a formatting character.
|
|
12
|
+
def self.description_errors(value)
|
|
13
|
+
errors = Schemas.errors(Schemas::CORE, value)
|
|
14
|
+
return errors if errors.any?
|
|
15
|
+
|
|
16
|
+
if Time.iso8601(value["expiresAt"]) <= Time.iso8601(value["issuedAt"])
|
|
17
|
+
errors << "/expiresAt: must be later than issuedAt"
|
|
18
|
+
end
|
|
19
|
+
ids = value["operations"].map { |operation| operation["id"] }
|
|
20
|
+
errors << "/operations: identifiers must be unique" if ids.uniq.size != ids.size
|
|
21
|
+
identifiers(value).each do |pointer, identifier|
|
|
22
|
+
next unless identifier && UNSAFE.match?(identifier)
|
|
23
|
+
|
|
24
|
+
errors << "#{pointer}: must not contain whitespace, controls or formatting characters"
|
|
25
|
+
end
|
|
26
|
+
errors
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The description in the bytes or text, or InvalidDocument with every reason it is not one.
|
|
30
|
+
def self.parse_description(input)
|
|
31
|
+
value = parse(input, DESCRIPTION_MAX_BYTES)
|
|
32
|
+
errors = description_errors(value)
|
|
33
|
+
raise InvalidDocument.new("The value is not a MAP 0.3 description.", errors) if errors.any?
|
|
34
|
+
|
|
35
|
+
value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The security-sensitive identifiers of a description, by their JSON Pointers.
|
|
39
|
+
def self.identifiers(value)
|
|
40
|
+
capabilities = value["operations"].each_with_index.map do |operation, index|
|
|
41
|
+
["/operations/#{index}/capability/url", operation.dig("capability", "url")]
|
|
42
|
+
end
|
|
43
|
+
[
|
|
44
|
+
["/@id", value["@id"]],
|
|
45
|
+
["/type/id", value.dig("type", "id")],
|
|
46
|
+
["/service/id", value.dig("service", "id")],
|
|
47
|
+
["/service/tenant", value.dig("service", "tenant")],
|
|
48
|
+
["/recipient", value["recipient"]],
|
|
49
|
+
["/subject/id", value.dig("subject", "id")],
|
|
50
|
+
["/terms/id", value.dig("terms", "id")],
|
|
51
|
+
["/human/url", value.dig("human", "url")],
|
|
52
|
+
*capabilities
|
|
53
|
+
]
|
|
54
|
+
end
|
|
55
|
+
private_class_method :identifiers
|
|
56
|
+
end
|