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.
Files changed (52) hide show
  1. package/README.md +25 -119
  2. package/bin/mailschema.js +40 -53
  3. package/dist/context.jsonld +50 -0
  4. package/dist/contract.schema.json +206 -0
  5. package/dist/core/artifacts.d.ts +11 -36
  6. package/dist/core/artifacts.js +21 -69
  7. package/dist/core/bundled.d.ts +4 -4
  8. package/dist/core/bundled.js +7 -7
  9. package/dist/core/contract.d.ts +59 -56
  10. package/dist/core/contract.js +224 -298
  11. package/dist/core/description.d.ts +45 -0
  12. package/dist/core/description.js +47 -0
  13. package/dist/core/implementation.d.ts +32 -0
  14. package/dist/core/implementation.js +25 -0
  15. package/dist/core/index.d.ts +7 -6
  16. package/dist/core/index.js +9 -9
  17. package/dist/core/json.d.ts +23 -0
  18. package/dist/core/{document.js → json.js} +61 -51
  19. package/dist/core/patterns.d.ts +12 -0
  20. package/dist/core/patterns.js +165 -0
  21. package/dist/core/sha256.d.ts +2 -0
  22. package/dist/core/sha256.js +59 -0
  23. package/dist/core.schema.json +210 -0
  24. package/dist/implementation.schema.json +173 -0
  25. package/dist/index.d.ts +4 -9
  26. package/dist/index.js +2 -14
  27. package/dist/profile.json +27 -0
  28. package/package.json +9 -10
  29. package/dist/contribution.schema.json +0 -874
  30. package/dist/core/binding.d.ts +0 -24
  31. package/dist/core/binding.js +0 -50
  32. package/dist/core/document.d.ts +0 -24
  33. package/dist/core/documents.d.ts +0 -68
  34. package/dist/core/documents.js +0 -145
  35. package/dist/core/limits.d.ts +0 -19
  36. package/dist/core/limits.js +0 -55
  37. package/dist/core/pointer.d.ts +0 -12
  38. package/dist/core/pointer.js +0 -48
  39. package/dist/core/schemas.d.ts +0 -21
  40. package/dist/core/schemas.js +0 -125
  41. package/dist/core/types.d.ts +0 -127
  42. package/dist/core/types.js +0 -2
  43. package/dist/forms-0.1.schema.json +0 -329
  44. package/dist/map-0.2.jsonld +0 -91
  45. package/dist/map-0.2.schema.json +0 -1033
  46. package/dist/map.d.ts +0 -8
  47. package/dist/map.js +0 -18
  48. package/dist/model.d.ts +0 -95
  49. package/dist/model.js +0 -5
  50. package/dist/type-contract-0.2.schema.json +0 -179
  51. package/dist/validation.d.ts +0 -15
  52. package/dist/validation.js +0 -64
@@ -1,109 +1,193 @@
1
- import { BUNDLED, CORE_SCHEMA, JSON_SCHEMA_2020_12, MAP_PROFILE, contractFormatErrors, createValidator, descriptionErrors, errorList, reached, requestErrors, resultErrors, } from "./artifacts.js";
2
- import { capabilityProblems } from "./binding.js";
3
- import { digest } from "./document.js";
4
- import { inputError } from "./limits.js";
5
- import { escape, member } from "./pointer.js";
6
- import { fieldValuesSchema, formProblems, referenceProblem, values } from "./schemas.js";
7
- /** A type contract that fails its own checks or its pinned digest. */
8
- export class InvalidContract extends Error {
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
- /** Reasons the core assigns to the approval lifecycle. */
11
- export const APPROVAL_REASONS = ['declined', 'stale-target', 'expired', 'superseded'];
12
- const FIELD_VALIDATORS = 128;
13
- function frozen(value) {
14
- const copy = structuredClone(value);
15
- const freeze = (item) => {
16
- if (item && typeof item === 'object') {
17
- Object.values(item).forEach(freeze);
18
- Object.freeze(item);
19
- }
20
- };
21
- freeze(copy);
22
- return copy;
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
- /** Each operation's branch of a request schema, with its input schema. */
25
- function branches(requestSchema) {
26
- const last = (requestSchema.allOf ?? []).at(-1) ?? {};
27
- return (last.oneOf ?? [last]).map((branch) => {
28
- const properties = (branch.properties ?? {});
29
- return {
30
- operation: properties.operation?.const,
31
- input: properties.input ?? {},
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
- /** A JSON Pointer to the failing member, naming a missing or extra member itself. */
36
- function pointerOf(error) {
37
- const name = error.params.missingProperty ?? error.params.additionalProperty;
38
- return name === undefined ? error.instancePath : `${error.instancePath}/${escape(String(name))}`;
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
- /** Input errors as a detail and a pointer, each within the core limits, the first 100 kept. */
41
- function inputErrors(errors, prefix = '') {
42
- const seen = new Set();
43
- return (errors ?? [])
44
- .filter((error) => error.keyword !== 'if')
45
- .map((error) => inputError(error.message ?? 'invalid', `${prefix}${pointerOf(error)}`))
46
- .filter(({ detail, pointer }) => {
47
- const key = JSON.stringify([pointer, detail]);
48
- return !seen.has(key) && Boolean(seen.add(key));
49
- })
50
- .slice(0, 100);
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
- #outputs = new Map();
68
- #fields = new Map();
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 InvalidContract(`invalid type contract: ${errors.join('; ')}`);
77
- this.document = frozen(contract);
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
- if (this.digest !== pinned)
82
- throw new InvalidContract(`the contract digest is ${this.digest}, not the pinned ${pinned}`);
83
- this.requestSchema = frozen(requestSchema);
84
- const references = this.#pin(dependencies);
85
- const supplied = [...references]
86
- .filter(([url]) => !BUNDLED.has(url))
87
- .map(([, schema]) => schema);
88
- this.#verify(references, supplied);
89
- try {
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
- /** Whether completing the operation decides the interaction. */
122
- isDecision(id) {
123
- const found = this.operation(id);
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
- #detailsProblems(description) {
247
- const present = Object.hasOwn(description, 'details');
248
- const valid = this.#details ? present && this.#details(description.details) : !present;
249
- if (!valid)
250
- return ['The details do not satisfy the type contract.'];
251
- const blocks = new Set(this.document.operations.flatMap((operation) => (operation.fieldBindings ?? []).map((binding) => binding.fields)));
252
- return [...blocks].flatMap((pointer) => {
253
- const fields = member(description.details, pointer);
254
- return fields ? formProblems(fields).map((problem) => `${pointer}: ${problem}`) : [];
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
- * The compiled schema of a fields block's values. Least recently used blocks leave first,
259
- * with their compiled copies, so a long-running service stays bounded.
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
- #fieldValues(fields) {
262
- const key = digest(fields);
263
- let entry = this.#fields.get(key);
264
- if (entry)
265
- this.#fields.delete(key);
266
- else {
267
- const schema = fieldValuesSchema(fields);
268
- entry = { schema, validate: this.#ajv.compile(schema) };
269
- }
270
- this.#fields.set(key, entry);
271
- if (this.#fields.size > FIELD_VALIDATORS) {
272
- const [oldest, evicted] = this.#fields.entries().next().value;
273
- this.#fields.delete(oldest);
274
- this.#ajv.removeSchema(evicted.schema);
275
- }
276
- return entry.validate;
277
- }
278
- /** The pinned schemas by URL, bundled or supplied, each matching its pinned digest. */
279
- #pin(dependencies) {
280
- const references = new Map();
281
- for (const dependency of this.document.dependencies ?? []) {
282
- const schema = (BUNDLED.get(dependency.url) ?? dependencies[dependency.url]);
283
- if (!schema)
284
- throw new InvalidContract(`unknown dependency ${dependency.url}`);
285
- if (digest(schema) !== dependency.canonicalDigest)
286
- throw new InvalidContract(`the pinned digest of ${dependency.url} differs`);
287
- if (schema.$id !== dependency.url)
288
- throw new InvalidContract(`the schema pinned as ${dependency.url} has another $id`);
289
- references.set(dependency.url, BUNDLED.has(dependency.url) ? schema : frozen(schema));
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
- const problem = referenceProblem(inline, this.requestSchema, references, supplied);
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
+ }