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,327 @@
|
|
|
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 {
|
|
9
|
+
}
|
|
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;
|
|
23
|
+
}
|
|
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
|
+
});
|
|
34
|
+
}
|
|
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))}`;
|
|
39
|
+
}
|
|
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);
|
|
51
|
+
}
|
|
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
|
+
*/
|
|
59
|
+
export class Contract {
|
|
60
|
+
document;
|
|
61
|
+
digest;
|
|
62
|
+
requestSchema;
|
|
63
|
+
#ajv = createValidator();
|
|
64
|
+
#details;
|
|
65
|
+
#request;
|
|
66
|
+
#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);
|
|
75
|
+
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');
|
|
80
|
+
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
|
+
}
|
|
107
|
+
}
|
|
108
|
+
get id() {
|
|
109
|
+
return this.document.id;
|
|
110
|
+
}
|
|
111
|
+
get version() {
|
|
112
|
+
return this.document.version;
|
|
113
|
+
}
|
|
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
|
+
operation(id) {
|
|
119
|
+
return this.document.operations.find((operation) => operation.id === id);
|
|
120
|
+
}
|
|
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
|
+
];
|
|
245
|
+
}
|
|
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
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
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.
|
|
260
|
+
*/
|
|
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`);
|
|
322
|
+
}
|
|
323
|
+
const problem = referenceProblem(inline, this.requestSchema, references, supplied);
|
|
324
|
+
if (problem)
|
|
325
|
+
throw new InvalidContract(problem);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** A MAP document that is not I-JSON within the core limits. */
|
|
2
|
+
export declare class InvalidDocument extends Error {
|
|
3
|
+
}
|
|
4
|
+
export declare const MAX_BYTES: number;
|
|
5
|
+
export declare const MAX_DEPTH = 32;
|
|
6
|
+
/**
|
|
7
|
+
* Parse a MAP document as I-JSON (RFC 7493) within the core limits: valid UTF-8 with no
|
|
8
|
+
* byte order mark, at most 64 KiB, no duplicate member names, no lone surrogates,
|
|
9
|
+
* noncharacters or U+0000 in any string, every number within ±(2^53−1), and arrays and
|
|
10
|
+
* objects nested at most 32 deep, the outermost counting as one. Text is measured in its
|
|
11
|
+
* UTF-8 bytes; bytes must be strict UTF-8.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parse(input: string | Uint8Array): unknown;
|
|
14
|
+
/**
|
|
15
|
+
* The RFC 8785 canonical form of a JSON value: members sorted by their UTF-16 code units,
|
|
16
|
+
* ECMAScript's minimal string escapes and its number format. An undefined member is
|
|
17
|
+
* left out and an undefined array item is null, as JSON.stringify writes them.
|
|
18
|
+
*/
|
|
19
|
+
export declare function canonicalize(value: unknown): string;
|
|
20
|
+
/**
|
|
21
|
+
* `sha-256:` and the SHA-256 of the RFC 8785 canonical form: the digest MAP uses for
|
|
22
|
+
* descriptions, contracts and pinned schemas.
|
|
23
|
+
*/
|
|
24
|
+
export declare const digest: (value: unknown) => string;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Parsing MAP documents as I-JSON within the core limits.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
/** A MAP document that is not I-JSON within the core limits. */
|
|
4
|
+
export class InvalidDocument extends Error {
|
|
5
|
+
}
|
|
6
|
+
export const MAX_BYTES = 64 * 1024;
|
|
7
|
+
export const MAX_DEPTH = 32;
|
|
8
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
9
|
+
// Unicode's noncharacters: U+FDD0 to U+FDEF, and the last two code points of every plane.
|
|
10
|
+
const NONCHARACTER = new RegExp(`[\\u{FDD0}-\\u{FDEF}${Array.from({ length: 17 }, (_, plane) => `\\u{${(plane * 0x10000 + 0xfffe).toString(16)}}\\u{${(plane * 0x10000 + 0xffff).toString(16)}}`).join('')}]`, 'u');
|
|
11
|
+
/**
|
|
12
|
+
* Parse a MAP document as I-JSON (RFC 7493) within the core limits: valid UTF-8 with no
|
|
13
|
+
* byte order mark, at most 64 KiB, no duplicate member names, no lone surrogates,
|
|
14
|
+
* noncharacters or U+0000 in any string, every number within ±(2^53−1), and arrays and
|
|
15
|
+
* objects nested at most 32 deep, the outermost counting as one. Text is measured in its
|
|
16
|
+
* UTF-8 bytes; bytes must be strict UTF-8.
|
|
17
|
+
*/
|
|
18
|
+
export function parse(input) {
|
|
19
|
+
let text;
|
|
20
|
+
if (typeof input === 'string')
|
|
21
|
+
text = input;
|
|
22
|
+
else {
|
|
23
|
+
try {
|
|
24
|
+
// The byte order mark is kept, so the parser refuses it.
|
|
25
|
+
text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(input);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new InvalidDocument('The MAP document is not valid UTF-8.');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (new TextEncoder().encode(text).length > MAX_BYTES)
|
|
32
|
+
throw new InvalidDocument(`The MAP document exceeds ${MAX_BYTES} bytes.`);
|
|
33
|
+
let index = 0;
|
|
34
|
+
const fail = (message) => {
|
|
35
|
+
throw new InvalidDocument(`Invalid MAP JSON at offset ${index}: ${message}`);
|
|
36
|
+
};
|
|
37
|
+
const space = () => {
|
|
38
|
+
while (' \t\n\r'.includes(text[index] ?? '-'))
|
|
39
|
+
index += 1;
|
|
40
|
+
};
|
|
41
|
+
const string = () => {
|
|
42
|
+
const start = index;
|
|
43
|
+
index += 1;
|
|
44
|
+
while (index < text.length && text[index] !== '"')
|
|
45
|
+
index += text[index] === '\\' ? 2 : 1;
|
|
46
|
+
if (text[index] !== '"')
|
|
47
|
+
fail('unterminated string');
|
|
48
|
+
index += 1;
|
|
49
|
+
let value;
|
|
50
|
+
try {
|
|
51
|
+
value = JSON.parse(text.slice(start, index));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return fail('malformed string');
|
|
55
|
+
}
|
|
56
|
+
if (LONE_SURROGATE.test(value))
|
|
57
|
+
fail('lone surrogate');
|
|
58
|
+
if (value.includes('\u0000'))
|
|
59
|
+
fail('U+0000 in a string');
|
|
60
|
+
if (NONCHARACTER.test(value))
|
|
61
|
+
fail('a noncharacter in a string');
|
|
62
|
+
return value;
|
|
63
|
+
};
|
|
64
|
+
// `depth` counts the arrays and objects enclosing the value.
|
|
65
|
+
const value = (depth) => {
|
|
66
|
+
space();
|
|
67
|
+
const next = text[index];
|
|
68
|
+
if ((next === '{' || next === '[') && depth >= MAX_DEPTH)
|
|
69
|
+
fail(`nesting deeper than ${MAX_DEPTH}`);
|
|
70
|
+
if (next === '{') {
|
|
71
|
+
index += 1;
|
|
72
|
+
const result = {};
|
|
73
|
+
const names = new Set();
|
|
74
|
+
space();
|
|
75
|
+
if (text[index] === '}') {
|
|
76
|
+
index += 1;
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
for (;;) {
|
|
80
|
+
space();
|
|
81
|
+
if (text[index] !== '"')
|
|
82
|
+
fail('expected a member name');
|
|
83
|
+
const name = string();
|
|
84
|
+
if (names.has(name))
|
|
85
|
+
fail(`duplicate member ${JSON.stringify(name)}`);
|
|
86
|
+
names.add(name);
|
|
87
|
+
space();
|
|
88
|
+
if (text[index] !== ':')
|
|
89
|
+
fail('expected ":"');
|
|
90
|
+
index += 1;
|
|
91
|
+
// A member named __proto__ is data, never the object's prototype.
|
|
92
|
+
Object.defineProperty(result, name, {
|
|
93
|
+
value: value(depth + 1),
|
|
94
|
+
enumerable: true,
|
|
95
|
+
writable: true,
|
|
96
|
+
configurable: true,
|
|
97
|
+
});
|
|
98
|
+
space();
|
|
99
|
+
if (text[index] === ',') {
|
|
100
|
+
index += 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (text[index] === '}') {
|
|
104
|
+
index += 1;
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
fail('expected "," or "}"');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (next === '[') {
|
|
111
|
+
index += 1;
|
|
112
|
+
const result = [];
|
|
113
|
+
space();
|
|
114
|
+
if (text[index] === ']') {
|
|
115
|
+
index += 1;
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
for (;;) {
|
|
119
|
+
result.push(value(depth + 1));
|
|
120
|
+
space();
|
|
121
|
+
if (text[index] === ',') {
|
|
122
|
+
index += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (text[index] === ']') {
|
|
126
|
+
index += 1;
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
fail('expected "," or "]"');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (next === '"')
|
|
133
|
+
return string();
|
|
134
|
+
const literal = /^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/.exec(text.slice(index));
|
|
135
|
+
if (!literal)
|
|
136
|
+
fail('unexpected token');
|
|
137
|
+
index += literal[0].length;
|
|
138
|
+
const parsed = JSON.parse(literal[0]);
|
|
139
|
+
if (typeof parsed === 'number' &&
|
|
140
|
+
(!Number.isFinite(parsed) || Math.abs(parsed) > Number.MAX_SAFE_INTEGER))
|
|
141
|
+
fail('number outside the I-JSON range');
|
|
142
|
+
return parsed;
|
|
143
|
+
};
|
|
144
|
+
const result = value(0);
|
|
145
|
+
space();
|
|
146
|
+
if (index !== text.length)
|
|
147
|
+
fail('trailing content');
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
const ESCAPES = {
|
|
151
|
+
'"': '\\"',
|
|
152
|
+
'\\': '\\\\',
|
|
153
|
+
'\b': '\\b',
|
|
154
|
+
'\f': '\\f',
|
|
155
|
+
'\n': '\\n',
|
|
156
|
+
'\r': '\\r',
|
|
157
|
+
'\t': '\\t',
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* The RFC 8785 canonical form of a JSON value: members sorted by their UTF-16 code units,
|
|
161
|
+
* ECMAScript's minimal string escapes and its number format. An undefined member is
|
|
162
|
+
* left out and an undefined array item is null, as JSON.stringify writes them.
|
|
163
|
+
*/
|
|
164
|
+
export function canonicalize(value) {
|
|
165
|
+
if (value === null || typeof value === 'boolean')
|
|
166
|
+
return String(value);
|
|
167
|
+
if (typeof value === 'number') {
|
|
168
|
+
if (!Number.isFinite(value))
|
|
169
|
+
throw new TypeError('Numbers must be finite.');
|
|
170
|
+
return String(value);
|
|
171
|
+
}
|
|
172
|
+
if (typeof value === 'string')
|
|
173
|
+
return `"${value.replace(/["\\\u0000-\u001f]/g, (char) => ESCAPES[char] ?? `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)}"`;
|
|
174
|
+
if (Array.isArray(value))
|
|
175
|
+
return `[${value.map((item) => (item === undefined ? 'null' : canonicalize(item))).join(',')}]`;
|
|
176
|
+
if (typeof value === 'object') {
|
|
177
|
+
const members = value;
|
|
178
|
+
return `{${Object.keys(members)
|
|
179
|
+
.filter((name) => members[name] !== undefined)
|
|
180
|
+
.sort()
|
|
181
|
+
.map((name) => `${canonicalize(name)}:${canonicalize(members[name])}`)
|
|
182
|
+
.join(',')}}`;
|
|
183
|
+
}
|
|
184
|
+
throw new TypeError(`${typeof value} is not a JSON value.`);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* `sha-256:` and the SHA-256 of the RFC 8785 canonical form: the digest MAP uses for
|
|
188
|
+
* descriptions, contracts and pinned schemas.
|
|
189
|
+
*/
|
|
190
|
+
export const digest = (value) => `sha-256:${createHash('sha256').update(canonicalize(value)).digest('hex')}`;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { InputError, JsonObject, MapDescription, MapProblem, MapRequest, MapResult, ResultState, Target } from './types.ts';
|
|
2
|
+
/** The HTTP status of every MAP problem code, as the core schema fixes it. */
|
|
3
|
+
export declare const PROBLEM_STATUS: {
|
|
4
|
+
readonly 'invalid-request': 400;
|
|
5
|
+
readonly 'authentication-required': 401;
|
|
6
|
+
readonly refused: 403;
|
|
7
|
+
readonly 'result-not-found': 404;
|
|
8
|
+
readonly 'stale-target': 409;
|
|
9
|
+
readonly 'idempotency-conflict': 409;
|
|
10
|
+
readonly 'request-in-progress': 409;
|
|
11
|
+
readonly 'already-decided': 409;
|
|
12
|
+
readonly 'expired-interaction': 410;
|
|
13
|
+
readonly 'unsupported-type': 422;
|
|
14
|
+
readonly 'unsupported-operation': 422;
|
|
15
|
+
};
|
|
16
|
+
export type ProblemCode = keyof typeof PROBLEM_STATUS;
|
|
17
|
+
/**
|
|
18
|
+
* A result for a request, with every correlation member. `approvalUrl` is given exactly for
|
|
19
|
+
* approval-required and `reason` exactly for failed. Like every builder here, it throws
|
|
20
|
+
* rather than return a document the core refuses.
|
|
21
|
+
*/
|
|
22
|
+
export declare function result(request: MapRequest, { state, target, resultUrl, recordedAt, output, reason, approvalUrl, actor, }: {
|
|
23
|
+
state: ResultState;
|
|
24
|
+
target: Target;
|
|
25
|
+
resultUrl: string;
|
|
26
|
+
recordedAt: Date;
|
|
27
|
+
output?: JsonObject;
|
|
28
|
+
reason?: string;
|
|
29
|
+
approvalUrl?: string;
|
|
30
|
+
actor?: string;
|
|
31
|
+
}): MapResult;
|
|
32
|
+
/**
|
|
33
|
+
* The next state of a pending or approval-required result. Every correlation member and the
|
|
34
|
+
* actor stay; the state, reason, output and recording time change.
|
|
35
|
+
*/
|
|
36
|
+
export declare function transition(previous: MapResult, { state, recordedAt, reason, output, }: {
|
|
37
|
+
state: ResultState;
|
|
38
|
+
recordedAt: Date;
|
|
39
|
+
reason?: string;
|
|
40
|
+
output?: JsonObject;
|
|
41
|
+
}): MapResult;
|
|
42
|
+
/**
|
|
43
|
+
* A problem. With `requestId` it is correlated and carries `instance`, `profile`,
|
|
44
|
+
* `requestId`, `interactionId` and `code`; without, it is plain RFC 9457. It stays within
|
|
45
|
+
* the core limits: a long detail is cut, each input error is bounded, and only as many of
|
|
46
|
+
* the first 100 errors as fit within 64 KiB are kept.
|
|
47
|
+
*/
|
|
48
|
+
export declare function problem(code: ProblemCode, { title, detail, requestId, interactionId, resultUrl, target, errors, }: {
|
|
49
|
+
title: string;
|
|
50
|
+
detail: string;
|
|
51
|
+
requestId?: string;
|
|
52
|
+
interactionId?: string;
|
|
53
|
+
resultUrl?: string;
|
|
54
|
+
target?: Target;
|
|
55
|
+
errors?: InputError[];
|
|
56
|
+
}): MapProblem;
|
|
57
|
+
/** The HTTP status of a result: 202 while work is pending, otherwise 200. */
|
|
58
|
+
export declare const resultStatus: (document: MapResult) => 200 | 202;
|
|
59
|
+
/**
|
|
60
|
+
* Results stay retrievable until the later of the interaction's expiry and the retention
|
|
61
|
+
* interval measured from the latest recorded state.
|
|
62
|
+
*/
|
|
63
|
+
export declare const retainUntil: (description: MapDescription, recordedAt: Date) => Date;
|
|
64
|
+
/**
|
|
65
|
+
* An undecided approval ends as failed, with reason expired, at the interaction's expiry,
|
|
66
|
+
* whether or not anyone looks. The settled result, or undefined when nothing changes.
|
|
67
|
+
*/
|
|
68
|
+
export declare function settle(document: MapResult, description: MapDescription, now: Date): MapResult | undefined;
|