mailschema 0.2.1 → 0.2.2
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 +93 -9
- package/dist/core/artifacts.d.ts +36 -0
- package/dist/core/artifacts.js +78 -0
- package/dist/core/binding.d.ts +24 -0
- package/dist/core/binding.js +50 -0
- package/dist/core/bundled.d.ts +4 -0
- package/dist/core/bundled.js +7 -0
- package/dist/core/contract.d.ts +66 -0
- package/dist/core/contract.js +327 -0
- package/dist/core/document.d.ts +24 -0
- package/dist/core/document.js +190 -0
- package/dist/core/documents.d.ts +68 -0
- package/dist/core/documents.js +145 -0
- package/dist/core/index.d.ts +6 -0
- package/dist/core/index.js +9 -0
- package/dist/core/limits.d.ts +19 -0
- package/dist/core/limits.js +55 -0
- package/dist/core/pointer.d.ts +12 -0
- package/dist/core/pointer.js +48 -0
- package/dist/core/schemas.d.ts +21 -0
- package/dist/core/schemas.js +125 -0
- package/dist/core/types.d.ts +127 -0
- package/dist/core/types.js +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +6 -2
- package/dist/map.d.ts +0 -2
- package/dist/map.js +0 -2
- package/dist/validation.d.ts +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Building MAP results and problems, and the lifecycle rules every service applies.
|
|
2
|
+
import { MAP_PROFILE, problemErrors, reached, resultErrors } from "./artifacts.js";
|
|
3
|
+
import { problemMembers, withinDocument } from "./limits.js";
|
|
4
|
+
/** The HTTP status of every MAP problem code, as the core schema fixes it. */
|
|
5
|
+
export const PROBLEM_STATUS = {
|
|
6
|
+
'invalid-request': 400,
|
|
7
|
+
'authentication-required': 401,
|
|
8
|
+
refused: 403,
|
|
9
|
+
'result-not-found': 404,
|
|
10
|
+
'stale-target': 409,
|
|
11
|
+
'idempotency-conflict': 409,
|
|
12
|
+
'request-in-progress': 409,
|
|
13
|
+
'already-decided': 409,
|
|
14
|
+
'expired-interaction': 410,
|
|
15
|
+
'unsupported-type': 422,
|
|
16
|
+
'unsupported-operation': 422,
|
|
17
|
+
};
|
|
18
|
+
const PROBLEM_TYPES = 'https://mailschema.org/problems/';
|
|
19
|
+
const NON_TERMINAL = ['pending', 'approval-required'];
|
|
20
|
+
/** The document, once the core definition `check` names accepts it. */
|
|
21
|
+
function built(document, check) {
|
|
22
|
+
const refused = check(document);
|
|
23
|
+
if (refused.length)
|
|
24
|
+
throw new TypeError(`The core refuses this document: ${refused.slice(0, 3).join('; ')}`);
|
|
25
|
+
return document;
|
|
26
|
+
}
|
|
27
|
+
const defined = (document) => Object.fromEntries(Object.entries(document).filter(([, value]) => value !== undefined));
|
|
28
|
+
/** A result with its members in one order, whichever builder made it, checked by the core. */
|
|
29
|
+
const resultDocument = (members) => built(defined({
|
|
30
|
+
kind: 'MapResult',
|
|
31
|
+
profile: MAP_PROFILE,
|
|
32
|
+
requestId: members.requestId,
|
|
33
|
+
interactionId: members.interactionId,
|
|
34
|
+
descriptionDigest: members.descriptionDigest,
|
|
35
|
+
type: members.type,
|
|
36
|
+
operation: members.operation,
|
|
37
|
+
state: members.state,
|
|
38
|
+
target: members.target,
|
|
39
|
+
recordedAt: members.recordedAt,
|
|
40
|
+
resultUrl: members.resultUrl,
|
|
41
|
+
actor: members.actor,
|
|
42
|
+
approvalUrl: members.approvalUrl,
|
|
43
|
+
reason: members.reason,
|
|
44
|
+
output: members.output,
|
|
45
|
+
}), resultErrors);
|
|
46
|
+
/**
|
|
47
|
+
* A result for a request, with every correlation member. `approvalUrl` is given exactly for
|
|
48
|
+
* approval-required and `reason` exactly for failed. Like every builder here, it throws
|
|
49
|
+
* rather than return a document the core refuses.
|
|
50
|
+
*/
|
|
51
|
+
export function result(request, { state, target, resultUrl, recordedAt, output = {}, reason, approvalUrl, actor, }) {
|
|
52
|
+
if ((state === 'approval-required') !== (approvalUrl !== undefined))
|
|
53
|
+
throw new TypeError('approvalUrl is given exactly when the state is approval-required.');
|
|
54
|
+
if ((state === 'failed') !== (reason !== undefined))
|
|
55
|
+
throw new TypeError('reason is given exactly when the state is failed.');
|
|
56
|
+
return resultDocument({
|
|
57
|
+
requestId: request.requestId,
|
|
58
|
+
interactionId: request.interactionId,
|
|
59
|
+
descriptionDigest: request.descriptionDigest,
|
|
60
|
+
type: request.type,
|
|
61
|
+
operation: request.operation,
|
|
62
|
+
state,
|
|
63
|
+
target,
|
|
64
|
+
recordedAt: recordedAt.toISOString(),
|
|
65
|
+
resultUrl,
|
|
66
|
+
actor,
|
|
67
|
+
approvalUrl,
|
|
68
|
+
reason,
|
|
69
|
+
output,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The next state of a pending or approval-required result. Every correlation member and the
|
|
74
|
+
* actor stay; the state, reason, output and recording time change.
|
|
75
|
+
*/
|
|
76
|
+
export function transition(previous, { state, recordedAt, reason, output = {}, }) {
|
|
77
|
+
if (!NON_TERMINAL.includes(previous.state))
|
|
78
|
+
throw new TypeError(`A ${previous.state} result is terminal.`);
|
|
79
|
+
if (NON_TERMINAL.includes(state))
|
|
80
|
+
throw new TypeError('A transition ends pending work.');
|
|
81
|
+
if ((state === 'failed') !== (reason !== undefined))
|
|
82
|
+
throw new TypeError('reason is given exactly when the state is failed.');
|
|
83
|
+
return resultDocument({
|
|
84
|
+
...previous,
|
|
85
|
+
approvalUrl: undefined,
|
|
86
|
+
state,
|
|
87
|
+
reason,
|
|
88
|
+
output,
|
|
89
|
+
recordedAt: recordedAt.toISOString(),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A problem. With `requestId` it is correlated and carries `instance`, `profile`,
|
|
94
|
+
* `requestId`, `interactionId` and `code`; without, it is plain RFC 9457. It stays within
|
|
95
|
+
* the core limits: a long detail is cut, each input error is bounded, and only as many of
|
|
96
|
+
* the first 100 errors as fit within 64 KiB are kept.
|
|
97
|
+
*/
|
|
98
|
+
export function problem(code, { title, detail, requestId, interactionId, resultUrl, target, errors, }) {
|
|
99
|
+
const members = problemMembers(title, detail, errors);
|
|
100
|
+
if (interactionId && !requestId)
|
|
101
|
+
throw new TypeError('An interaction is named only with its request.');
|
|
102
|
+
if (requestId && code === 'authentication-required')
|
|
103
|
+
throw new TypeError('authentication-required is never correlated.');
|
|
104
|
+
if (members.errors && !(requestId && code === 'invalid-request'))
|
|
105
|
+
throw new TypeError('Errors belong to a correlated invalid-request.');
|
|
106
|
+
let correlation = {};
|
|
107
|
+
if (requestId) {
|
|
108
|
+
if (!resultUrl)
|
|
109
|
+
throw new TypeError('A correlated problem names its result URL.');
|
|
110
|
+
if (!interactionId && code !== 'result-not-found')
|
|
111
|
+
throw new TypeError('A correlated problem names its interaction.');
|
|
112
|
+
if (code === 'stale-target' && !target)
|
|
113
|
+
throw new TypeError('A stale-target problem carries the current target.');
|
|
114
|
+
correlation = { instance: resultUrl, profile: MAP_PROFILE, requestId, interactionId, code };
|
|
115
|
+
}
|
|
116
|
+
return built(withinDocument(defined({
|
|
117
|
+
type: `${PROBLEM_TYPES}${code}`,
|
|
118
|
+
title: members.title,
|
|
119
|
+
status: PROBLEM_STATUS[code],
|
|
120
|
+
detail: members.detail,
|
|
121
|
+
...correlation,
|
|
122
|
+
target,
|
|
123
|
+
errors: members.errors,
|
|
124
|
+
})), problemErrors);
|
|
125
|
+
}
|
|
126
|
+
/** The HTTP status of a result: 202 while work is pending, otherwise 200. */
|
|
127
|
+
export const resultStatus = (document) => NON_TERMINAL.includes(document.state) ? 202 : 200;
|
|
128
|
+
/**
|
|
129
|
+
* Results stay retrievable until the later of the interaction's expiry and the retention
|
|
130
|
+
* interval measured from the latest recorded state.
|
|
131
|
+
*/
|
|
132
|
+
export const retainUntil = (description, recordedAt) => new Date(Math.max(Date.parse(description.expiresAt), recordedAt.getTime() + description.service.execution.resultRetentionSeconds * 1000));
|
|
133
|
+
/**
|
|
134
|
+
* An undecided approval ends as failed, with reason expired, at the interaction's expiry,
|
|
135
|
+
* whether or not anyone looks. The settled result, or undefined when nothing changes.
|
|
136
|
+
*/
|
|
137
|
+
export function settle(document, description, now) {
|
|
138
|
+
if (document.state !== 'approval-required' || !reached(now, description.expiresAt))
|
|
139
|
+
return;
|
|
140
|
+
return transition(document, {
|
|
141
|
+
state: 'failed',
|
|
142
|
+
reason: 'expired',
|
|
143
|
+
recordedAt: new Date(description.expiresAt),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { MAP_PROFILE, CORE_SCHEMA, FORMS_SCHEMA, CONTRACT_FORMAT, CONTEXT, mapErrors, descriptionErrors, requestErrors, resultErrors, problemErrors, isRequestId, reached, } from './artifacts.ts';
|
|
2
|
+
export { InvalidDocument, parse, canonicalize, digest } from './document.ts';
|
|
3
|
+
export { APPROVAL_REASONS, Contract, InvalidContract, type RequestProblem } from './contract.ts';
|
|
4
|
+
export { PROBLEM_STATUS, type ProblemCode, result, transition, problem, resultStatus, retainUntil, settle, } from './documents.ts';
|
|
5
|
+
export { DESCRIPTION_MEDIA_TYPE, isDescriptionPart, capability, writtenPath, isJsonRequest, resultUrl, } from './binding.ts';
|
|
6
|
+
export type { Authority, Consequence, ContractOperation, InputError, JsonObject, MapDescription, MapProblem, MapRequest, MapResult, ResultState, SchemaReference, Target, TypeContract, TypeReference, } from './types.ts';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Mail Action Protocol 0.2 processing. It parses, canonicalizes, digests and validates MAP
|
|
2
|
+
// documents, checks the type contracts an implementation vendors, and builds results and
|
|
3
|
+
// problems. It does not establish endpoint trust, verify email authentication, grant
|
|
4
|
+
// authority or send email.
|
|
5
|
+
export { MAP_PROFILE, CORE_SCHEMA, FORMS_SCHEMA, CONTRACT_FORMAT, CONTEXT, mapErrors, descriptionErrors, requestErrors, resultErrors, problemErrors, isRequestId, reached, } from "./artifacts.js";
|
|
6
|
+
export { InvalidDocument, parse, canonicalize, digest } from "./document.js";
|
|
7
|
+
export { APPROVAL_REASONS, Contract, InvalidContract } from "./contract.js";
|
|
8
|
+
export { PROBLEM_STATUS, result, transition, problem, resultStatus, retainUntil, settle, } from "./documents.js";
|
|
9
|
+
export { DESCRIPTION_MEDIA_TYPE, isDescriptionPart, capability, writtenPath, isJsonRequest, resultUrl, } from "./binding.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { InputError, JsonObject } from './types.ts';
|
|
2
|
+
/** The text, or its first limit - 1 code points and an ellipsis. */
|
|
3
|
+
export declare const cut: (text: string, limit: number) => string;
|
|
4
|
+
/**
|
|
5
|
+
* An input error within the core problem's limits. A pointer too long to report names its
|
|
6
|
+
* nearest ancestor that fits, with a detail that says so, and a detail too long is cut.
|
|
7
|
+
*/
|
|
8
|
+
export declare function inputError(detail: string, pointer: string): InputError;
|
|
9
|
+
/**
|
|
10
|
+
* A problem's title, detail and input errors within the core limits. An empty or overlong
|
|
11
|
+
* title, an empty detail or an empty error list is the caller's mistake.
|
|
12
|
+
*/
|
|
13
|
+
export declare function problemMembers(title: string, detail: string, errors?: InputError[]): {
|
|
14
|
+
title: string;
|
|
15
|
+
detail: string;
|
|
16
|
+
errors: InputError[] | undefined;
|
|
17
|
+
};
|
|
18
|
+
/** The problem with as many of its errors, in order, as fit within the document limit. */
|
|
19
|
+
export declare function withinDocument(problem: JsonObject): JsonObject;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// MAP documents within the core limits, with lengths counted in code points as JSON
|
|
2
|
+
// Schema counts them.
|
|
3
|
+
import { MAX_BYTES } from "./document.js";
|
|
4
|
+
import { codePoints, within } from "./pointer.js";
|
|
5
|
+
const TITLE = 240;
|
|
6
|
+
const DETAIL = 4000;
|
|
7
|
+
const POINTER = 1000;
|
|
8
|
+
const ERROR_DETAIL = 2000;
|
|
9
|
+
const ERRORS = 100;
|
|
10
|
+
/** The text, or its first limit - 1 code points and an ellipsis. */
|
|
11
|
+
export const cut = (text, limit) => codePoints(text) > limit ? `${[...text].slice(0, limit - 1).join('')}…` : text;
|
|
12
|
+
/**
|
|
13
|
+
* An input error within the core problem's limits. A pointer too long to report names its
|
|
14
|
+
* nearest ancestor that fits, with a detail that says so, and a detail too long is cut.
|
|
15
|
+
*/
|
|
16
|
+
export function inputError(detail, pointer) {
|
|
17
|
+
if (codePoints(pointer) > POINTER)
|
|
18
|
+
return {
|
|
19
|
+
detail: 'A member within this value does not satisfy the contract.',
|
|
20
|
+
pointer: within(pointer, POINTER),
|
|
21
|
+
};
|
|
22
|
+
return { detail: cut(detail, ERROR_DETAIL), pointer };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A problem's title, detail and input errors within the core limits. An empty or overlong
|
|
26
|
+
* title, an empty detail or an empty error list is the caller's mistake.
|
|
27
|
+
*/
|
|
28
|
+
export function problemMembers(title, detail, errors) {
|
|
29
|
+
if (codePoints(title) < 1 || codePoints(title) > TITLE)
|
|
30
|
+
throw new RangeError(`A problem title is 1 to ${TITLE} characters.`);
|
|
31
|
+
if (!detail)
|
|
32
|
+
throw new RangeError('A problem detail is not empty.');
|
|
33
|
+
if (errors) {
|
|
34
|
+
if (!errors.length)
|
|
35
|
+
throw new RangeError('Errors, when given, name at least one input error.');
|
|
36
|
+
if (errors.some((error) => !error.detail))
|
|
37
|
+
throw new RangeError('An input error has a detail.');
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
title,
|
|
41
|
+
detail: cut(detail, DETAIL),
|
|
42
|
+
errors: errors?.slice(0, ERRORS).map((error) => inputError(error.detail, error.pointer)),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** The problem with as many of its errors, in order, as fit within the document limit. */
|
|
46
|
+
export function withinDocument(problem) {
|
|
47
|
+
const errors = problem.errors;
|
|
48
|
+
if (!errors)
|
|
49
|
+
return problem;
|
|
50
|
+
const kept = [...errors];
|
|
51
|
+
const size = () => new TextEncoder().encode(JSON.stringify({ ...problem, errors: kept })).length;
|
|
52
|
+
while (kept.length > 1 && size() > MAX_BYTES)
|
|
53
|
+
kept.pop();
|
|
54
|
+
return { ...problem, errors: kept };
|
|
55
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const escape: (name: string) => string;
|
|
2
|
+
export declare const segments: (pointer: string) => string[];
|
|
3
|
+
export declare const codePoints: (text: string) => number;
|
|
4
|
+
/** The pointer, or its nearest ancestor no longer than limit code points. */
|
|
5
|
+
export declare function within(pointer: string, limit: number): string;
|
|
6
|
+
/** The value a pointer names through objects and arrays, or undefined. */
|
|
7
|
+
export declare function resolve(value: unknown, pointer: string): unknown;
|
|
8
|
+
/**
|
|
9
|
+
* The value a pointer names through object members only, or undefined. The contract
|
|
10
|
+
* rules bind form fields only through object properties, so no binding indexes an array.
|
|
11
|
+
*/
|
|
12
|
+
export declare function member(value: unknown, pointer: string): unknown;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// JSON Pointer (RFC 6901). Undefined stands for "nothing there", which no JSON value is.
|
|
2
|
+
const INDEX = /^(?:0|[1-9][0-9]*)$/;
|
|
3
|
+
export const escape = (name) => name.replaceAll('~', '~0').replaceAll('/', '~1');
|
|
4
|
+
export const segments = (pointer) => pointer
|
|
5
|
+
.split('/')
|
|
6
|
+
.slice(1)
|
|
7
|
+
.map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~'));
|
|
8
|
+
// Lengths as JSON Schema counts them: in code points, not UTF-16 code units.
|
|
9
|
+
export const codePoints = (text) => [...text].length;
|
|
10
|
+
/** The pointer, or its nearest ancestor no longer than limit code points. */
|
|
11
|
+
export function within(pointer, limit) {
|
|
12
|
+
const tokens = pointer.split('/');
|
|
13
|
+
while (codePoints(tokens.join('/')) > limit)
|
|
14
|
+
tokens.pop();
|
|
15
|
+
return tokens.join('/');
|
|
16
|
+
}
|
|
17
|
+
/** The value a pointer names through objects and arrays, or undefined. */
|
|
18
|
+
export function resolve(value, pointer) {
|
|
19
|
+
let current = value;
|
|
20
|
+
for (const name of segments(pointer)) {
|
|
21
|
+
if (Array.isArray(current)) {
|
|
22
|
+
if (!INDEX.test(name) || Number(name) >= current.length)
|
|
23
|
+
return undefined;
|
|
24
|
+
current = current[Number(name)];
|
|
25
|
+
}
|
|
26
|
+
else if (current && typeof current === 'object' && Object.hasOwn(current, name))
|
|
27
|
+
current = current[name];
|
|
28
|
+
else
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
return current;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The value a pointer names through object members only, or undefined. The contract
|
|
35
|
+
* rules bind form fields only through object properties, so no binding indexes an array.
|
|
36
|
+
*/
|
|
37
|
+
export function member(value, pointer) {
|
|
38
|
+
let current = value;
|
|
39
|
+
for (const name of segments(pointer)) {
|
|
40
|
+
if (!current ||
|
|
41
|
+
typeof current !== 'object' ||
|
|
42
|
+
Array.isArray(current) ||
|
|
43
|
+
!Object.hasOwn(current, name))
|
|
44
|
+
return undefined;
|
|
45
|
+
current = current[name];
|
|
46
|
+
}
|
|
47
|
+
return current;
|
|
48
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { JsonObject } from './types.ts';
|
|
2
|
+
/** Every schema object within a schema, the schema itself first. */
|
|
3
|
+
export declare function nodes(schema: unknown): Generator<JsonObject>;
|
|
4
|
+
/** Every value a keyword has in the schema objects within a schema. */
|
|
5
|
+
export declare const values: (schema: unknown, keyword: string) => unknown[];
|
|
6
|
+
/** Rules of a form fields block that JSON Schema cannot state. */
|
|
7
|
+
export declare function formProblems(fields: JsonObject): string[];
|
|
8
|
+
/**
|
|
9
|
+
* The schema the values of a fields block satisfy: only its fields, each with a text
|
|
10
|
+
* field's `format` read as the core lexical form of that name.
|
|
11
|
+
*/
|
|
12
|
+
export declare function fieldValuesSchema(fields: JsonObject): JsonObject;
|
|
13
|
+
/**
|
|
14
|
+
* Why a contract's schemas would resolve anything but a pinned schema, or undefined. No
|
|
15
|
+
* keyword resolves by scope, in the contract's schemas or the pinned ones it supplies. In
|
|
16
|
+
* the contract's own schemas, no $id or $schema appears but at the request schema's root,
|
|
17
|
+
* since it would move references or change the dialect a subschema is read in; no type is
|
|
18
|
+
* a list; autocomplete, the form fields block's annotation, is absent; and every
|
|
19
|
+
* reference is a string naming a schema in the pinned documents, by URL.
|
|
20
|
+
*/
|
|
21
|
+
export declare function referenceProblem(inline: unknown[], requestSchema: JsonObject, pinned: ReadonlyMap<string, JsonObject>, supplied: JsonObject[]): string | undefined;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The schema objects within a schema, the form fields block and the references a contract's
|
|
2
|
+
// schemas make.
|
|
3
|
+
import { CORE_SCHEMA } from "./artifacts.js";
|
|
4
|
+
import { resolve } from "./pointer.js";
|
|
5
|
+
// The keywords whose value is a schema, a list of schemas, or a map from names to schemas.
|
|
6
|
+
// A name in a map is never a keyword, whatever it is called.
|
|
7
|
+
const SUBSCHEMA = [
|
|
8
|
+
'additionalProperties',
|
|
9
|
+
'propertyNames',
|
|
10
|
+
'items',
|
|
11
|
+
'contains',
|
|
12
|
+
'not',
|
|
13
|
+
'if',
|
|
14
|
+
'then',
|
|
15
|
+
'else',
|
|
16
|
+
'unevaluatedItems',
|
|
17
|
+
'unevaluatedProperties',
|
|
18
|
+
'contentSchema',
|
|
19
|
+
];
|
|
20
|
+
const LISTS = ['allOf', 'anyOf', 'oneOf', 'prefixItems'];
|
|
21
|
+
const MAPS = ['properties', 'patternProperties', '$defs', 'dependentSchemas'];
|
|
22
|
+
/** Every schema object within a schema, the schema itself first. */
|
|
23
|
+
export function* nodes(schema) {
|
|
24
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema))
|
|
25
|
+
return;
|
|
26
|
+
const node = schema;
|
|
27
|
+
yield node;
|
|
28
|
+
for (const keyword of SUBSCHEMA)
|
|
29
|
+
yield* nodes(node[keyword]);
|
|
30
|
+
for (const keyword of LISTS)
|
|
31
|
+
if (Array.isArray(node[keyword]))
|
|
32
|
+
for (const item of node[keyword])
|
|
33
|
+
yield* nodes(item);
|
|
34
|
+
for (const keyword of MAPS) {
|
|
35
|
+
const map = node[keyword];
|
|
36
|
+
if (map && typeof map === 'object' && !Array.isArray(map))
|
|
37
|
+
for (const item of Object.values(map))
|
|
38
|
+
yield* nodes(item);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Every value a keyword has in the schema objects within a schema. */
|
|
42
|
+
export const values = (schema, keyword) => [...nodes(schema)].filter((node) => Object.hasOwn(node, keyword)).map((node) => node[keyword]);
|
|
43
|
+
// The core definitions that give a text field's `format` its lexical form.
|
|
44
|
+
const FORMATS = {
|
|
45
|
+
email: 'address',
|
|
46
|
+
uri: 'identifier',
|
|
47
|
+
date: 'date',
|
|
48
|
+
'date-time': 'dateTime',
|
|
49
|
+
};
|
|
50
|
+
/** Rules of a form fields block that JSON Schema cannot state. */
|
|
51
|
+
export function formProblems(fields) {
|
|
52
|
+
const properties = (fields.properties ?? {});
|
|
53
|
+
const problems = (fields.required ?? [])
|
|
54
|
+
.filter((name) => !Object.hasOwn(properties, name))
|
|
55
|
+
.map((name) => `required field ${name} is not defined`);
|
|
56
|
+
for (const [name, field] of Object.entries(properties)) {
|
|
57
|
+
const choices = (field.oneOf ?? field.items?.anyOf ?? []).map((choice) => choice.const);
|
|
58
|
+
if (new Set(choices).size !== choices.length)
|
|
59
|
+
problems.push(`field ${name} repeats a choice`);
|
|
60
|
+
const defaults = field.default === undefined ? [] : [field.default].flat();
|
|
61
|
+
if (choices.length && defaults.some((value) => !choices.includes(value)))
|
|
62
|
+
problems.push(`field ${name} defaults to a value it does not offer`);
|
|
63
|
+
}
|
|
64
|
+
return problems;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The schema the values of a fields block satisfy: only its fields, each with a text
|
|
68
|
+
* field's `format` read as the core lexical form of that name.
|
|
69
|
+
*/
|
|
70
|
+
export function fieldValuesSchema(fields) {
|
|
71
|
+
const properties = Object.fromEntries(Object.entries((fields.properties ?? {})).map(([name, field]) => {
|
|
72
|
+
if (typeof field.format !== 'string')
|
|
73
|
+
return [name, field];
|
|
74
|
+
const { format, ...rest } = field;
|
|
75
|
+
return [name, { ...rest, $ref: `${CORE_SCHEMA}#/$defs/${FORMATS[format]}` }];
|
|
76
|
+
}));
|
|
77
|
+
return { ...fields, properties, additionalProperties: false };
|
|
78
|
+
}
|
|
79
|
+
// A plain JSON Pointer fragment: nothing percent-encoded, nothing to decode.
|
|
80
|
+
const FRAGMENT = /^(?:\/[A-Za-z0-9._~!$&'()*+,;=:@-]*)*$/;
|
|
81
|
+
// Keywords that resolve by scope rather than by a pinned address.
|
|
82
|
+
const UNPINNED = ['$dynamicRef', '$recursiveRef', '$dynamicAnchor', '$recursiveAnchor', '$anchor'];
|
|
83
|
+
/**
|
|
84
|
+
* Why a contract's schemas would resolve anything but a pinned schema, or undefined. No
|
|
85
|
+
* keyword resolves by scope, in the contract's schemas or the pinned ones it supplies. In
|
|
86
|
+
* the contract's own schemas, no $id or $schema appears but at the request schema's root,
|
|
87
|
+
* since it would move references or change the dialect a subschema is read in; no type is
|
|
88
|
+
* a list; autocomplete, the form fields block's annotation, is absent; and every
|
|
89
|
+
* reference is a string naming a schema in the pinned documents, by URL.
|
|
90
|
+
*/
|
|
91
|
+
export function referenceProblem(inline, requestSchema, pinned, supplied) {
|
|
92
|
+
for (const schema of [...inline, requestSchema, ...supplied]) {
|
|
93
|
+
const found = UNPINNED.find((keyword) => values(schema, keyword).length);
|
|
94
|
+
if (found)
|
|
95
|
+
return `${found} is not pinned by any digest`;
|
|
96
|
+
}
|
|
97
|
+
for (const schema of [...inline, requestSchema]) {
|
|
98
|
+
const all = [...nodes(schema)];
|
|
99
|
+
const nested = schema === requestSchema ? all.slice(1) : all;
|
|
100
|
+
if (nested.some((node) => '$id' in node))
|
|
101
|
+
return 'a nested $id would move references';
|
|
102
|
+
if (nested.some((node) => '$schema' in node))
|
|
103
|
+
return 'a nested $schema would change the dialect';
|
|
104
|
+
if (all.some((node) => Array.isArray(node.type)))
|
|
105
|
+
return 'type names one type, never a list';
|
|
106
|
+
if (all.some((node) => 'autocomplete' in node))
|
|
107
|
+
return 'autocomplete belongs to the form fields block';
|
|
108
|
+
for (const ref of values(schema, '$ref')) {
|
|
109
|
+
if (typeof ref !== 'string')
|
|
110
|
+
return 'a $ref is not a string';
|
|
111
|
+
const [base, fragment = ''] = ref.split(/#(.*)/s);
|
|
112
|
+
const document = pinned.get(base);
|
|
113
|
+
if (!document)
|
|
114
|
+
return `${ref} is not a pinned dependency`;
|
|
115
|
+
if (fragment && !fragment.startsWith('/'))
|
|
116
|
+
return `${ref} names an anchor, not a JSON Pointer`;
|
|
117
|
+
if (!FRAGMENT.test(fragment))
|
|
118
|
+
return `${ref} is not a plain JSON Pointer`;
|
|
119
|
+
const target = resolve(document, fragment);
|
|
120
|
+
if (typeof target !== 'boolean' &&
|
|
121
|
+
!(target && typeof target === 'object' && !Array.isArray(target)))
|
|
122
|
+
return `${ref} does not name a schema`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** The documents MAP 0.2 defines and the type contracts that give them meaning. */
|
|
2
|
+
export type JsonObject = Record<string, unknown>;
|
|
3
|
+
export type Authority = 'credential' | 'possession';
|
|
4
|
+
export type ResultState = 'accepted' | 'completed' | 'failed' | 'pending' | 'approval-required';
|
|
5
|
+
export type Consequence = 'refusal' | 'protection' | 'record' | 'disclosure' | 'commitment' | 'authorization' | 'assertion';
|
|
6
|
+
export interface SchemaReference {
|
|
7
|
+
url: string;
|
|
8
|
+
canonicalDigest: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ContractOperation {
|
|
11
|
+
id: string;
|
|
12
|
+
effect: string;
|
|
13
|
+
authority: Authority[];
|
|
14
|
+
consequences: Consequence[];
|
|
15
|
+
repeatable?: boolean;
|
|
16
|
+
fieldBindings?: {
|
|
17
|
+
input: string;
|
|
18
|
+
fields: string;
|
|
19
|
+
}[];
|
|
20
|
+
results: {
|
|
21
|
+
state: ResultState;
|
|
22
|
+
reasons?: string[];
|
|
23
|
+
outputSchema: JsonObject;
|
|
24
|
+
}[];
|
|
25
|
+
}
|
|
26
|
+
export interface TypeContract {
|
|
27
|
+
kind: 'MapTypeContract';
|
|
28
|
+
id: string;
|
|
29
|
+
version: string;
|
|
30
|
+
profile: string;
|
|
31
|
+
target: string;
|
|
32
|
+
dependencies?: SchemaReference[];
|
|
33
|
+
detailsSchema?: JsonObject;
|
|
34
|
+
requestSchema: SchemaReference;
|
|
35
|
+
operations: ContractOperation[];
|
|
36
|
+
}
|
|
37
|
+
/** The exact type a description and its requests name. */
|
|
38
|
+
export type TypeReference = {
|
|
39
|
+
id: string;
|
|
40
|
+
version: string;
|
|
41
|
+
contractDigest: string;
|
|
42
|
+
};
|
|
43
|
+
export type Target = {
|
|
44
|
+
id: string;
|
|
45
|
+
revision: string;
|
|
46
|
+
digest: string;
|
|
47
|
+
title?: string;
|
|
48
|
+
};
|
|
49
|
+
export type MapDescription = {
|
|
50
|
+
'@context': string;
|
|
51
|
+
'@type': 'MailAction';
|
|
52
|
+
'@id': string;
|
|
53
|
+
profile: string;
|
|
54
|
+
type: TypeReference;
|
|
55
|
+
describedAt: string;
|
|
56
|
+
expiresAt: string;
|
|
57
|
+
service: {
|
|
58
|
+
id: string;
|
|
59
|
+
name: string;
|
|
60
|
+
authority: Authority;
|
|
61
|
+
resource?: string;
|
|
62
|
+
execution: {
|
|
63
|
+
url: string;
|
|
64
|
+
resultUrlTemplate: string;
|
|
65
|
+
resultRetentionSeconds: number;
|
|
66
|
+
};
|
|
67
|
+
onBehalfOf?: {
|
|
68
|
+
id: string;
|
|
69
|
+
name: string;
|
|
70
|
+
};
|
|
71
|
+
humanUrl: string;
|
|
72
|
+
};
|
|
73
|
+
recipient?: string;
|
|
74
|
+
target: Target;
|
|
75
|
+
details?: JsonObject;
|
|
76
|
+
operations: {
|
|
77
|
+
id: string;
|
|
78
|
+
name: string;
|
|
79
|
+
description: string;
|
|
80
|
+
}[];
|
|
81
|
+
};
|
|
82
|
+
export type MapRequest = {
|
|
83
|
+
kind: 'MapRequest';
|
|
84
|
+
profile: string;
|
|
85
|
+
requestId: string;
|
|
86
|
+
interactionId: string;
|
|
87
|
+
descriptionDigest: string;
|
|
88
|
+
type: TypeReference;
|
|
89
|
+
operation: string;
|
|
90
|
+
input: JsonObject;
|
|
91
|
+
};
|
|
92
|
+
export type MapResult = {
|
|
93
|
+
kind: 'MapResult';
|
|
94
|
+
profile: string;
|
|
95
|
+
requestId: string;
|
|
96
|
+
interactionId: string;
|
|
97
|
+
descriptionDigest: string;
|
|
98
|
+
type: TypeReference;
|
|
99
|
+
operation: string;
|
|
100
|
+
state: ResultState;
|
|
101
|
+
target: Target;
|
|
102
|
+
recordedAt: string;
|
|
103
|
+
resultUrl: string;
|
|
104
|
+
actor?: string;
|
|
105
|
+
approvalUrl?: string;
|
|
106
|
+
reason?: string;
|
|
107
|
+
output: JsonObject;
|
|
108
|
+
};
|
|
109
|
+
/** An RFC 9457 problem, with MAP's correlation members when it is correlated. */
|
|
110
|
+
export type MapProblem = {
|
|
111
|
+
type: string;
|
|
112
|
+
title: string;
|
|
113
|
+
status: number;
|
|
114
|
+
detail: string;
|
|
115
|
+
instance?: string;
|
|
116
|
+
profile?: string;
|
|
117
|
+
requestId?: string;
|
|
118
|
+
interactionId?: string;
|
|
119
|
+
code?: string;
|
|
120
|
+
target?: Target;
|
|
121
|
+
errors?: InputError[];
|
|
122
|
+
};
|
|
123
|
+
/** One way a request's input fails its contract, with a JSON Pointer into the input. */
|
|
124
|
+
export type InputError = {
|
|
125
|
+
detail: string;
|
|
126
|
+
pointer: string;
|
|
127
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
export * from './core/index.js';
|
|
1
2
|
export { contributionErrors, assertContribution, assertTypeRecord, referenceErrors, } from './validation.js';
|
|
2
3
|
export type { CatalogView } from './validation.js';
|
|
3
4
|
export { typeStages } from './model.js';
|
|
4
5
|
export type { Party, TypeDefinition, TypeRecord, Contribution, Implementation } from './model.js';
|
|
5
|
-
export {
|
|
6
|
+
export { getMapSchema, getMapContext, getContractFormatSchema, getFormsSchema } from './map.js';
|
|
6
7
|
/** Return an independent copy of the contribution JSON Schema (Draft 2020-12). */
|
|
7
8
|
export declare function getContributionSchema(): Record<string, unknown>;
|
|
8
9
|
/** Return the schema for an expanded Registry record, including attribution and history. */
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Mail Action Protocol 0.2 processing and core artifacts, and validation tools for MailSchema
|
|
3
|
+
* Registry contributions.
|
|
4
|
+
*/
|
|
2
5
|
import schema from './contribution.schema.json' with { type: 'json' };
|
|
6
|
+
export * from './core/index.js';
|
|
3
7
|
export { contributionErrors, assertContribution, assertTypeRecord, referenceErrors, } from './validation.js';
|
|
4
8
|
export { typeStages } from './model.js';
|
|
5
|
-
export {
|
|
9
|
+
export { getMapSchema, getMapContext, getContractFormatSchema, getFormsSchema } from './map.js';
|
|
6
10
|
/** Return an independent copy of the contribution JSON Schema (Draft 2020-12). */
|
|
7
11
|
export function getContributionSchema() {
|
|
8
12
|
return structuredClone(schema);
|
package/dist/map.d.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/** The MAP profile whose core artifacts this package carries. */
|
|
2
|
-
export declare const MAP_PROFILE = "https://mailschema.org/profiles/map/0.2";
|
|
3
1
|
/** A fresh copy of the MAP 0.2 core schema, as the profile record binds it by SHA-256. */
|
|
4
2
|
export declare function getMapSchema(): Record<string, unknown>;
|
|
5
3
|
/** A fresh copy of the MAP 0.2 JSON-LD context. */
|
package/dist/map.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
/** The MAP profile whose core artifacts this package carries. */
|
|
3
|
-
export const MAP_PROFILE = 'https://mailschema.org/profiles/map/0.2';
|
|
4
2
|
const artifact = (name) => JSON.parse(readFileSync(new URL(`./${name}`, import.meta.url), 'utf8'));
|
|
5
3
|
/** A fresh copy of the MAP 0.2 core schema, as the profile record binds it by SHA-256. */
|
|
6
4
|
export function getMapSchema() {
|
package/dist/validation.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Contribution, TypeRecord } from './model.
|
|
1
|
+
import type { Contribution, TypeRecord } from './model.ts';
|
|
2
2
|
export declare function contributionErrors(value: unknown): string[];
|
|
3
3
|
export declare function assertContribution(value: unknown): asserts value is Contribution;
|
|
4
4
|
export declare function assertTypeRecord(value: unknown): asserts value is TypeRecord;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mailschema",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Mail Action Protocol 0.2 core artifacts and MailSchema Registry validation",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Mail Action Protocol 0.2 processing and core artifacts, and MailSchema Registry validation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "MailSchema contributors",
|