mailschema 0.2.2 → 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.
- package/README.md +25 -119
- package/bin/mailschema.js +40 -53
- package/dist/context.jsonld +50 -0
- package/dist/contract.schema.json +206 -0
- package/dist/core/artifacts.d.ts +11 -36
- package/dist/core/artifacts.js +21 -69
- package/dist/core/bundled.d.ts +4 -4
- package/dist/core/bundled.js +7 -7
- package/dist/core/contract.d.ts +59 -56
- package/dist/core/contract.js +224 -298
- package/dist/core/description.d.ts +45 -0
- package/dist/core/description.js +47 -0
- package/dist/core/implementation.d.ts +32 -0
- package/dist/core/implementation.js +25 -0
- package/dist/core/index.d.ts +7 -6
- package/dist/core/index.js +9 -9
- package/dist/core/json.d.ts +23 -0
- package/dist/core/{document.js → json.js} +61 -51
- package/dist/core/patterns.d.ts +12 -0
- package/dist/core/patterns.js +165 -0
- package/dist/core/sha256.d.ts +2 -0
- package/dist/core/sha256.js +59 -0
- package/dist/core.schema.json +210 -0
- package/dist/implementation.schema.json +173 -0
- package/dist/index.d.ts +4 -9
- package/dist/index.js +2 -14
- package/dist/profile.json +27 -0
- package/package.json +9 -10
- package/dist/contribution.schema.json +0 -874
- package/dist/core/binding.d.ts +0 -24
- package/dist/core/binding.js +0 -50
- package/dist/core/document.d.ts +0 -24
- package/dist/core/documents.d.ts +0 -68
- package/dist/core/documents.js +0 -145
- package/dist/core/limits.d.ts +0 -19
- package/dist/core/limits.js +0 -55
- package/dist/core/pointer.d.ts +0 -12
- package/dist/core/pointer.js +0 -48
- package/dist/core/schemas.d.ts +0 -21
- package/dist/core/schemas.js +0 -125
- package/dist/core/types.d.ts +0 -127
- package/dist/core/types.js +0 -2
- package/dist/forms-0.1.schema.json +0 -329
- package/dist/map-0.2.jsonld +0 -91
- package/dist/map-0.2.schema.json +0 -1033
- package/dist/map.d.ts +0 -8
- package/dist/map.js +0 -18
- package/dist/model.d.ts +0 -95
- package/dist/model.js +0 -5
- package/dist/type-contract-0.2.schema.json +0 -179
- package/dist/validation.d.ts +0 -15
- package/dist/validation.js +0 -64
package/dist/core/contract.js
CHANGED
|
@@ -1,109 +1,193 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { digest } from "./
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { CONTRACT_MAX_BYTES, contractSchema, createValidator, EFFECTS, FORMATS, schemaErrors, } from "./artifacts.js";
|
|
2
|
+
import { descriptionErrors } from "./description.js";
|
|
3
|
+
import { digest, InvalidDocument, parse } from "./json.js";
|
|
4
|
+
import { unportablePattern } from "./patterns.js";
|
|
5
|
+
/** The capability binding's kinds: the exact effects each declares and its longest lifetime. */
|
|
6
|
+
export const CAPABILITY_KINDS = {
|
|
7
|
+
refusal: { effects: ['refusal', 'state'], maxLifetimeSeconds: 604800 },
|
|
8
|
+
'protective-report': { effects: ['protection', 'state'], maxLifetimeSeconds: 259200 },
|
|
9
|
+
'address-confirmation': { effects: ['assertion', 'state'], maxLifetimeSeconds: 86400 },
|
|
10
|
+
};
|
|
11
|
+
// The JSON Schema 2020-12 keywords a contract's schemas may use. Every implementation
|
|
12
|
+
// enforces all of them alike; scope-resolved references and content annotations are absent.
|
|
13
|
+
const KEYWORDS = new Set([
|
|
14
|
+
'$schema', '$ref', '$defs', '$comment',
|
|
15
|
+
'allOf', 'anyOf', 'oneOf', 'not', 'if', 'then', 'else', 'dependentSchemas',
|
|
16
|
+
'prefixItems', 'items', 'contains', 'properties', 'patternProperties',
|
|
17
|
+
'additionalProperties', 'propertyNames', 'unevaluatedItems', 'unevaluatedProperties',
|
|
18
|
+
'type', 'enum', 'const', 'multipleOf', 'maximum', 'exclusiveMaximum', 'minimum',
|
|
19
|
+
'exclusiveMinimum', 'maxLength', 'minLength', 'pattern', 'maxItems', 'minItems',
|
|
20
|
+
'uniqueItems', 'maxContains', 'minContains', 'maxProperties', 'minProperties', 'required',
|
|
21
|
+
'dependentRequired', 'format',
|
|
22
|
+
'title', 'description', 'default', 'deprecated', 'readOnly', 'writeOnly', 'examples',
|
|
23
|
+
]); // prettier-ignore
|
|
24
|
+
const SCHEMA = ['additionalProperties', 'propertyNames', 'items', 'contains', 'not', 'if', 'then', 'else', 'unevaluatedItems', 'unevaluatedProperties']; // prettier-ignore
|
|
25
|
+
const SCHEMA_LISTS = ['allOf', 'anyOf', 'oneOf', 'prefixItems'];
|
|
26
|
+
const SCHEMA_MAPS = ['properties', 'patternProperties', '$defs', 'dependentSchemas'];
|
|
27
|
+
const DIALECT = 'https://json-schema.org/draft/2020-12/schema';
|
|
28
|
+
// The keywords that apply their subschemas to the value itself, not to a member or item.
|
|
29
|
+
const IN_PLACE = new Set(['not', 'if', 'then', 'else', 'allOf', 'anyOf', 'oneOf', 'dependentSchemas']);
|
|
30
|
+
const escape = (name) => name.replaceAll('~', '~0').replaceAll('/', '~1');
|
|
31
|
+
/** The values a schema object holds where schemas belong, as [keyword, pointer, value]. */
|
|
32
|
+
function children(node, pointer) {
|
|
33
|
+
const found = [];
|
|
34
|
+
for (const keyword of SCHEMA)
|
|
35
|
+
if (Object.hasOwn(node, keyword))
|
|
36
|
+
found.push([keyword, `${pointer}/${keyword}`, node[keyword]]);
|
|
37
|
+
for (const keyword of SCHEMA_LISTS)
|
|
38
|
+
if (Array.isArray(node[keyword]))
|
|
39
|
+
for (const [index, item] of node[keyword].entries())
|
|
40
|
+
found.push([keyword, `${pointer}/${keyword}/${index}`, item]);
|
|
41
|
+
for (const keyword of SCHEMA_MAPS) {
|
|
42
|
+
const map = node[keyword];
|
|
43
|
+
if (map && typeof map === 'object' && !Array.isArray(map))
|
|
44
|
+
for (const [name, item] of Object.entries(map))
|
|
45
|
+
found.push([keyword, `${pointer}/${keyword}/${escape(name)}`, item]);
|
|
46
|
+
}
|
|
47
|
+
return found;
|
|
9
48
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
49
|
+
const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
50
|
+
/**
|
|
51
|
+
* Every schema within a schema, object or boolean, with its JSON Pointer from the root, the
|
|
52
|
+
* schema itself first.
|
|
53
|
+
*/
|
|
54
|
+
function* schemas(schema, pointer) {
|
|
55
|
+
if (typeof schema === 'boolean')
|
|
56
|
+
yield [pointer, schema];
|
|
57
|
+
if (!isObject(schema))
|
|
58
|
+
return;
|
|
59
|
+
yield [pointer, schema];
|
|
60
|
+
for (const [, at, item] of children(schema, pointer))
|
|
61
|
+
yield* schemas(item, at);
|
|
23
62
|
}
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Whether applying the schema can never finish: from its root, a chain of $ref and in-place
|
|
65
|
+
* keywords returns to a schema without moving into a member or item.
|
|
66
|
+
*/
|
|
67
|
+
function endless(schema) {
|
|
68
|
+
// Where each schema object sends its value, and whether it is the same value.
|
|
69
|
+
const links = new Map();
|
|
70
|
+
for (const [at, node] of schemas(schema, '')) {
|
|
71
|
+
if (!isObject(node))
|
|
72
|
+
continue;
|
|
73
|
+
const applied = children(node, at)
|
|
74
|
+
.filter(([keyword]) => keyword !== '$defs')
|
|
75
|
+
.map(([keyword, to]) => [to, IN_PLACE.has(keyword)]);
|
|
76
|
+
if (typeof node.$ref === 'string')
|
|
77
|
+
applied.push([node.$ref.slice(1), true]);
|
|
78
|
+
links.set(at, applied);
|
|
79
|
+
}
|
|
80
|
+
const reachable = [''];
|
|
81
|
+
for (const at of reachable)
|
|
82
|
+
for (const [to] of links.get(at) ?? [])
|
|
83
|
+
if (!reachable.includes(to))
|
|
84
|
+
reachable.push(to);
|
|
85
|
+
const state = new Map();
|
|
86
|
+
const loops = (at) => {
|
|
87
|
+
if (state.has(at))
|
|
88
|
+
return state.get(at) === 'open';
|
|
89
|
+
state.set(at, 'open');
|
|
90
|
+
const looped = (links.get(at) ?? []).some(([to, inPlace]) => inPlace && loops(to));
|
|
91
|
+
state.set(at, 'done');
|
|
92
|
+
return looped;
|
|
93
|
+
};
|
|
94
|
+
return reachable.some(loops);
|
|
34
95
|
}
|
|
35
|
-
/**
|
|
36
|
-
function
|
|
37
|
-
const
|
|
38
|
-
|
|
96
|
+
/** Why a schema a contract supplies cannot be enforced alike everywhere, if it cannot. */
|
|
97
|
+
function schemaProblems(schema, pointer) {
|
|
98
|
+
const problems = [];
|
|
99
|
+
const positions = new Set([...schemas(schema, '')].map(([at]) => at));
|
|
100
|
+
for (const [at, value] of schemas(schema, pointer)) {
|
|
101
|
+
if (typeof value === 'boolean')
|
|
102
|
+
continue;
|
|
103
|
+
const node = value;
|
|
104
|
+
for (const keyword of Object.keys(node))
|
|
105
|
+
if (!KEYWORDS.has(keyword))
|
|
106
|
+
problems.push(`${at}: ${keyword} is not a MAP schema keyword`);
|
|
107
|
+
if ('$schema' in node && (at !== pointer || node.$schema !== DIALECT))
|
|
108
|
+
problems.push(`${at}: $schema names JSON Schema 2020-12, at the schema's root only`);
|
|
109
|
+
const ref = node.$ref;
|
|
110
|
+
if (ref !== undefined &&
|
|
111
|
+
(typeof ref !== 'string' || !ref.startsWith('#') || !positions.has(ref.slice(1))))
|
|
112
|
+
problems.push(`${at}: $ref is a JSON Pointer to a schema within this schema`);
|
|
113
|
+
if ('format' in node && !FORMATS.includes(node.format))
|
|
114
|
+
problems.push(`${at}: format is one of ${FORMATS.join(', ')}`);
|
|
115
|
+
if (typeof node.multipleOf === 'number' && !Number.isInteger(node.multipleOf))
|
|
116
|
+
problems.push(`${at}: multipleOf is an integer`);
|
|
117
|
+
const patterns = [
|
|
118
|
+
...(typeof node.pattern === 'string' ? [node.pattern] : []),
|
|
119
|
+
...Object.keys(node.patternProperties ?? {}),
|
|
120
|
+
];
|
|
121
|
+
for (const pattern of patterns) {
|
|
122
|
+
const problem = unportablePattern(pattern);
|
|
123
|
+
if (problem)
|
|
124
|
+
problems.push(`${at}: pattern ${JSON.stringify(pattern)} uses ${problem}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (problems.length)
|
|
128
|
+
return problems;
|
|
129
|
+
if (endless(schema))
|
|
130
|
+
return [`${pointer}: $ref leads back without moving into the value`];
|
|
131
|
+
try {
|
|
132
|
+
createValidator().compile(schema);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
problems.push(`${pointer}: ${error.message}`);
|
|
136
|
+
}
|
|
137
|
+
return problems;
|
|
39
138
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
139
|
+
const validateContract = createValidator().compile(contractSchema);
|
|
140
|
+
/** An HTTPS URL's host and port as an origin compares them: lowercase, without :443. */
|
|
141
|
+
const authority = (url) => /^https:\/\/([^/?#]*)/i.exec(url)?.[1].toLowerCase().replace(/:443$/, '');
|
|
142
|
+
/** Why the value is not a MAP 0.3 type contract, or no reasons. */
|
|
143
|
+
export function contractErrors(value) {
|
|
144
|
+
if (!validateContract(value))
|
|
145
|
+
return schemaErrors(validateContract.errors);
|
|
146
|
+
const errors = [];
|
|
147
|
+
if (new TextEncoder().encode(JSON.stringify(value)).length > CONTRACT_MAX_BYTES)
|
|
148
|
+
errors.push(`/: exceeds ${CONTRACT_MAX_BYTES} bytes`);
|
|
149
|
+
const ids = value.operations.map((operation) => operation.id);
|
|
150
|
+
if (new Set(ids).size !== ids.length)
|
|
151
|
+
errors.push('/operations: identifiers must be unique');
|
|
152
|
+
errors.push(...schemaProblems(value.detailsSchema, '/detailsSchema'));
|
|
153
|
+
for (const [index, operation] of value.operations.entries()) {
|
|
154
|
+
const at = `/operations/${index}`;
|
|
155
|
+
if (operation.inputSchema)
|
|
156
|
+
errors.push(...schemaProblems(operation.inputSchema, `${at}/inputSchema`));
|
|
157
|
+
if (!operation.capability)
|
|
158
|
+
continue;
|
|
159
|
+
const kind = CAPABILITY_KINDS[operation.capability.kind];
|
|
160
|
+
const expected = kind.effects.map((effect) => `${EFFECTS}${effect}`).sort();
|
|
161
|
+
if (operation.effects.toSorted().join('\n') !== expected.join('\n'))
|
|
162
|
+
errors.push(`${at}/effects: a ${operation.capability.kind} capability declares exactly ${expected.join(' and ')}`);
|
|
163
|
+
if (operation.capability.maxLifetimeSeconds > kind.maxLifetimeSeconds)
|
|
164
|
+
errors.push(`${at}/capability/maxLifetimeSeconds: at most ${kind.maxLifetimeSeconds}`);
|
|
165
|
+
}
|
|
166
|
+
return errors;
|
|
51
167
|
}
|
|
52
|
-
/**
|
|
53
|
-
* A type contract an implementation vendors, verified against the digest it was pinned by
|
|
54
|
-
* and compiled once. It checks the descriptions, requests, inputs and results of that exact
|
|
55
|
-
* type. The Registry has already applied the contract rules, such as portable patterns, to
|
|
56
|
-
* the contract that digest names; loading refuses anything that would otherwise fail when a
|
|
57
|
-
* request arrives.
|
|
58
|
-
*/
|
|
168
|
+
/** A valid contract with its digest and compiled schemas. */
|
|
59
169
|
export class Contract {
|
|
60
170
|
document;
|
|
171
|
+
/** `sha-256:` and the SHA-256 of the contract's RFC 8785 form. */
|
|
61
172
|
digest;
|
|
62
|
-
requestSchema;
|
|
63
|
-
#ajv = createValidator();
|
|
64
173
|
#details;
|
|
65
|
-
#request;
|
|
66
174
|
#inputs = new Map();
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
* `digest` is the contract digest the implementation pinned; `dependencies` supplies, by
|
|
71
|
-
* URL, any pinned schema the package does not bundle.
|
|
72
|
-
*/
|
|
73
|
-
constructor(contract, requestSchema, { digest: pinned, dependencies = {}, }) {
|
|
74
|
-
const errors = contractFormatErrors(contract);
|
|
175
|
+
/** The contract, or InvalidDocument with every reason the value is not one. */
|
|
176
|
+
constructor(value) {
|
|
177
|
+
const errors = contractErrors(value);
|
|
75
178
|
if (errors.length)
|
|
76
|
-
throw new
|
|
77
|
-
this.document =
|
|
78
|
-
if (this.document.profile !== MAP_PROFILE)
|
|
79
|
-
throw new InvalidContract('the contract is for another MAP profile');
|
|
179
|
+
throw new InvalidDocument('The value is not a MAP 0.3 type contract.', errors);
|
|
180
|
+
this.document = structuredClone(value);
|
|
80
181
|
this.digest = digest(this.document);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
this.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
// Every schema compiles now, so a pattern or reference fails the contract, not a request.
|
|
91
|
-
for (const schema of [...supplied, this.requestSchema])
|
|
92
|
-
this.#ajv.addSchema(schema);
|
|
93
|
-
for (const schema of supplied)
|
|
94
|
-
this.#ajv.getSchema(String(schema.$id));
|
|
95
|
-
this.#request = this.#ajv.getSchema(String(this.requestSchema.$id));
|
|
96
|
-
if (this.document.detailsSchema)
|
|
97
|
-
this.#details = this.#ajv.compile(this.document.detailsSchema);
|
|
98
|
-
for (const branch of branches(this.requestSchema))
|
|
99
|
-
this.#inputs.set(branch.operation, this.#ajv.compile(branch.input));
|
|
100
|
-
for (const operation of this.document.operations)
|
|
101
|
-
for (const result of operation.results)
|
|
102
|
-
this.#outputs.set(`${operation.id}/${result.state}`, this.#ajv.compile(result.outputSchema));
|
|
103
|
-
}
|
|
104
|
-
catch (error) {
|
|
105
|
-
throw new InvalidContract(`a schema does not compile: ${error.message}`);
|
|
106
|
-
}
|
|
182
|
+
const ajv = createValidator();
|
|
183
|
+
this.#details = ajv.compile(this.document.detailsSchema);
|
|
184
|
+
for (const operation of this.document.operations)
|
|
185
|
+
if (operation.inputSchema)
|
|
186
|
+
this.#inputs.set(operation.id, ajv.compile(operation.inputSchema));
|
|
187
|
+
}
|
|
188
|
+
/** The contract in the bytes or text, read as MAP JSON within the contract limit. */
|
|
189
|
+
static parse(input) {
|
|
190
|
+
return new Contract(parse(input, CONTRACT_MAX_BYTES));
|
|
107
191
|
}
|
|
108
192
|
get id() {
|
|
109
193
|
return this.document.id;
|
|
@@ -111,217 +195,59 @@ export class Contract {
|
|
|
111
195
|
get version() {
|
|
112
196
|
return this.document.version;
|
|
113
197
|
}
|
|
114
|
-
/** The type reference a description and a request of this contract name exactly. */
|
|
115
|
-
get typeReference() {
|
|
116
|
-
return { id: this.id, version: this.version, contractDigest: this.digest };
|
|
117
|
-
}
|
|
118
198
|
operation(id) {
|
|
119
199
|
return this.document.operations.find((operation) => operation.id === id);
|
|
120
200
|
}
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (!found)
|
|
125
|
-
throw new TypeError(`${id} is not an operation of this type.`);
|
|
126
|
-
return !found.repeatable;
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* Every rule a description must satisfy beyond the core schema, for the service that issues
|
|
130
|
-
* it and the client that receives it.
|
|
131
|
-
*/
|
|
132
|
-
descriptionErrors(description) {
|
|
133
|
-
const errors = descriptionErrors(description);
|
|
134
|
-
if (errors.length)
|
|
135
|
-
return errors;
|
|
136
|
-
const issued = description;
|
|
137
|
-
if (!this.#names(issued.type))
|
|
138
|
-
return ['The description names another type contract.'];
|
|
139
|
-
const problems = [...this.#detailsProblems(issued), ...this.#operationProblems(issued)];
|
|
140
|
-
if (!(Date.parse(issued.describedAt) < Date.parse(issued.expiresAt)))
|
|
141
|
-
problems.push('The interaction expires before it was described.');
|
|
142
|
-
if (issued.service.authority === 'possession')
|
|
143
|
-
problems.push(...capabilityProblems(issued));
|
|
144
|
-
return problems;
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* The checks a service makes on a request once it has resolved the description it issued,
|
|
148
|
-
* compared the description digest and established the caller: the exact type, an offered
|
|
149
|
-
* operation the authority permits, and expiry. Undefined when they pass. The service then
|
|
150
|
-
* applies its own state (a decided interaction, a stale target) and `inputErrors`, in that
|
|
151
|
-
* order, before any effect.
|
|
152
|
-
*/
|
|
153
|
-
requestProblem(description, request, { now }) {
|
|
154
|
-
if (!this.#names(request.type) || !this.#names(description.type))
|
|
155
|
-
return {
|
|
156
|
-
code: 'unsupported-type',
|
|
157
|
-
title: 'Unsupported interaction type',
|
|
158
|
-
detail: 'The service does not implement this exact interaction type contract.',
|
|
159
|
-
};
|
|
160
|
-
if (!this.#offered(description, request.operation))
|
|
161
|
-
return {
|
|
162
|
-
code: 'unsupported-operation',
|
|
163
|
-
title: 'Unsupported operation',
|
|
164
|
-
detail: 'The operation was not offered in this interaction.',
|
|
165
|
-
};
|
|
166
|
-
if (reached(now, description.expiresAt))
|
|
167
|
-
return {
|
|
168
|
-
code: 'expired-interaction',
|
|
169
|
-
title: 'Interaction expired',
|
|
170
|
-
detail: 'The interaction expired before the request was processed.',
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
/**
|
|
174
|
-
* Input problems for one request, each with a detail and a JSON Pointer into its input: the
|
|
175
|
-
* operation's input schema, then its field bindings against the description's details. Type
|
|
176
|
-
* rules a contract cannot express are the caller's.
|
|
177
|
-
*/
|
|
178
|
-
inputErrors(description, request) {
|
|
179
|
-
const found = this.operation(request.operation);
|
|
180
|
-
const validate = this.#inputs.get(request.operation);
|
|
181
|
-
if (!found || !validate)
|
|
182
|
-
return [inputError('The operation is not part of this type.', '')];
|
|
183
|
-
if (!validate(request.input))
|
|
184
|
-
return inputErrors(validate.errors);
|
|
185
|
-
return (found.fieldBindings ?? [])
|
|
186
|
-
.flatMap((binding) => {
|
|
187
|
-
const fields = member(description.details, binding.fields);
|
|
188
|
-
const supplied = member(request.input, binding.input);
|
|
189
|
-
if (!fields)
|
|
190
|
-
return supplied === undefined
|
|
191
|
-
? []
|
|
192
|
-
: [inputError('This interaction defines no fields for these values.', binding.input)];
|
|
193
|
-
if (supplied === undefined)
|
|
194
|
-
return (fields.required ?? []).length
|
|
195
|
-
? [inputError('Values for the required fields are missing.', binding.input)]
|
|
196
|
-
: [];
|
|
197
|
-
const schema = this.#fieldValues(fields);
|
|
198
|
-
return schema(supplied) ? [] : inputErrors(schema.errors, binding.input);
|
|
199
|
-
})
|
|
200
|
-
.slice(0, 100);
|
|
201
|
-
}
|
|
202
|
-
/** A request against the core request definition and this contract's request schema. */
|
|
203
|
-
requestErrors(request) {
|
|
204
|
-
return [
|
|
205
|
-
...requestErrors(request),
|
|
206
|
-
...(this.#request(request) ? [] : errorList(this.#request.errors)),
|
|
207
|
-
];
|
|
208
|
-
}
|
|
209
|
-
/** The core result definition, then the output schema and reason the operation declares. */
|
|
210
|
-
resultErrors(result) {
|
|
211
|
-
const errors = resultErrors(result);
|
|
212
|
-
if (errors.length)
|
|
213
|
-
return errors;
|
|
214
|
-
const recorded = result;
|
|
215
|
-
if (!this.#names(recorded.type))
|
|
216
|
-
return ['The result names another type contract.'];
|
|
217
|
-
const found = this.operation(recorded.operation);
|
|
218
|
-
if (!found)
|
|
219
|
-
return [`${recorded.operation} is not an operation of this type.`];
|
|
220
|
-
const declared = found.results.find((entry) => entry.state === recorded.state);
|
|
221
|
-
if (!declared)
|
|
222
|
-
return [`${found.id} does not declare the state ${recorded.state}.`];
|
|
223
|
-
const validate = this.#outputs.get(`${found.id}/${declared.state}`);
|
|
224
|
-
const problems = validate(recorded.output) ? [] : errorList(validate.errors, '/output');
|
|
225
|
-
if (recorded.state === 'failed' && !declared.reasons?.includes(recorded.reason))
|
|
226
|
-
problems.push(`${found.id} does not declare the reason ${recorded.reason}.`);
|
|
227
|
-
return problems;
|
|
228
|
-
}
|
|
229
|
-
#names(type) {
|
|
230
|
-
return (type.id === this.id && type.version === this.version && type.contractDigest === this.digest);
|
|
231
|
-
}
|
|
232
|
-
#offered(description, id) {
|
|
233
|
-
return (description.operations.some((offered) => offered.id === id) &&
|
|
234
|
-
Boolean(this.operation(id)?.authority.includes(description.service.authority)));
|
|
235
|
-
}
|
|
236
|
-
#operationProblems(description) {
|
|
237
|
-
const ids = description.operations.map((offered) => offered.id);
|
|
238
|
-
const authority = description.service.authority;
|
|
239
|
-
return [
|
|
240
|
-
...(new Set(ids).size === ids.length ? [] : ['An operation is offered twice.']),
|
|
241
|
-
...ids
|
|
242
|
-
.filter((id) => !this.operation(id)?.authority.includes(authority))
|
|
243
|
-
.map((id) => `${id} is not a ${authority} operation of this type.`),
|
|
244
|
-
];
|
|
201
|
+
/** Why the details do not satisfy the contract's details schema, or no reasons. */
|
|
202
|
+
detailsErrors(details) {
|
|
203
|
+
return this.#details(details) ? [] : schemaErrors(this.#details.errors, '/details');
|
|
245
204
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
if (!
|
|
250
|
-
return [
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
});
|
|
205
|
+
/** Why the input is not acceptable for the operation, or no reasons. */
|
|
206
|
+
inputErrors(operationId, input) {
|
|
207
|
+
const operation = this.operation(operationId);
|
|
208
|
+
if (!operation)
|
|
209
|
+
return [`/operation: ${operationId} is not an operation of this contract`];
|
|
210
|
+
const validate = this.#inputs.get(operationId);
|
|
211
|
+
if (!validate)
|
|
212
|
+
return input === undefined ? [] : ['/input: this operation accepts no input'];
|
|
213
|
+
return validate(input) ? [] : schemaErrors(validate.errors, '/input');
|
|
256
214
|
}
|
|
257
215
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
216
|
+
* Why a valid description does not use this contract as it allows, or no reasons: it names
|
|
217
|
+
* this contract by identifier, version and digest; it offers only declared operations; a
|
|
218
|
+
* capability appears only where the contract permits one, at the service's origin and
|
|
219
|
+
* within the kind's lifetime; and its details satisfy the details schema.
|
|
260
220
|
*/
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
if (!references.has(CORE_SCHEMA))
|
|
292
|
-
throw new InvalidContract('the core schema must be pinned');
|
|
293
|
-
return references;
|
|
294
|
-
}
|
|
295
|
-
#verify(references, supplied) {
|
|
296
|
-
const inline = [
|
|
297
|
-
this.document.detailsSchema,
|
|
298
|
-
...this.document.operations.flatMap((operation) => operation.results.map((result) => result.outputSchema)),
|
|
299
|
-
].filter(Boolean);
|
|
300
|
-
// Every schema is valid JSON Schema 2020-12, so no malformed keyword fails at request time.
|
|
301
|
-
for (const schema of [...inline, this.requestSchema, ...supplied])
|
|
302
|
-
if (!this.#ajv.validateSchema(schema))
|
|
303
|
-
throw new InvalidContract(`a schema is not valid JSON Schema 2020-12: ${this.#ajv.errorsText(this.#ajv.errors)}`);
|
|
304
|
-
const reference = this.document.requestSchema;
|
|
305
|
-
if (this.requestSchema.$schema !== JSON_SCHEMA_2020_12)
|
|
306
|
-
throw new InvalidContract('the request schema must declare JSON Schema 2020-12');
|
|
307
|
-
if (this.requestSchema.$id !== reference.url)
|
|
308
|
-
throw new InvalidContract('the request schema $id differs from the contract');
|
|
309
|
-
if (digest(this.requestSchema) !== reference.canonicalDigest)
|
|
310
|
-
throw new InvalidContract('the request schema digest differs from the contract');
|
|
311
|
-
const constants = new Set(values(this.requestSchema, 'const'));
|
|
312
|
-
const ids = this.document.operations.map((operation) => operation.id);
|
|
313
|
-
for (const expected of [this.id, this.version, ...ids])
|
|
314
|
-
if (!constants.has(expected))
|
|
315
|
-
throw new InvalidContract(`the request schema does not bind ${expected}`);
|
|
316
|
-
if (new Set(ids).size !== ids.length)
|
|
317
|
-
throw new InvalidContract('duplicate operation identifiers');
|
|
318
|
-
for (const operation of this.document.operations) {
|
|
319
|
-
const states = operation.results.map((result) => result.state);
|
|
320
|
-
if (new Set(states).size !== states.length)
|
|
321
|
-
throw new InvalidContract(`duplicate ${operation.id} result states`);
|
|
221
|
+
descriptionErrors(description) {
|
|
222
|
+
const invalid = descriptionErrors(description);
|
|
223
|
+
if (invalid.length)
|
|
224
|
+
return invalid;
|
|
225
|
+
const errors = [];
|
|
226
|
+
const { type } = description;
|
|
227
|
+
if (type.id !== this.id || type.version !== this.version)
|
|
228
|
+
errors.push(`/type: names ${type.id} ${type.version}, not this contract`);
|
|
229
|
+
if (type.contractDigest !== this.digest)
|
|
230
|
+
errors.push('/type/contractDigest: does not match');
|
|
231
|
+
if (description.profile !== this.document.profile)
|
|
232
|
+
errors.push('/profile: differs from the contract profile');
|
|
233
|
+
const lifetime = (Date.parse(description.expiresAt) - Date.parse(description.issuedAt)) / 1000;
|
|
234
|
+
const origin = authority(description.service.id);
|
|
235
|
+
for (const [index, offered] of description.operations.entries()) {
|
|
236
|
+
const at = `/operations/${index}`;
|
|
237
|
+
const operation = this.operation(offered.id);
|
|
238
|
+
if (!operation) {
|
|
239
|
+
errors.push(`${at}/id: ${offered.id} is not an operation of this contract`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!offered.capability)
|
|
243
|
+
continue;
|
|
244
|
+
if (!operation.capability)
|
|
245
|
+
errors.push(`${at}/capability: the contract permits no capability for ${offered.id}`);
|
|
246
|
+
else if (lifetime > operation.capability.maxLifetimeSeconds)
|
|
247
|
+
errors.push(`/expiresAt: a ${offered.id} capability lasts at most ${operation.capability.maxLifetimeSeconds} seconds`);
|
|
248
|
+
if (authority(offered.capability.url) !== origin)
|
|
249
|
+
errors.push(`${at}/capability/url: must have the service's origin`);
|
|
322
250
|
}
|
|
323
|
-
|
|
324
|
-
if (problem)
|
|
325
|
-
throw new InvalidContract(problem);
|
|
251
|
+
return [...errors, ...this.detailsErrors(description.details)];
|
|
326
252
|
}
|
|
327
253
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface Description {
|
|
2
|
+
'@context': string;
|
|
3
|
+
'@type': 'MailAction';
|
|
4
|
+
'@id': string;
|
|
5
|
+
profile: string;
|
|
6
|
+
type: {
|
|
7
|
+
id: string;
|
|
8
|
+
version: string;
|
|
9
|
+
contractDigest: string;
|
|
10
|
+
};
|
|
11
|
+
issuedAt: string;
|
|
12
|
+
expiresAt: string;
|
|
13
|
+
inLanguage: string;
|
|
14
|
+
service: {
|
|
15
|
+
id: string;
|
|
16
|
+
tenant?: string;
|
|
17
|
+
};
|
|
18
|
+
recipient: string;
|
|
19
|
+
subject: {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
};
|
|
23
|
+
terms: {
|
|
24
|
+
id: string;
|
|
25
|
+
version: string;
|
|
26
|
+
};
|
|
27
|
+
details: Record<string, unknown>;
|
|
28
|
+
operations: {
|
|
29
|
+
id: string;
|
|
30
|
+
capability?: {
|
|
31
|
+
url: string;
|
|
32
|
+
};
|
|
33
|
+
}[];
|
|
34
|
+
human: {
|
|
35
|
+
url: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Why the value is not a MAP 0.3 description, or no reasons. Beyond the core schema:
|
|
40
|
+
* expiry follows issuance, operation identifiers are unique, and no security-sensitive
|
|
41
|
+
* identifier contains whitespace, a control or a formatting character.
|
|
42
|
+
*/
|
|
43
|
+
export declare function descriptionErrors(value: unknown): string[];
|
|
44
|
+
/** The description in the bytes or text, or InvalidDocument with every reason it is not one. */
|
|
45
|
+
export declare function parseDescription(input: string | Uint8Array): Description;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// A MAP description: the JSON-LD a message carries beside its readable content.
|
|
2
|
+
import { coreSchema, createValidator, DESCRIPTION_MAX_BYTES, schemaErrors } from "./artifacts.js";
|
|
3
|
+
import { InvalidDocument, parse } from "./json.js";
|
|
4
|
+
const validate = createValidator().compile(coreSchema);
|
|
5
|
+
// Whitespace, controls, bidirectional and invisible formatting characters.
|
|
6
|
+
const UNSAFE = /[\p{White_Space}\p{Cc}\p{Cf}]/u;
|
|
7
|
+
/**
|
|
8
|
+
* Why the value is not a MAP 0.3 description, or no reasons. Beyond the core schema:
|
|
9
|
+
* expiry follows issuance, operation identifiers are unique, and no security-sensitive
|
|
10
|
+
* identifier contains whitespace, a control or a formatting character.
|
|
11
|
+
*/
|
|
12
|
+
export function descriptionErrors(value) {
|
|
13
|
+
if (!validate(value))
|
|
14
|
+
return schemaErrors(validate.errors);
|
|
15
|
+
const errors = [];
|
|
16
|
+
if (Date.parse(value.expiresAt) <= Date.parse(value.issuedAt))
|
|
17
|
+
errors.push('/expiresAt: must be later than issuedAt');
|
|
18
|
+
const ids = value.operations.map((operation) => operation.id);
|
|
19
|
+
if (new Set(ids).size !== ids.length)
|
|
20
|
+
errors.push('/operations: identifiers must be unique');
|
|
21
|
+
const identifiers = [
|
|
22
|
+
['/@id', value['@id']],
|
|
23
|
+
['/type/id', value.type.id],
|
|
24
|
+
['/service/id', value.service.id],
|
|
25
|
+
['/service/tenant', value.service.tenant],
|
|
26
|
+
['/recipient', value.recipient],
|
|
27
|
+
['/subject/id', value.subject.id],
|
|
28
|
+
['/terms/id', value.terms.id],
|
|
29
|
+
['/human/url', value.human.url],
|
|
30
|
+
...value.operations.map((operation, index) => [
|
|
31
|
+
`/operations/${index}/capability/url`,
|
|
32
|
+
operation.capability?.url,
|
|
33
|
+
]),
|
|
34
|
+
];
|
|
35
|
+
for (const [pointer, identifier] of identifiers)
|
|
36
|
+
if (identifier !== undefined && UNSAFE.test(identifier))
|
|
37
|
+
errors.push(`${pointer}: must not contain whitespace, controls or formatting characters`);
|
|
38
|
+
return errors;
|
|
39
|
+
}
|
|
40
|
+
/** The description in the bytes or text, or InvalidDocument with every reason it is not one. */
|
|
41
|
+
export function parseDescription(input) {
|
|
42
|
+
const value = parse(input, DESCRIPTION_MAX_BYTES);
|
|
43
|
+
const errors = descriptionErrors(value);
|
|
44
|
+
if (errors.length)
|
|
45
|
+
throw new InvalidDocument('The value is not a MAP 0.3 description.', errors);
|
|
46
|
+
return value;
|
|
47
|
+
}
|