mailschema 0.2.1 → 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.
@@ -0,0 +1,253 @@
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;
48
+ }
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);
62
+ }
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);
95
+ }
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;
138
+ }
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;
167
+ }
168
+ /** A valid contract with its digest and compiled schemas. */
169
+ export class Contract {
170
+ document;
171
+ /** `sha-256:` and the SHA-256 of the contract's RFC 8785 form. */
172
+ digest;
173
+ #details;
174
+ #inputs = new Map();
175
+ /** The contract, or InvalidDocument with every reason the value is not one. */
176
+ constructor(value) {
177
+ const errors = contractErrors(value);
178
+ if (errors.length)
179
+ throw new InvalidDocument('The value is not a MAP 0.3 type contract.', errors);
180
+ this.document = structuredClone(value);
181
+ this.digest = digest(this.document);
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));
191
+ }
192
+ get id() {
193
+ return this.document.id;
194
+ }
195
+ get version() {
196
+ return this.document.version;
197
+ }
198
+ operation(id) {
199
+ return this.document.operations.find((operation) => operation.id === id);
200
+ }
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');
204
+ }
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');
214
+ }
215
+ /**
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.
220
+ */
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`);
250
+ }
251
+ return [...errors, ...this.detailsErrors(description.details)];
252
+ }
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
+ }
@@ -0,0 +1,32 @@
1
+ export interface ImplementationRecord {
2
+ service: string;
3
+ maintainer: {
4
+ name: string;
5
+ url: string;
6
+ };
7
+ type: {
8
+ id: string;
9
+ version: string;
10
+ contractDigest: string;
11
+ };
12
+ operations: string[];
13
+ binding: string;
14
+ artifact?: {
15
+ url: string;
16
+ digest: string;
17
+ digestMode: 'canonical-json' | 'bytes';
18
+ };
19
+ format?: string;
20
+ status: 'Draft' | 'Experimental' | 'Stable' | 'Deprecated' | 'Superseded';
21
+ documentation: string;
22
+ evidence: {
23
+ kind: 'declaration' | 'test-report';
24
+ url: string;
25
+ summary: string;
26
+ }[];
27
+ successor?: string;
28
+ }
29
+ /** Why the value is not an implementation record, or no reasons. */
30
+ export declare function implementationErrors(value: unknown): string[];
31
+ /** The record in the bytes or text, or InvalidDocument with every reason it is not one. */
32
+ export declare function parseImplementation(input: string | Uint8Array): ImplementationRecord;
@@ -0,0 +1,25 @@
1
+ // An implementation record: a Registry declaration or report of a service's support for exact
2
+ // contract versions.
3
+ import { CONTRACT_MAX_BYTES, createValidator, implementationSchema, schemaErrors, } from "./artifacts.js";
4
+ import { InvalidDocument, parse } from "./json.js";
5
+ const validate = createValidator().compile(implementationSchema);
6
+ /** Why the value is not an implementation record, or no reasons. */
7
+ export function implementationErrors(value) {
8
+ if (!validate(value))
9
+ return schemaErrors(validate.errors);
10
+ const errors = [];
11
+ const { origin } = new URL(value.service);
12
+ if (value.service !== origin && value.service !== `${origin}/`)
13
+ errors.push('/service: must be an HTTPS origin, not an operation path');
14
+ if (new Set(value.operations).size !== value.operations.length)
15
+ errors.push('/operations: identifiers must be unique');
16
+ return errors;
17
+ }
18
+ /** The record in the bytes or text, or InvalidDocument with every reason it is not one. */
19
+ export function parseImplementation(input) {
20
+ const value = parse(input, CONTRACT_MAX_BYTES);
21
+ const errors = implementationErrors(value);
22
+ if (errors.length)
23
+ throw new InvalidDocument('The value is not an implementation record.', errors);
24
+ return value;
25
+ }
@@ -0,0 +1,7 @@
1
+ export { PROFILE, CONTEXT, DESCRIPTION_MEDIA_TYPE, DESCRIPTION_MAX_BYTES, CONTRACT_MAX_BYTES, EFFECTS, FORMATS, coreSchema, contractSchema, implementationSchema, } from './artifacts.ts';
2
+ export { InvalidDocument, MAX_DEPTH, decodeUtf8, parse, canonicalize, digest } from './json.ts';
3
+ export { sha256 } from './sha256.ts';
4
+ export { descriptionErrors, parseDescription, type Description } from './description.ts';
5
+ export { CAPABILITY_KINDS, Contract, contractErrors, type CapabilityKind, type ContractDocument, type Operation, } from './contract.ts';
6
+ export { implementationErrors, parseImplementation, type ImplementationRecord, } from './implementation.ts';
7
+ export { unportablePattern } from './patterns.ts';
@@ -0,0 +1,9 @@
1
+ // Mail Action Protocol 0.3 processing: one implementation, run unchanged by the repository's
2
+ // site and checks and shipped as the npm package.
3
+ export { PROFILE, CONTEXT, DESCRIPTION_MEDIA_TYPE, DESCRIPTION_MAX_BYTES, CONTRACT_MAX_BYTES, EFFECTS, FORMATS, coreSchema, contractSchema, implementationSchema, } from "./artifacts.js";
4
+ export { InvalidDocument, MAX_DEPTH, decodeUtf8, parse, canonicalize, digest } from "./json.js";
5
+ export { sha256 } from "./sha256.js";
6
+ export { descriptionErrors, parseDescription } from "./description.js";
7
+ export { CAPABILITY_KINDS, Contract, contractErrors, } from "./contract.js";
8
+ export { implementationErrors, parseImplementation, } from "./implementation.js";
9
+ export { unportablePattern } from "./patterns.js";
@@ -0,0 +1,23 @@
1
+ /** A document that is not MAP JSON, or not the document it claims to be. */
2
+ export declare class InvalidDocument extends Error {
3
+ readonly errors: string[];
4
+ constructor(message: string, errors?: string[]);
5
+ }
6
+ export declare const MAX_DEPTH = 32;
7
+ /** The bytes as text, refusing anything but well-formed UTF-8. */
8
+ export declare function decodeUtf8(input: Uint8Array): string;
9
+ /**
10
+ * Parse MAP JSON from its original text: UTF-8 I-JSON (RFC 7493) of at most `maxBytes`, with
11
+ * no byte order mark, duplicate member names, lone surrogates, noncharacters or U+0000, no
12
+ * container deeper than 32 counting the root as 1, and every number a finite token of at most
13
+ * 64 characters within ±(2^53−1) that does not underflow to zero. These rules apply to the
14
+ * original tokens, before a general parser could erase them.
15
+ */
16
+ export declare function parse(input: string | Uint8Array, maxBytes: number): unknown;
17
+ /**
18
+ * The RFC 8785 canonical form of a JSON value: members sorted by their UTF-16 code units,
19
+ * ECMAScript's minimal string escapes and its number format.
20
+ */
21
+ export declare function canonicalize(value: unknown): string;
22
+ /** `sha-256:` and the SHA-256 of the value's RFC 8785 form, as MAP identifies contracts. */
23
+ export declare const digest: (value: unknown) => string;
@@ -0,0 +1,200 @@
1
+ // MAP JSON: I-JSON parsed from its original text, and the RFC 8785 digests that identify
2
+ // contracts and descriptions.
3
+ import { sha256 } from "./sha256.js";
4
+ /** A document that is not MAP JSON, or not the document it claims to be. */
5
+ export class InvalidDocument extends Error {
6
+ errors;
7
+ constructor(message, errors = [message]) {
8
+ super(message);
9
+ this.errors = errors;
10
+ }
11
+ }
12
+ export const MAX_DEPTH = 32;
13
+ const MAX_NUMBER_TOKEN = 64;
14
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
15
+ const NUMBER = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/;
16
+ const ZERO = /^-?0(?:\.0+)?(?:[eE][+-]?[0-9]+)?$/;
17
+ const forbidden = (point) => point === 0 || (point >= 0xfdd0 && point <= 0xfdef) || (point & 0xfffe) === 0xfffe;
18
+ /** The bytes as text, refusing anything but well-formed UTF-8. */
19
+ export function decodeUtf8(input) {
20
+ try {
21
+ // The byte order mark is kept, so parsing refuses it.
22
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(input);
23
+ }
24
+ catch {
25
+ throw new InvalidDocument('The JSON is not valid UTF-8.');
26
+ }
27
+ }
28
+ /**
29
+ * Parse MAP JSON from its original text: UTF-8 I-JSON (RFC 7493) of at most `maxBytes`, with
30
+ * no byte order mark, duplicate member names, lone surrogates, noncharacters or U+0000, no
31
+ * container deeper than 32 counting the root as 1, and every number a finite token of at most
32
+ * 64 characters within ±(2^53−1) that does not underflow to zero. These rules apply to the
33
+ * original tokens, before a general parser could erase them.
34
+ */
35
+ export function parse(input, maxBytes) {
36
+ const text = typeof input === 'string' ? input : decodeUtf8(input);
37
+ if (text.charCodeAt(0) === 0xfeff)
38
+ throw new InvalidDocument('A byte order mark is not allowed.');
39
+ if (new TextEncoder().encode(text).length > maxBytes)
40
+ throw new InvalidDocument(`The JSON exceeds ${maxBytes} bytes.`);
41
+ let index = 0;
42
+ const fail = (reason) => {
43
+ throw new InvalidDocument(`Invalid MAP JSON at offset ${index}: ${reason}.`);
44
+ };
45
+ const space = () => {
46
+ while (' \t\n\r'.includes(text[index] ?? '-'))
47
+ index += 1;
48
+ };
49
+ const string = () => {
50
+ const start = index;
51
+ index += 1;
52
+ while (index < text.length && text[index] !== '"')
53
+ index += text[index] === '\\' ? 2 : 1;
54
+ if (text[index] !== '"')
55
+ fail('unterminated string');
56
+ index += 1;
57
+ let value;
58
+ try {
59
+ value = JSON.parse(text.slice(start, index));
60
+ }
61
+ catch {
62
+ return fail('malformed string');
63
+ }
64
+ if (LONE_SURROGATE.test(value))
65
+ fail('lone surrogate');
66
+ for (const character of value)
67
+ if (forbidden(character.codePointAt(0)))
68
+ fail('U+0000 or a noncharacter');
69
+ return value;
70
+ };
71
+ // `depth` counts the containers enclosing the value.
72
+ const value = (depth) => {
73
+ space();
74
+ const next = text[index];
75
+ if ((next === '{' || next === '[') && depth >= MAX_DEPTH)
76
+ fail(`nesting deeper than ${MAX_DEPTH}`);
77
+ if (next === '{') {
78
+ index += 1;
79
+ const result = {};
80
+ const names = new Set();
81
+ space();
82
+ if (text[index] === '}') {
83
+ index += 1;
84
+ return result;
85
+ }
86
+ for (;;) {
87
+ space();
88
+ if (text[index] !== '"')
89
+ fail('expected a member name');
90
+ const name = string();
91
+ if (names.has(name))
92
+ fail(`duplicate member ${JSON.stringify(name)}`);
93
+ names.add(name);
94
+ space();
95
+ if (text[index] !== ':')
96
+ fail('expected ":"');
97
+ index += 1;
98
+ // A member named __proto__ is data, never the object's prototype.
99
+ Object.defineProperty(result, name, {
100
+ value: value(depth + 1),
101
+ enumerable: true,
102
+ writable: true,
103
+ configurable: true,
104
+ });
105
+ space();
106
+ if (text[index] === ',') {
107
+ index += 1;
108
+ continue;
109
+ }
110
+ if (text[index] === '}') {
111
+ index += 1;
112
+ return result;
113
+ }
114
+ fail('expected "," or "}"');
115
+ }
116
+ }
117
+ if (next === '[') {
118
+ index += 1;
119
+ const result = [];
120
+ space();
121
+ if (text[index] === ']') {
122
+ index += 1;
123
+ return result;
124
+ }
125
+ for (;;) {
126
+ result.push(value(depth + 1));
127
+ space();
128
+ if (text[index] === ',') {
129
+ index += 1;
130
+ continue;
131
+ }
132
+ if (text[index] === ']') {
133
+ index += 1;
134
+ return result;
135
+ }
136
+ fail('expected "," or "]"');
137
+ }
138
+ }
139
+ if (next === '"')
140
+ return string();
141
+ for (const literal of ['true', 'false', 'null'])
142
+ if (text.startsWith(literal, index)) {
143
+ index += literal.length;
144
+ return JSON.parse(literal);
145
+ }
146
+ const token = NUMBER.exec(text.slice(index))?.[0];
147
+ if (token === undefined)
148
+ return fail('unexpected token');
149
+ index += token.length;
150
+ if (token.length > MAX_NUMBER_TOKEN)
151
+ fail(`a number token longer than ${MAX_NUMBER_TOKEN} characters`);
152
+ const number = Number(token);
153
+ if (!Number.isFinite(number) || Math.abs(number) > Number.MAX_SAFE_INTEGER)
154
+ fail('a number outside the I-JSON range');
155
+ if (number === 0 && !ZERO.test(token))
156
+ fail('a number that underflows to zero');
157
+ return number;
158
+ };
159
+ const result = value(0);
160
+ space();
161
+ if (index !== text.length)
162
+ fail('trailing content');
163
+ return result;
164
+ }
165
+ const ESCAPES = {
166
+ '"': '\\"',
167
+ '\\': '\\\\',
168
+ '\b': '\\b',
169
+ '\f': '\\f',
170
+ '\n': '\\n',
171
+ '\r': '\\r',
172
+ '\t': '\\t',
173
+ };
174
+ /**
175
+ * The RFC 8785 canonical form of a JSON value: members sorted by their UTF-16 code units,
176
+ * ECMAScript's minimal string escapes and its number format.
177
+ */
178
+ export function canonicalize(value) {
179
+ if (value === null || typeof value === 'boolean')
180
+ return String(value);
181
+ if (typeof value === 'number') {
182
+ if (!Number.isFinite(value))
183
+ throw new TypeError('Numbers must be finite.');
184
+ return String(value);
185
+ }
186
+ if (typeof value === 'string')
187
+ return `"${value.replace(/["\\\u0000-\u001f]/g, (char) => ESCAPES[char] ?? `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)}"`;
188
+ if (Array.isArray(value))
189
+ return `[${value.map(canonicalize).join(',')}]`;
190
+ if (typeof value === 'object') {
191
+ const members = value;
192
+ return `{${Object.keys(members)
193
+ .sort()
194
+ .map((name) => `${canonicalize(name)}:${canonicalize(members[name])}`)
195
+ .join(',')}}`;
196
+ }
197
+ throw new TypeError(`${typeof value} is not a JSON value.`);
198
+ }
199
+ /** `sha-256:` and the SHA-256 of the value's RFC 8785 form, as MAP identifies contracts. */
200
+ export const digest = (value) => `sha-256:${sha256(canonicalize(value))}`;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Why a pattern is outside the portable subset, or undefined. The subset is printable ASCII
3
+ * text, with any other code point written as \uXXXX. It has literal characters and escaped
4
+ * syntax characters; \t, \n, \f, \r and \d, the ASCII digits; non-empty classes of literal or
5
+ * escaped members and ranges, with a literal hyphen only first or last; (?: groups;
6
+ * alternation; ^ and $; and one *, +, ?, {n}, {n,} or {n,m} after an atom. Other class
7
+ * escapes and the dot match different characters in different engines, and brackets, && or a
8
+ * stray brace inside a class, or a quantifier on a quantifier, are read differently or refused
9
+ * by one of them. ^ and $ anchor the whole value, as in ECMA-262; an engine whose anchors also
10
+ * match at line breaks must read them so.
11
+ */
12
+ export declare function unportablePattern(pattern: string): string | undefined;