From e16b9b23e4b84c9dc4100d73e86c7108e9c3c83f Mon Sep 17 00:00:00 2001 From: Kam Date: Wed, 21 Oct 2026 20:57:53 +1100 Subject: [PATCH] feat: check inLanguage, say what to fix, and release 0.3.3 --- README.md | 24 ++++++--- UPSTREAM.md | 2 +- bin/mailschema.js | 101 +++++++++++++++++++++++++++---------- dist/core/artifacts.js | 43 ++++++++++++++-- dist/core/description.d.ts | 5 +- dist/core/description.js | 13 ++++- package-lock.json | 4 +- package.json | 2 +- src/core/artifacts.ts | 49 ++++++++++++++++-- src/core/description.ts | 17 ++++++- test/package.test.mjs | 25 ++++++++- 11 files changed, 230 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 7cbe055..259380b 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,11 @@ [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. ```sh -npm install mailschema@0.3.2 +npm install mailschema@0.3.3 ``` +It needs Node.js 22 or later, as `engines` declares; browsers and workers run it as they are. + ## Check a description against its contract ```js @@ -20,7 +22,15 @@ if (errors.length) throw new Error(errors.join('\n')); `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. -The published types and their contracts are in the Registry, [`https://mailschema.org/registry/map-0.3.json`](https://mailschema.org/registry/map-0.3.json): each type's `contractHref` is its contract under `https://mailschema.org/artifacts/map-0.3/contracts/`, and its Registry page (`href`) links an example description of the type. +The published types and their contracts are in the Registry, [`https://mailschema.org/registry/map-0.3.json`](https://mailschema.org/registry/map-0.3.json): each type's `contractHref` is its contract under `https://mailschema.org/artifacts/map-0.3/contracts/`, `exampleHref` is an example description of the type, and its Registry page is `href`. + +## Write a description + +Start from the type's example. A description has these members, all required: `@context`, `@type` (`MailAction`), `@id`, `profile`, `type` (`id`, `version`, `contractDigest`), `issuedAt`, `expiresAt`, `inLanguage` (a BCP 47 tag such as `en`), `service` (`id`, an HTTPS origin with no path, and an optional `tenant`), `recipient`, `subject` (`id`, `title`), `terms` (`id`, `version`), `details` (the shape the contract's `detailsSchema` sets), `operations` and `human` (`url`). Times are UTC to the second, `YYYY-MM-DDTHH:MM:SSZ`. `type.contractDigest` is the contract's digest: the second line `mailschema contract ` prints, and the type's `digest` in the Registry. + +## What a check covers + +`parseDescription` and `Contract.descriptionErrors` check the document: the core rules, and that `details` and the operations are what the contract allows. They do not check what only the acting service knows. Whether the service, recipient, `details` and terms match a request recorded when the person started it (for an Email Address Confirmation, that `details.mailbox` is the recipient's mailbox), whether the current time is between `issuedAt` and `expiresAt`, and whether the message is authentic are the implementation's checks, under the contract's requirements and the [specification](https://mailschema.org/specification/core). ## API @@ -37,13 +47,13 @@ The profile record, context and schemas are exported byte for byte: `mailschema/ ## Command line ```sh -npx mailschema@0.3.2 contract campaign-send-approval-0.1.json -npx mailschema@0.3.2 description description.json --contract campaign-send-approval-0.1.json -npx mailschema@0.3.2 implementation record.json -npx mailschema@0.3.2 message approval.eml > description.json +npx mailschema@0.3.3 contract campaign-send-approval-0.1.json +npx mailschema@0.3.3 description description.json --contract campaign-send-approval-0.1.json +npx mailschema@0.3.3 implementation record.json +npx mailschema@0.3.3 message approval.eml > description.json ``` -Each command first prints a line naming what it checked. `contract` prints `Valid type contract ` and, on the second line, the contract's digest. `description` prints `Valid MAP 0.3 description <@id>` and, on the second line, the description's digest; with `--contract`, it first checks that the description uses that contract as it allows. `implementation` prints `Valid implementation record for `. `message` reads a received email (a saved `.eml`), writes its one MAP description to standard output exactly as the MAP part carries it, and prints the `description` lines on standard error. A digest is `sha-256:` and the SHA-256 of the document's RFC 8785 canonical JSON, the value `digest()` returns; the description's digest is not the contract digest it names. An invalid file prints every reason on standard error and exits with status 1. Nothing is uploaded or changed. +`mailschema --help` explains one command, `--version` prints the package version, and `--json` prints the result as one JSON object: `{"valid":true,...}` with the identifiers and digest, or `{"valid":false,"errors":[...]}`. Each command first prints a line naming what it checked. `contract` prints `Valid type contract ` and, on the second line, the contract's digest. `description` prints `Valid MAP 0.3 description <@id>` and, on the second line, the description's digest; with `--contract`, it first checks that the description uses that contract as it allows. `implementation` prints `Valid implementation record for `. `message` reads a received email (a saved `.eml`), writes its one MAP description to standard output exactly as the MAP part carries it, and prints the `description` lines on standard error. A digest is `sha-256:` and the SHA-256 of the document's RFC 8785 canonical JSON, the value `digest()` returns; the description's digest is not the contract digest it names. An invalid file prints every reason on standard error and exits with status 1: each names the JSON Pointer to fix, and where a schema offers alternatives, only the one the value chose. Nothing is uploaded or changed. 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). diff --git a/UPSTREAM.md b/UPSTREAM.md index 63f61c4..99cfc05 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -1,6 +1,6 @@ # Release provenance -Version `0.3.2` derives from [`mailschema/mailschema@1b41186`](https://github.com/mailschema/mailschema/commit/1b41186a0c843cd12a574038fa9e7bcfca913765). Its sources, tests and bundled files are prepared from that commit by `npm run packages:prepare`. +Version `0.3.3` derives from [`mailschema/mailschema@8e18e30`](https://github.com/mailschema/mailschema/commit/8e18e3027baf29f65b48169939feb0b4dbf9d3e3). Its sources, tests and bundled files are prepared from that commit by `npm run packages:prepare`. The bundled MAP 0.3 artifacts are exact copies of that commit's files and do not independently define MAP: diff --git a/bin/mailschema.js b/bin/mailschema.js index 1c61d53..b9ee6d0 100755 --- a/bin/mailschema.js +++ b/bin/mailschema.js @@ -4,6 +4,7 @@ import { parseArgs } from 'node:util'; import { Contract, CONTRACT_MAX_BYTES, + DESCRIPTION_MAX_BYTES, digest, extractMessage, InvalidDocument, @@ -11,28 +12,53 @@ import { parseImplementation, } from '../dist/index.js'; -const help = `Mail Action Protocol 0.3 checks +const { version } = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); - mailschema contract - mailschema description [--contract ] - mailschema implementation - mailschema message +const COMMANDS = { + contract: { + usage: 'mailschema contract ', + about: `Checks a type contract and prints "Valid type contract " and the +contract's digest. type.contractDigest in a description names this digest.`, + }, + description: { + usage: 'mailschema description [--contract ]', + about: `Checks a description against MAP 0.3 Core and, with --contract, that it uses that +contract as it allows: its details and operations. Prints "Valid MAP 0.3 description +<@id>" and the description's digest, which is not the contract digest it names. It +checks the document only: whether the service, recipient and terms match a request +recorded when the person started it, and whether the description is still in its +issuedAt to expiresAt window, are for the implementation that acts on it.`, + }, + implementation: { + usage: 'mailschema implementation ', + about: 'Checks a Registry implementation record and names the service it describes.', + }, + message: { + usage: 'mailschema message ', + about: `Finds a received email's one MAP part, writes its description to standard output +exactly as the part carries it, and prints the description's digest on standard error.`, + }, +}; -contract checks a type contract and prints its digest. description checks a -description and, with --contract, that it uses that contract as it allows, -then prints the description's digest. implementation checks a Registry -implementation record. message finds a received email's one MAP part, writes -its description to standard output exactly as the part carries it, and prints -the description's digest on standard error. A digest is the SHA-256 of the -document's RFC 8785 canonical JSON. Nothing is uploaded or changed.`; +const help = `Mail Action Protocol 0.3 checks, mailschema ${version} + +${Object.values(COMMANDS) + .map((command) => ` ${command.usage}`) + .join('\n')} + +Options: --json prints the result as JSON, --help after a command explains it, and +--version prints the package version. A digest is the SHA-256 of the document's RFC 8785 +canonical JSON. Nothing is uploaded or changed. An invalid document prints every reason +it is not valid, one per line, and exits 1.`; + +const commandHelp = (name) => `${COMMANDS[name].usage}\n\n${COMMANDS[name].about}\n\nAdd --json for a JSON result.`; // A whole email, attachments included, is far larger than any MAP document. const MESSAGE_MAX_BYTES = 26214400; -async function read(path, maxBytes = CONTRACT_MAX_BYTES, kind = 'a JSON file') { +async function read(path, maxBytes, kind) { const info = await stat(path); - if (!info.isFile() || info.size > maxBytes) - throw new Error(`Expected ${kind} of at most ${maxBytes} bytes.`); + if (!info.isFile() || info.size > maxBytes) throw new Error(`Expected ${kind} of at most ${maxBytes} bytes.`); return new Uint8Array(await readFile(path)); } @@ -41,34 +67,53 @@ function report(errors) { throw new InvalidDocument('Not valid.', errors); } +let json = false; try { const { values, positionals } = parseArgs({ allowPositionals: true, - options: { contract: { type: 'string' }, help: { type: 'boolean', short: 'h' } }, + options: { + contract: { type: 'string' }, + json: { type: 'boolean' }, + help: { type: 'boolean', short: 'h' }, + version: { type: 'boolean', short: 'v' }, + }, }); + json = Boolean(values.json); const [command, path, ...extra] = positionals; - if (values.help || !command) console.log(help); + if (values.version) console.log(version); + else if (values.help && Object.hasOwn(COMMANDS, command ?? '')) console.log(commandHelp(command)); + else if (values.help || !command) console.log(help); + else if (!Object.hasOwn(COMMANDS, command)) throw new Error(`Unknown command ${command}.\n\n${help}`); else if (!path || extra.length || (values.contract && command !== 'description')) - throw new Error(help); + throw new Error(commandHelp(command)); else if (command === 'contract') { - const contract = Contract.parse(await read(path)); - console.log(`Valid type contract ${contract.id} ${contract.version}\n${contract.digest}`); + const contract = Contract.parse(await read(path, CONTRACT_MAX_BYTES, 'a contract file')); + if (json) print({ valid: true, id: contract.id, version: contract.version, digest: contract.digest }); + else console.log(`Valid type contract ${contract.id} ${contract.version}\n${contract.digest}`); } else if (command === 'description') { - const description = parseDescription(await read(path)); + const description = parseDescription(await read(path, DESCRIPTION_MAX_BYTES, 'a description file')); if (values.contract) - report(Contract.parse(await read(values.contract)).descriptionErrors(description)); - console.log(`Valid MAP 0.3 description ${description['@id']}\n${digest(description)}`); + report(Contract.parse(await read(values.contract, CONTRACT_MAX_BYTES, 'a contract file')).descriptionErrors(description)); + if (json) print({ valid: true, id: description['@id'], digest: digest(description) }); + else console.log(`Valid MAP 0.3 description ${description['@id']}\n${digest(description)}`); } else if (command === 'message') { const result = extractMessage(await read(path, MESSAGE_MAX_BYTES, 'an email file')); if (result.status === 'none') throw new Error('The message has no MAP 0.3 part.'); if (result.status === 'invalid') throw new Error(`The message's MAP part cannot be used: ${result.reason}.`); process.stdout.write(result.bytes); console.error(`Valid MAP 0.3 description ${result.description['@id']}\n${digest(result.description)}`); - } else if (command === 'implementation') { - const record = parseImplementation(await read(path)); - console.log(`Valid implementation record for ${record.service}`); - } else throw new Error(help); + } else { + const record = parseImplementation(await read(path, CONTRACT_MAX_BYTES, 'a record file')); + if (json) print({ valid: true, service: record.service }); + else console.log(`Valid implementation record for ${record.service}`); + } } catch (error) { - console.error(error instanceof InvalidDocument ? error.errors.join('\n') : error.message); + const errors = error instanceof InvalidDocument ? error.errors : [error.message]; + if (json) print({ valid: false, errors }); + else console.error(errors.join('\n')); process.exitCode = 1; } + +function print(result) { + console.log(JSON.stringify(result)); +} diff --git a/dist/core/artifacts.js b/dist/core/artifacts.js index 6ca25fe..65760d9 100644 --- a/dist/core/artifacts.js +++ b/dist/core/artifacts.js @@ -27,14 +27,49 @@ export function createValidator() { return ajv; } /** - * Each validation error as its JSON Pointer and message. An enum or const error names the - * values it allows, so the fix is in the message. @internal + * Each validation error as its JSON Pointer and message, reduced to the ones that say what to + * fix: an enum or const error names the values it allows, an unexpected member is named, an + * if/then wrapper is left to the error inside it, and a oneOf whose branches are told apart by + * a const reports only the branch the value chose. @internal */ -export const schemaErrors = (errors, prefix = '') => (errors ?? []).map((error) => `${prefix + error.instancePath || '/'}: ${schemaMessage(error)}`); +export const schemaErrors = (errors, prefix = '') => actionable(errors ?? []).map((error) => `${prefix + error.instancePath || '/'}: ${schemaMessage(error)}`); function schemaMessage(error) { if (error.keyword === 'enum') - return `must be one of ${error.params.allowedValues.map((value) => JSON.stringify(value)).join(', ')}`; + return `must be one of ${listed(error.params.allowedValues)}`; if (error.keyword === 'const') return `must be ${JSON.stringify(error.params.allowedValue)}`; + if (error.keyword === 'additionalProperties') + return `must not have the member ${JSON.stringify(error.params.additionalProperty)}`; return error.message; } +const listed = (values) => values.map((value) => JSON.stringify(value)).join(', '); +function actionable(errors) { + let kept = errors.filter((error) => error.keyword !== 'if'); + for (const oneOf of kept.filter((error) => error.keyword === 'oneOf' && !error.params.passingSchemas)) { + const branchOf = (error) => error.schemaPath.startsWith(`${oneOf.schemaPath}/`) + ? Number(error.schemaPath.slice(oneOf.schemaPath.length + 1).split('/')[0]) + : undefined; + const branches = new Map(); + for (const error of kept) { + const branch = branchOf(error); + if (branch !== undefined) + branches.set(branch, [...(branches.get(branch) ?? []), error]); + } + // The discriminator: a const one level below the oneOf's value that a branch refused. + const discriminator = (branch) => branch.find((error) => error.keyword === 'const' && + error.instancePath.startsWith(`${oneOf.instancePath}/`) && + !error.instancePath.slice(oneOf.instancePath.length + 1).includes('/')); + const refused = [...branches.values()].map(discriminator); + const chosen = [...branches.keys()].filter((_, index) => !refused[index]); + const sharedPointer = refused.find(Boolean)?.instancePath; + if (chosen.length === 1 && refused.filter(Boolean).length === branches.size - 1) { + kept = kept.filter((error) => error !== oneOf && (branchOf(error) ?? chosen[0]) === chosen[0]); + } + else if (chosen.length === 0 && sharedPointer && refused.every((error) => error?.instancePath === sharedPointer)) { + const allowed = refused.map((error) => error.params.allowedValue); + const combined = { ...refused[0], keyword: 'enum', params: { allowedValues: allowed } }; + kept = [...kept.filter((error) => error !== oneOf && branchOf(error) === undefined), combined]; + } + } + return kept; +} diff --git a/dist/core/description.d.ts b/dist/core/description.d.ts index 69df5dc..2c5c0db 100644 --- a/dist/core/description.d.ts +++ b/dist/core/description.d.ts @@ -37,8 +37,9 @@ export interface Description { } /** * Why the value is not a MAP 0.3 description, or no reasons. Beyond the core schema: - * expiry follows issuance, operation identifiers are unique, and no security-sensitive - * identifier contains whitespace, a control or a formatting character. + * expiry follows issuance, inLanguage is a well-formed BCP 47 tag, operation identifiers are + * unique, and no security-sensitive identifier contains whitespace, a control or a + * formatting character. */ export declare function descriptionErrors(value: unknown): string[]; /** The description in the bytes or text, or InvalidDocument with every reason it is not one. */ diff --git a/dist/core/description.js b/dist/core/description.js index 93838e9..8f20797 100644 --- a/dist/core/description.js +++ b/dist/core/description.js @@ -4,10 +4,17 @@ import { InvalidDocument, parse } from "./json.js"; const validate = createValidator().compile(coreSchema); // Whitespace, controls, bidirectional and invisible formatting characters. const UNSAFE = /[\p{White_Space}\p{Cc}\p{Cf}]/u; +// A well-formed BCP 47 language tag: RFC 5646's langtag, privateuse and grandfathered forms. +const LANGUAGE_TAG = new RegExp('^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4}|[a-z]{5,8})(?:-[a-z]{4})?(?:-(?:[a-z]{2}|[0-9]{3}))?' + + '(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?' + + '|x(?:-[a-z0-9]{1,8})+' + + '|en-gb-oed|i-(?:ami|bnn|default|enochian|hak|klingon|lux|mingo|navajo|pwn|tao|tay|tsu)' + + '|sgn-(?:be-fr|be-nl|ch-de)|art-lojban|cel-gaulish|no-(?:bok|nyn)|zh-(?:guoyu|hakka|min|min-nan|xiang))$', 'i'); /** * Why the value is not a MAP 0.3 description, or no reasons. Beyond the core schema: - * expiry follows issuance, operation identifiers are unique, and no security-sensitive - * identifier contains whitespace, a control or a formatting character. + * expiry follows issuance, inLanguage is a well-formed BCP 47 tag, operation identifiers are + * unique, and no security-sensitive identifier contains whitespace, a control or a + * formatting character. */ export function descriptionErrors(value) { if (!validate(value)) @@ -15,6 +22,8 @@ export function descriptionErrors(value) { const errors = []; if (Date.parse(value.expiresAt) <= Date.parse(value.issuedAt)) errors.push('/expiresAt: must be later than issuedAt'); + if (!LANGUAGE_TAG.test(value.inLanguage)) + errors.push('/inLanguage: must be a well-formed BCP 47 language tag'); const ids = value.operations.map((operation) => operation.id); if (new Set(ids).size !== ids.length) errors.push('/operations: identifiers must be unique'); diff --git a/package-lock.json b/package-lock.json index 192b662..dd671be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mailschema", - "version": "0.3.2", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mailschema", - "version": "0.3.2", + "version": "0.3.3", "license": "MIT", "dependencies": { "ajv": "8.20.0", diff --git a/package.json b/package.json index 9f696f5..92ead46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mailschema", - "version": "0.3.2", + "version": "0.3.3", "description": "Mail Action Protocol 0.3: parse and check descriptions, type contracts and implementation records", "type": "module", "license": "MIT", diff --git a/src/core/artifacts.ts b/src/core/artifacts.ts index 9285dc7..f34142a 100644 --- a/src/core/artifacts.ts +++ b/src/core/artifacts.ts @@ -30,16 +30,55 @@ export function createValidator() { } /** - * Each validation error as its JSON Pointer and message. An enum or const error names the - * values it allows, so the fix is in the message. @internal + * Each validation error as its JSON Pointer and message, reduced to the ones that say what to + * fix: an enum or const error names the values it allows, an unexpected member is named, an + * if/then wrapper is left to the error inside it, and a oneOf whose branches are told apart by + * a const reports only the branch the value chose. @internal */ export const schemaErrors = (errors: ErrorObject[] | null | undefined, prefix = '') => - (errors ?? []).map((error) => `${prefix + error.instancePath || '/'}: ${schemaMessage(error)}`); + actionable(errors ?? []).map((error) => `${prefix + error.instancePath || '/'}: ${schemaMessage(error)}`); function schemaMessage(error: ErrorObject): string | undefined { - if (error.keyword === 'enum') - return `must be one of ${(error.params as { allowedValues: unknown[] }).allowedValues.map((value) => JSON.stringify(value)).join(', ')}`; + if (error.keyword === 'enum') return `must be one of ${listed((error.params as { allowedValues: unknown[] }).allowedValues)}`; if (error.keyword === 'const') return `must be ${JSON.stringify((error.params as { allowedValue: unknown }).allowedValue)}`; + if (error.keyword === 'additionalProperties') + return `must not have the member ${JSON.stringify((error.params as { additionalProperty: string }).additionalProperty)}`; return error.message; } + +const listed = (values: unknown[]) => values.map((value) => JSON.stringify(value)).join(', '); + +function actionable(errors: ErrorObject[]): ErrorObject[] { + let kept = errors.filter((error) => error.keyword !== 'if'); + for (const oneOf of kept.filter((error) => error.keyword === 'oneOf' && !error.params.passingSchemas)) { + const branchOf = (error: ErrorObject) => + error.schemaPath.startsWith(`${oneOf.schemaPath}/`) + ? Number(error.schemaPath.slice(oneOf.schemaPath.length + 1).split('/')[0]) + : undefined; + const branches = new Map(); + for (const error of kept) { + const branch = branchOf(error); + if (branch !== undefined) branches.set(branch, [...(branches.get(branch) ?? []), error]); + } + // The discriminator: a const one level below the oneOf's value that a branch refused. + const discriminator = (branch: ErrorObject[]) => + branch.find( + (error) => + error.keyword === 'const' && + error.instancePath.startsWith(`${oneOf.instancePath}/`) && + !error.instancePath.slice(oneOf.instancePath.length + 1).includes('/'), + ); + const refused = [...branches.values()].map(discriminator); + const chosen = [...branches.keys()].filter((_, index) => !refused[index]); + const sharedPointer = refused.find(Boolean)?.instancePath; + if (chosen.length === 1 && refused.filter(Boolean).length === branches.size - 1) { + kept = kept.filter((error) => error !== oneOf && (branchOf(error) ?? chosen[0]) === chosen[0]); + } else if (chosen.length === 0 && sharedPointer && refused.every((error) => error?.instancePath === sharedPointer)) { + const allowed = refused.map((error) => (error!.params as { allowedValue: unknown }).allowedValue); + const combined = { ...refused[0]!, keyword: 'enum', params: { allowedValues: allowed } } as ErrorObject; + kept = [...kept.filter((error) => error !== oneOf && branchOf(error) === undefined), combined]; + } + } + return kept; +} diff --git a/src/core/description.ts b/src/core/description.ts index 9e9e8eb..6aeba14 100644 --- a/src/core/description.ts +++ b/src/core/description.ts @@ -25,16 +25,29 @@ const validate = createValidator().compile(coreSchema); // Whitespace, controls, bidirectional and invisible formatting characters. const UNSAFE = /[\p{White_Space}\p{Cc}\p{Cf}]/u; +// A well-formed BCP 47 language tag: RFC 5646's langtag, privateuse and grandfathered forms. +const LANGUAGE_TAG = new RegExp( + '^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4}|[a-z]{5,8})(?:-[a-z]{4})?(?:-(?:[a-z]{2}|[0-9]{3}))?' + + '(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?' + + '|x(?:-[a-z0-9]{1,8})+' + + '|en-gb-oed|i-(?:ami|bnn|default|enochian|hak|klingon|lux|mingo|navajo|pwn|tao|tay|tsu)' + + '|sgn-(?:be-fr|be-nl|ch-de)|art-lojban|cel-gaulish|no-(?:bok|nyn)|zh-(?:guoyu|hakka|min|min-nan|xiang))$', + 'i', +); + /** * Why the value is not a MAP 0.3 description, or no reasons. Beyond the core schema: - * expiry follows issuance, operation identifiers are unique, and no security-sensitive - * identifier contains whitespace, a control or a formatting character. + * expiry follows issuance, inLanguage is a well-formed BCP 47 tag, operation identifiers are + * unique, and no security-sensitive identifier contains whitespace, a control or a + * formatting character. */ export function descriptionErrors(value: unknown): string[] { if (!validate(value)) return schemaErrors(validate.errors); const errors: string[] = []; if (Date.parse(value.expiresAt) <= Date.parse(value.issuedAt)) errors.push('/expiresAt: must be later than issuedAt'); + if (!LANGUAGE_TAG.test(value.inLanguage)) + errors.push('/inLanguage: must be a well-formed BCP 47 language tag'); const ids = value.operations.map((operation) => operation.id); if (new Set(ids).size !== ids.length) errors.push('/operations: identifiers must be unique'); const identifiers: [string, string | undefined][] = [ diff --git a/test/package.test.mjs b/test/package.test.mjs index 11693a0..ae40ed0 100644 --- a/test/package.test.mjs +++ b/test/package.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { readFile, readdir } from 'node:fs/promises'; +import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; @@ -100,6 +102,27 @@ test('checks files from the command line and prints their digests', async () => } const extracted = execFileSync(process.execPath, [cli, 'message', path('examples/publication.eml')], { stdio: 'pipe' }); assert.deepEqual(parseDescription(extracted), await load('examples/publication-approval.json')); + const { version } = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); + assert.equal(run('--version'), `${version}\n`); + assert.match(run('description', '--help'), /^mailschema description \[--contract \]\n/); + const publication = path('examples/publication-approval.json'); + assert.deepEqual(JSON.parse(run('description', publication, '--json')), { + valid: true, + id: (await load('examples/publication-approval.json'))['@id'], + digest: descriptionDigests['publication-approval'], + }); + const refused = (args) => { + try { + run(...args); + } catch (error) { + return error; + } + assert.fail(`${args.join(' ')} was accepted`); + }; + assert.deepEqual(JSON.parse(refused(['contract', publication, '--json']).stdout).valid, false); + const large = join(await mkdtemp(join(tmpdir(), 'mailschema-')), 'large.json'); + await writeFile(large, JSON.stringify({ padding: 'x'.repeat(DESCRIPTION_MAX_BYTES) })); + assert.match(refused(['description', large]).stderr, /at most 65536 bytes/); const campaign = path('examples/campaign-send-approval.json'); assert.throws(() => run('message', campaign)); assert.throws(() => run('contract', campaign));