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.
package/README.md CHANGED
@@ -1,54 +1,44 @@
1
- # MailSchema for JavaScript
1
+ # mailschema
2
2
 
3
- [![npm](https://img.shields.io/npm/v/mailschema)](https://www.npmjs.com/package/mailschema)
4
- [![CI](https://github.com/mailschema/javascript/actions/workflows/test.yml/badge.svg)](https://github.com/mailschema/javascript/actions/workflows/test.yml)
5
-
6
- The Mail Action Protocol 0.2 core artifacts, and local validation of MailSchema Registry contributions, with no network requests.
7
-
8
- [Specification](https://mailschema.org/specification) · [Registry](https://mailschema.org/registry) · [Tools](https://mailschema.org/tools) · [Source](https://github.com/mailschema/javascript)
9
-
10
- ## Install
3
+ [Mail Action Protocol](https://mailschema.org) 0.3 for JavaScript. It parses and checks MAP descriptions, type contracts and Registry implementation records, and computes the RFC 8785 digests that identify contracts. The same code runs the MailSchema specification's own checks. It has no Node.js-only dependencies, so it also runs in browsers and workers.
11
4
 
12
5
  ```sh
13
- npm install mailschema
6
+ npm install mailschema@0.3.0
14
7
  ```
15
8
 
16
- Node.js 22 or newer is required. The package is ESM and includes TypeScript declarations.
17
-
18
- ## MAP 0.2 core artifacts
9
+ ## Check a description against its contract
19
10
 
20
11
  ```js
21
- import {
22
- MAP_PROFILE,
23
- getContractFormatSchema,
24
- getFormsSchema,
25
- getMapContext,
26
- getMapSchema,
27
- } from 'mailschema';
28
- ```
12
+ import { readFile } from 'node:fs/promises';
13
+ import { Contract, parseDescription } from 'mailschema';
29
14
 
30
- Each is byte-identical to the file the [profile record](https://mailschema.org/profiles/map/0.2.json) binds by SHA-256, and is also exported raw as `mailschema/map-0.2.schema.json`, `mailschema/map-0.2.jsonld`, `mailschema/type-contract-0.2.schema.json` and `mailschema/forms-0.1.schema.json`. Type contracts are not bundled: a client obtains them from the [Registry catalogue](https://mailschema.org/registry/catalog.json) by digest. Processing MAP messages is outside this package; see the [profile](https://mailschema.org/specification/profile).
15
+ const contract = Contract.parse(await readFile('campaign-send-approval-0.1.json'));
16
+ const description = parseDescription(await readFile('description.json'));
17
+ const errors = contract.descriptionErrors(description);
18
+ if (errors.length) throw new Error(errors.join('\n'));
19
+ ```
31
20
 
32
- ## Registry data
21
+ `parseDescription` reads the bytes as MAP JSON (strict I-JSON within 64 KiB) and checks the core rules, or throws `InvalidDocument` with every reason. Obtain contracts from a catalogue you configure and pin them by digest; a message never chooses one.
33
22
 
34
- ```js
35
- import { assertContribution, contributionErrors, referenceErrors } from 'mailschema';
23
+ ## API
36
24
 
37
- const errors = contributionErrors(candidate);
38
- if (errors.length) console.error(errors);
39
- else assertContribution(candidate);
25
+ - `parse(input, maxBytes)`, `canonicalize(value)`, `digest(value)`: MAP JSON and RFC 8785 digests.
26
+ - `descriptionErrors(value)`, `parseDescription(input)`: the core description rules.
27
+ - `new Contract(value)`, `Contract.parse(input)`: a checked contract with `digest`, `operation(id)`, `detailsErrors(details)`, `inputErrors(operationId, input)` and `descriptionErrors(description)`.
28
+ - `contractErrors(value)`: why a value is not a contract, including schema keywords, formats and patterns every implementation can enforce alike.
29
+ - `implementationErrors(value)`, `parseImplementation(input)`: Registry implementation records.
30
+ - `PROFILE`, `CONTEXT`, `DESCRIPTION_MEDIA_TYPE`, `EFFECTS`, `CAPABILITY_KINDS` and the size limits.
40
31
 
41
- const referenceProblems = referenceErrors(candidate, catalog);
42
- ```
32
+ The profile record, context and schemas are exported byte for byte: `mailschema/profile.json`, `mailschema/context.jsonld`, `mailschema/core.schema.json`, `mailschema/contract.schema.json` and `mailschema/implementation.schema.json`.
43
33
 
44
- `assertTypeRecord` and `getRecordSchema` handle expanded Registry records. Reference checks use a catalogue supplied by the caller; the package never fetches one.
34
+ ## Command line
45
35
 
46
36
  ```sh
47
- npx mailschema check contribution.json
48
- npx mailschema check record.json --record
49
- npx mailschema schema --map
37
+ npx mailschema@0.3.0 contract campaign-send-approval-0.1.json
38
+ npx mailschema@0.3.0 description description.json --contract campaign-send-approval-0.1.json
39
+ npx mailschema@0.3.0 implementation record.json
50
40
  ```
51
41
 
52
- ## Trust boundary
42
+ Checking does not authenticate a message, establish a service's authority or perform an action. Those belong to the implementation, under the [specification](https://mailschema.org/specification/core).
53
43
 
54
- A valid document is structured input. Validation does not authenticate a service, grant authority or establish product conformance. The CLI reads one local file of at most 256 KiB and does not upload or modify it. Package versions and MAP profile versions advance independently. MIT licensed.
44
+ Source: [mailschema/javascript](https://github.com/mailschema/javascript). License: MIT.
package/bin/mailschema.js CHANGED
@@ -1,72 +1,59 @@
1
1
  #!/usr/bin/env node
2
- import { open } from 'node:fs/promises';
2
+ import { readFile, stat } from 'node:fs/promises';
3
3
  import { parseArgs } from 'node:util';
4
4
  import {
5
- assertContribution,
6
- assertTypeRecord,
7
- getContractFormatSchema,
8
- getContributionSchema,
9
- getFormsSchema,
10
- getMapContext,
11
- getMapSchema,
12
- getRecordSchema,
5
+ Contract,
6
+ CONTRACT_MAX_BYTES,
7
+ InvalidDocument,
8
+ parseDescription,
9
+ parseImplementation,
13
10
  } from '../dist/index.js';
14
11
 
15
- const help = `MailSchema Registry tools
12
+ const help = `Mail Action Protocol 0.3 checks
16
13
 
17
- mailschema check <file.json> [--record]
18
- mailschema schema [--record | --map | --context | --contract-format | --forms]
14
+ mailschema contract <contract.json>
15
+ mailschema description <description.json> [--contract <contract.json>]
16
+ mailschema implementation <record.json>
19
17
 
20
- check validates a Registry contribution, or with --record an expanded record,
21
- locally. schema prints a bundled schema: the contribution schema by default, or
22
- a MAP 0.2 core artifact. No files are uploaded or changed.`;
18
+ contract checks a type contract and prints its digest. description checks a
19
+ description and, with --contract, that it uses that contract as it allows.
20
+ implementation checks a Registry implementation record. Nothing is uploaded
21
+ or changed.`;
23
22
 
24
- const schemas = {
25
- record: getRecordSchema,
26
- map: getMapSchema,
27
- context: getMapContext,
28
- 'contract-format': getContractFormatSchema,
29
- forms: getFormsSchema,
30
- };
23
+ async function read(path) {
24
+ const info = await stat(path);
25
+ if (!info.isFile() || info.size > CONTRACT_MAX_BYTES)
26
+ throw new Error(`Expected a JSON file of at most ${CONTRACT_MAX_BYTES} bytes.`);
27
+ return new Uint8Array(await readFile(path));
28
+ }
29
+
30
+ function report(errors) {
31
+ if (!errors.length) return;
32
+ throw new InvalidDocument('Not valid.', errors);
33
+ }
31
34
 
32
35
  try {
33
36
  const { values, positionals } = parseArgs({
34
37
  allowPositionals: true,
35
- options: {
36
- ...Object.fromEntries(Object.keys(schemas).map((name) => [name, { type: 'boolean' }])),
37
- help: { type: 'boolean', short: 'h' },
38
- },
38
+ options: { contract: { type: 'string' }, help: { type: 'boolean', short: 'h' } },
39
39
  });
40
40
  const [command, path, ...extra] = positionals;
41
- const chosen = Object.keys(schemas).filter((name) => values[name]);
42
- if (chosen.length > 1) throw new Error('Choose only one schema.');
43
41
  if (values.help || !command) console.log(help);
44
- else if (command === 'schema' && !path && !extra.length)
45
- console.log(JSON.stringify((schemas[chosen[0]] ?? getContributionSchema)(), null, 2));
46
- else if (
47
- command === 'check' &&
48
- path &&
49
- !extra.length &&
50
- chosen.every((name) => name === 'record')
51
- ) {
52
- const file = await open(path, 'r');
53
- let value;
54
- try {
55
- const info = await file.stat();
56
- if (!info.isFile() || info.size > 256 * 1024)
57
- throw new Error('Expected a JSON file no larger than 256 KiB.');
58
- const bytes = Buffer.alloc(256 * 1024 + 1);
59
- const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
60
- if (bytesRead > 256 * 1024) throw new Error('JSON file exceeds 256 KiB.');
61
- value = JSON.parse(bytes.subarray(0, bytesRead).toString('utf8'));
62
- } finally {
63
- await file.close();
64
- }
65
- if (values.record) assertTypeRecord(value);
66
- else assertContribution(value);
67
- console.log(`Valid MailSchema ${values.record ? 'type record' : 'contribution'}.`);
42
+ else if (!path || extra.length || (values.contract && command !== 'description'))
43
+ throw new Error(help);
44
+ else if (command === 'contract') {
45
+ const contract = Contract.parse(await read(path));
46
+ console.log(`Valid type contract ${contract.id} ${contract.version}\n${contract.digest}`);
47
+ } else if (command === 'description') {
48
+ const description = parseDescription(await read(path));
49
+ if (values.contract)
50
+ report(Contract.parse(await read(values.contract)).descriptionErrors(description));
51
+ console.log(`Valid MAP 0.3 description ${description['@id']}`);
52
+ } else if (command === 'implementation') {
53
+ const record = parseImplementation(await read(path));
54
+ console.log(`Valid implementation record for ${record.service}`);
68
55
  } else throw new Error(help);
69
56
  } catch (error) {
70
- console.error(error instanceof Error ? error.message : String(error));
57
+ console.error(error instanceof InvalidDocument ? error.errors.join('\n') : error.message);
71
58
  process.exitCode = 1;
72
59
  }
@@ -0,0 +1,50 @@
1
+ {
2
+ "@context": {
3
+ "@version": 1.1,
4
+ "@protected": true,
5
+ "@vocab": "https://mailschema.org/vocab/map/0.3#",
6
+ "MailAction": "https://mailschema.org/vocab/map/0.3#MailAction",
7
+ "profile": {
8
+ "@id": "profile",
9
+ "@type": "@id"
10
+ },
11
+ "type": {
12
+ "@id": "type",
13
+ "@type": "@json"
14
+ },
15
+ "service": {
16
+ "@id": "service",
17
+ "@type": "@json"
18
+ },
19
+ "subject": {
20
+ "@id": "subject",
21
+ "@type": "@json"
22
+ },
23
+ "terms": {
24
+ "@id": "terms",
25
+ "@type": "@json"
26
+ },
27
+ "details": {
28
+ "@id": "details",
29
+ "@type": "@json"
30
+ },
31
+ "operations": {
32
+ "@id": "operations",
33
+ "@type": "@json"
34
+ },
35
+ "human": {
36
+ "@id": "human",
37
+ "@type": "@json"
38
+ },
39
+ "recipient": "recipient",
40
+ "inLanguage": "inLanguage",
41
+ "issuedAt": {
42
+ "@id": "issuedAt",
43
+ "@type": "http://www.w3.org/2001/XMLSchema#dateTime"
44
+ },
45
+ "expiresAt": {
46
+ "@id": "expiresAt",
47
+ "@type": "http://www.w3.org/2001/XMLSchema#dateTime"
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,206 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://mailschema.org/artifacts/map-0.3/schemas/contract.schema.json",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "required": [
7
+ "id",
8
+ "version",
9
+ "profile",
10
+ "name",
11
+ "summary",
12
+ "requirements",
13
+ "detailsSchema",
14
+ "operations"
15
+ ],
16
+ "properties": {
17
+ "id": {
18
+ "type": "string",
19
+ "minLength": 1,
20
+ "format": "uri",
21
+ "pattern": "^https://",
22
+ "maxLength": 2048
23
+ },
24
+ "version": {
25
+ "type": "string",
26
+ "minLength": 1,
27
+ "maxLength": 64
28
+ },
29
+ "profile": {
30
+ "const": "https://mailschema.org/profiles/map/0.3"
31
+ },
32
+ "name": {
33
+ "type": "string",
34
+ "minLength": 1,
35
+ "maxLength": 2048
36
+ },
37
+ "summary": {
38
+ "type": "string",
39
+ "minLength": 1,
40
+ "maxLength": 2048
41
+ },
42
+ "requirements": {
43
+ "type": "array",
44
+ "items": {
45
+ "type": "string",
46
+ "minLength": 1,
47
+ "maxLength": 2048
48
+ },
49
+ "minItems": 1,
50
+ "maxItems": 64
51
+ },
52
+ "detailsSchema": {
53
+ "type": "object"
54
+ },
55
+ "operations": {
56
+ "type": "array",
57
+ "items": {
58
+ "type": "object",
59
+ "additionalProperties": false,
60
+ "required": [
61
+ "id",
62
+ "name",
63
+ "semantics",
64
+ "effects",
65
+ "bindings",
66
+ "actors",
67
+ "exactTerms",
68
+ "inputSchema",
69
+ "outcomes"
70
+ ],
71
+ "properties": {
72
+ "id": {
73
+ "type": "string",
74
+ "minLength": 1,
75
+ "pattern": "^[a-z][a-z0-9-]{0,63}$"
76
+ },
77
+ "name": {
78
+ "type": "string",
79
+ "minLength": 1,
80
+ "maxLength": 2048
81
+ },
82
+ "semantics": {
83
+ "type": "string",
84
+ "minLength": 1,
85
+ "maxLength": 2048
86
+ },
87
+ "effects": {
88
+ "type": "array",
89
+ "items": {
90
+ "type": "string",
91
+ "minLength": 1,
92
+ "format": "uri",
93
+ "pattern": "^https://"
94
+ },
95
+ "minItems": 1,
96
+ "maxItems": 32,
97
+ "uniqueItems": true
98
+ },
99
+ "bindings": {
100
+ "type": "array",
101
+ "items": {
102
+ "enum": [
103
+ "credential",
104
+ "capability"
105
+ ]
106
+ },
107
+ "minItems": 1,
108
+ "maxItems": 2,
109
+ "uniqueItems": true
110
+ },
111
+ "actors": {
112
+ "type": "array",
113
+ "items": {
114
+ "enum": [
115
+ "human",
116
+ "agent"
117
+ ]
118
+ },
119
+ "minItems": 1,
120
+ "maxItems": 2,
121
+ "uniqueItems": true
122
+ },
123
+ "exactTerms": {
124
+ "type": "boolean"
125
+ },
126
+ "inputSchema": {
127
+ "type": [
128
+ "object",
129
+ "null"
130
+ ]
131
+ },
132
+ "outcomes": {
133
+ "type": "object",
134
+ "minProperties": 1,
135
+ "propertyNames": {
136
+ "pattern": "^[a-z][a-z0-9-]{0,63}$"
137
+ },
138
+ "additionalProperties": {
139
+ "type": "string",
140
+ "minLength": 1,
141
+ "maxLength": 2048
142
+ }
143
+ },
144
+ "capability": {
145
+ "type": "object",
146
+ "additionalProperties": false,
147
+ "required": [
148
+ "kind",
149
+ "maxLifetimeSeconds"
150
+ ],
151
+ "properties": {
152
+ "kind": {
153
+ "enum": [
154
+ "refusal",
155
+ "protective-report",
156
+ "address-confirmation"
157
+ ]
158
+ },
159
+ "maxLifetimeSeconds": {
160
+ "type": "integer",
161
+ "minimum": 1,
162
+ "maximum": 604800
163
+ }
164
+ }
165
+ }
166
+ },
167
+ "allOf": [
168
+ {
169
+ "if": {
170
+ "properties": {
171
+ "bindings": {
172
+ "contains": {
173
+ "const": "capability"
174
+ },
175
+ "type": "array"
176
+ }
177
+ }
178
+ },
179
+ "then": {
180
+ "required": [
181
+ "capability"
182
+ ],
183
+ "properties": {
184
+ "inputSchema": {
185
+ "type": "null"
186
+ },
187
+ "exactTerms": {
188
+ "const": true
189
+ }
190
+ }
191
+ },
192
+ "else": {
193
+ "not": {
194
+ "required": [
195
+ "capability"
196
+ ]
197
+ }
198
+ }
199
+ }
200
+ ]
201
+ },
202
+ "minItems": 1,
203
+ "maxItems": 32
204
+ }
205
+ }
206
+ }
@@ -0,0 +1,11 @@
1
+ export { coreSchema, contractSchema, implementationSchema } from './bundled.ts';
2
+ export declare const PROFILE = "https://mailschema.org/profiles/map/0.3";
3
+ export declare const CONTEXT = "https://mailschema.org/contexts/map-0.3.jsonld";
4
+ /** The Content-Type of the MIME part that carries a description. */
5
+ export declare const DESCRIPTION_MEDIA_TYPE = "application/ld+json; profile=\"https://mailschema.org/profiles/map/0.3\"";
6
+ export declare const DESCRIPTION_MAX_BYTES = 65536;
7
+ export declare const CONTRACT_MAX_BYTES = 262144;
8
+ /** The namespace of the initial effect vocabulary. */
9
+ export declare const EFFECTS = "https://mailschema.org/effects/";
10
+ /** The formats MAP schemas may assert. Every implementation checks exactly these. */
11
+ export declare const FORMATS: readonly ["date", "date-time", "email", "uri"];
@@ -0,0 +1,30 @@
1
+ // MAP 0.3's identifiers, limits and schema validation.
2
+ import Ajv2020 from 'ajv/dist/2020.js';
3
+ import addFormats from 'ajv-formats';
4
+ export { coreSchema, contractSchema, implementationSchema } from "./bundled.js";
5
+ export const PROFILE = 'https://mailschema.org/profiles/map/0.3';
6
+ export const CONTEXT = 'https://mailschema.org/contexts/map-0.3.jsonld';
7
+ /** The Content-Type of the MIME part that carries a description. */
8
+ export const DESCRIPTION_MEDIA_TYPE = `application/ld+json; profile="${PROFILE}"`;
9
+ export const DESCRIPTION_MAX_BYTES = 65536;
10
+ export const CONTRACT_MAX_BYTES = 262144;
11
+ /** The namespace of the initial effect vocabulary. */
12
+ export const EFFECTS = 'https://mailschema.org/effects/';
13
+ /** The formats MAP schemas may assert. Every implementation checks exactly these. */
14
+ export const FORMATS = ['date', 'date-time', 'email', 'uri'];
15
+ /**
16
+ * JSON Schema 2020-12 as MAP reads it: the listed formats asserted, and references resolved
17
+ * only within the schema, never over the network. MAP's own rules decide which keywords and
18
+ * formats a contract may use; the validator adds no checks of its own beyond the vocabulary.
19
+ * @internal
20
+ */
21
+ export function createValidator() {
22
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
23
+ addFormats(ajv, [...FORMATS]);
24
+ // Ajv also reads OpenAPI's `nullable` and the earlier `dependencies` and `definitions`.
25
+ for (const keyword of ['nullable', 'dependencies', 'definitions'])
26
+ ajv.removeKeyword(keyword);
27
+ return ajv;
28
+ }
29
+ /** Each validation error as its JSON Pointer and message. @internal */
30
+ export const schemaErrors = (errors, prefix = '') => (errors ?? []).map((error) => `${prefix + error.instancePath || '/'}: ${error.message}`);
@@ -0,0 +1,4 @@
1
+ import coreSchema from '../core.schema.json';
2
+ import contractSchema from '../contract.schema.json';
3
+ import implementationSchema from '../implementation.schema.json';
4
+ export { coreSchema, contractSchema, implementationSchema };
@@ -0,0 +1,7 @@
1
+ // The schemas the 0.3 profile record binds, by their canonical files. Package preparation
2
+ // points these three imports at the package's own copies; no other line differs between the
3
+ // repository and the npm package.
4
+ import coreSchema from '../core.schema.json' with { type: 'json' };
5
+ import contractSchema from '../contract.schema.json' with { type: 'json' };
6
+ import implementationSchema from '../implementation.schema.json' with { type: 'json' };
7
+ export { coreSchema, contractSchema, implementationSchema };
@@ -0,0 +1,69 @@
1
+ import { type Description } from './description.ts';
2
+ export interface Operation {
3
+ id: string;
4
+ name: string;
5
+ semantics: string;
6
+ effects: string[];
7
+ bindings: ('credential' | 'capability')[];
8
+ actors: ('human' | 'agent')[];
9
+ exactTerms: boolean;
10
+ inputSchema: Record<string, unknown> | null;
11
+ outcomes: Record<string, string>;
12
+ capability?: {
13
+ kind: CapabilityKind;
14
+ maxLifetimeSeconds: number;
15
+ };
16
+ }
17
+ export interface ContractDocument {
18
+ id: string;
19
+ version: string;
20
+ profile: string;
21
+ name: string;
22
+ summary: string;
23
+ requirements: string[];
24
+ detailsSchema: Record<string, unknown>;
25
+ operations: Operation[];
26
+ }
27
+ export type CapabilityKind = keyof typeof CAPABILITY_KINDS;
28
+ /** The capability binding's kinds: the exact effects each declares and its longest lifetime. */
29
+ export declare const CAPABILITY_KINDS: {
30
+ readonly refusal: {
31
+ readonly effects: readonly ["refusal", "state"];
32
+ readonly maxLifetimeSeconds: 604800;
33
+ };
34
+ readonly 'protective-report': {
35
+ readonly effects: readonly ["protection", "state"];
36
+ readonly maxLifetimeSeconds: 259200;
37
+ };
38
+ readonly 'address-confirmation': {
39
+ readonly effects: readonly ["assertion", "state"];
40
+ readonly maxLifetimeSeconds: 86400;
41
+ };
42
+ };
43
+ /** Why the value is not a MAP 0.3 type contract, or no reasons. */
44
+ export declare function contractErrors(value: unknown): string[];
45
+ /** A valid contract with its digest and compiled schemas. */
46
+ export declare class Contract {
47
+ #private;
48
+ readonly document: ContractDocument;
49
+ /** `sha-256:` and the SHA-256 of the contract's RFC 8785 form. */
50
+ readonly digest: string;
51
+ /** The contract, or InvalidDocument with every reason the value is not one. */
52
+ constructor(value: unknown);
53
+ /** The contract in the bytes or text, read as MAP JSON within the contract limit. */
54
+ static parse(input: string | Uint8Array): Contract;
55
+ get id(): string;
56
+ get version(): string;
57
+ operation(id: string): Operation | undefined;
58
+ /** Why the details do not satisfy the contract's details schema, or no reasons. */
59
+ detailsErrors(details: unknown): string[];
60
+ /** Why the input is not acceptable for the operation, or no reasons. */
61
+ inputErrors(operationId: string, input: unknown): string[];
62
+ /**
63
+ * Why a valid description does not use this contract as it allows, or no reasons: it names
64
+ * this contract by identifier, version and digest; it offers only declared operations; a
65
+ * capability appears only where the contract permits one, at the service's origin and
66
+ * within the kind's lifetime; and its details satisfy the details schema.
67
+ */
68
+ descriptionErrors(description: Description): string[];
69
+ }