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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -7
  3. data/README.md +29 -88
  4. data/artifacts/context.jsonld +50 -0
  5. data/artifacts/contract.schema.json +206 -0
  6. data/artifacts/core.schema.json +210 -0
  7. data/artifacts/implementation.schema.json +173 -0
  8. data/artifacts/profile.json +27 -0
  9. data/lib/mailschema/artifacts.rb +32 -75
  10. data/lib/mailschema/authentication.rb +77 -0
  11. data/lib/mailschema/contract.rb +184 -281
  12. data/lib/mailschema/description.rb +56 -0
  13. data/lib/mailschema/formats.rb +77 -0
  14. data/lib/mailschema/header.rb +75 -0
  15. data/lib/mailschema/implementation.rb +27 -0
  16. data/lib/mailschema/jcs.rb +11 -10
  17. data/lib/mailschema/json.rb +190 -0
  18. data/lib/mailschema/message.rb +127 -12
  19. data/lib/mailschema/origin.rb +18 -0
  20. data/lib/mailschema/patterns.rb +173 -0
  21. data/lib/mailschema/subschemas.rb +70 -0
  22. data/lib/mailschema/version.rb +1 -1
  23. data/lib/mailschema.rb +16 -26
  24. data/sig/mailschema.rbs +76 -68
  25. metadata +29 -54
  26. data/contexts/map-0.2.jsonld +0 -91
  27. data/lib/mailschema/capability.rb +0 -41
  28. data/lib/mailschema/document.rb +0 -79
  29. data/lib/mailschema/documents.rb +0 -127
  30. data/lib/mailschema/forms.rb +0 -41
  31. data/lib/mailschema/http.rb +0 -25
  32. data/lib/mailschema/limits.rb +0 -56
  33. data/lib/mailschema/pointer.rb +0 -46
  34. data/lib/mailschema/references.rb +0 -62
  35. data/lib/mailschema/schema_walk.rb +0 -29
  36. data/schemas/contribution.schema.json +0 -874
  37. data/schemas/forms-0.1.schema.json +0 -329
  38. data/schemas/map-0.2.schema.json +0 -1033
  39. data/schemas/type-contract-0.2.schema.json +0 -179
@@ -1,331 +1,234 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Type contracts an implementation vendors, verified by digest and compiled once.
3
+ # A type contract: the digest-bound definition of an interaction's details, operations,
4
+ # effects and permitted bindings.
4
5
  module Mailschema
5
- # A type contract that fails its own checks or its pinned digest.
6
- class InvalidContract < Error; end
7
-
8
- # Reasons the core assigns to the approval lifecycle.
9
- APPROVAL_REASONS = %w[declined stale-target expired superseded].freeze
10
-
11
- # A type contract an implementation vendors, verified against the digest it was
12
- # pinned by and compiled once. It checks the descriptions, requests, inputs and
13
- # results of that exact type. The Registry has already applied the contract rules,
14
- # such as portable patterns, to the contract that digest names; loading refuses
15
- # anything that would otherwise fail at request time.
16
- class Contract
17
- # A MAP problem a request earns before the service's own state is consulted.
18
- Problem = Data.define(:code, :title, :detail)
19
-
20
- FIELD_VALIDATORS = 128
21
- private_constant :FIELD_VALIDATORS
22
-
23
- attr_reader :document, :digest, :request_schema
24
-
25
- # `digest` is the contract digest the implementation pinned; `dependencies`
26
- # supplies, by URL, any pinned schema the gem does not bundle.
27
- def initialize(contract, request_schema, digest:, dependencies: {})
28
- errors = Validation.errors(CONTRACTS, contract)
29
- raise InvalidContract, "invalid type contract: #{errors.join("; ")}" if errors.any?
30
- raise InvalidContract, "the contract is for another MAP profile" unless contract["profile"] == PROFILE
31
-
32
- @document = frozen_copy(contract)
33
- @digest = Mailschema.digest(@document)
34
- raise InvalidContract, "the contract digest is #{@digest}, not the pinned #{digest}" unless @digest == digest
35
-
36
- @request_schema = frozen_copy(request_schema)
37
- @references = pin(@document.fetch("dependencies", []), dependencies)
38
- verify_structure
39
- compile
40
- end
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
- def id = document.fetch("id")
43
- def version = document.fetch("version")
43
+ [compile_problem(schema, within, pointer)].compact
44
+ end
44
45
 
45
- # The type reference a description and a request of this contract name exactly.
46
- def type_reference = { "id" => id, "version" => version, "contractDigest" => digest }
46
+ private
47
47
 
48
- def operation(id) = document.fetch("operations").find { |operation| operation.fetch("id") == id }
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
- # Whether completing the operation decides the interaction.
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
- # Every rule a description must satisfy beyond the core schema, for the service
57
- # that issues it and the client that receives it.
58
- def description_errors(description)
59
- errors = Mailschema.description_errors(description)
60
- return errors if errors.any?
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
- problems = details_problems(description)
64
- problems.concat(operation_problems(description))
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
- # The checks a service makes on a request once it has resolved the description it
73
- # issued, compared the description digest and established the caller: the exact
74
- # type, an offered operation the authority permits, and expiry. Nil when they
75
- # pass. The service then applies its own state (a decided interaction, a stale
76
- # target) and `input_errors`, in that order, before any effect.
77
- def request_problem(description, request, now:)
78
- unless request["type"] == type_reference && description["type"] == type_reference
79
- return Problem.new("unsupported-type", "Unsupported interaction type",
80
- "The service does not implement this exact interaction type contract.")
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
- found = operation(result.fetch("operation"))
116
- return ["#{result["operation"]} is not an operation of this type."] unless found
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
- declared = found.fetch("results").find { |entry| entry.fetch("state") == result.fetch("state") }
119
- return ["#{found["id"]} does not declare the state #{result["state"]}."] unless declared
120
-
121
- problems = Validation.errors(@outputs.fetch([found["id"], declared["state"]]), result.fetch("output"), "/output")
122
- if result["state"] == "failed" && !declared.fetch("reasons", []).include?(result["reason"])
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
- private
129
-
130
- def frozen_copy(value) = deep_freeze(JSON.parse(JSON.generate(value)))
131
-
132
- def deep_freeze(value)
133
- case value
134
- when Hash then value.each_value { |member| deep_freeze(member) }
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
- def pin(dependencies, supplied)
141
- references = dependencies.to_h do |dependency|
142
- url = dependency.fetch("url")
143
- schema = BUNDLED[url] || supplied[url]
144
- raise InvalidContract, "unknown dependency #{url}" unless schema
145
- unless Mailschema.digest(schema) == dependency.fetch("canonicalDigest")
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
- references.freeze
154
- end
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
- def verify_structure
157
- verify_schemas
158
- verify_request_schema
159
- verify_operations
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
- # The schemas the contract carries itself: its details and every output.
164
- def inline_schemas
165
- outputs = document.fetch("operations").flat_map do |operation|
166
- operation.fetch("results").map { |result| result["outputSchema"] }
167
- end
168
- [document["detailsSchema"], *outputs].compact
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
- # Every schema is valid JSON Schema 2020-12, so no malformed keyword fails when a
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 verify_request_schema
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
- constants = SchemaWalk.values(request_schema, "const").grep(String)
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
- def verify_operations
199
- raise InvalidContract, "duplicate operation identifiers" unless operation_ids.uniq == operation_ids
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
- document.fetch("operations").each do |operation|
202
- states = operation.fetch("results").map { |result| result.fetch("state") }
203
- raise InvalidContract, "duplicate #{operation["id"]} result states" unless states.uniq == states
204
- end
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
- def operation_ids = document.fetch("operations").map { |operation| operation.fetch("id") }
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
- # The pinned schemas the gem does not bundle.
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
- # Every reference resolved now, into every schema it reaches, as a request would
243
- # otherwise resolve it first.
244
- def resolve_references = [@details, *@inputs.values, *@outputs.values].compact.each(&:bundle)
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
- # Each operation's branch of the request schema, with its input schema.
247
- def branches
248
- last = request_schema.fetch("allOf", []).last || {}
249
- (last["oneOf"] || [last]).to_h do |branch|
250
- properties = branch["properties"] || {}
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
- def offered?(description, id)
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 operation_problems(description)
262
- ids = description.fetch("operations").map { |offered| offered.fetch("id") }
263
- authority = description.dig("service", "authority")
264
- problems = ids.uniq.size == ids.size ? [] : ["An operation is offered twice."]
265
- ids.each do |offered|
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
- problems
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 details_problems(description)
274
- details = description.key?("details") ? description["details"] : Pointer::MISSING
275
- missing = details.equal?(Pointer::MISSING)
276
- valid = @details ? !missing && @details.valid?(details) : missing
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
- blocks = document.fetch("operations").flat_map do |operation|
280
- operation.fetch("fieldBindings", []).map { |binding| binding["fields"] }
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
- blocks.uniq.flat_map do |pointer|
283
- fields = Pointer.member(details, pointer)
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
- # A form-shaped input holds only the fields the details define, and satisfies them.
289
- def binding_errors(binding, details, input)
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
- [problem("Values for the required fields are missing.", binding["input"])]
300
- else
301
- pointer_errors(fields_schema(fields), values, binding["input"])
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