@datumesh/verify 0.1.1 → 0.2.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,38 @@
1
+ import { type ErrorCode } from './errors.js';
2
+ export type Outcome = 'ok' | 'stale' | 'not_covered' | 'unlicensed' | 'payment_required' | 'rate_limited' | 'error' | 'bad_request';
3
+ /** What the client asked for (SPEC 5.5 step 5): the requested cell's id, and `asof` if the request had one. */
4
+ export interface EnvelopeRequest {
5
+ cell_id: string;
6
+ asof?: string;
7
+ }
8
+ export type Trust = 'primary' | 'verified' | 'unverified';
9
+ export interface EnvelopeResult {
10
+ /** True when the envelope is well formed, matches the status, and its receipt (if any) is signature-valid. */
11
+ ok: boolean;
12
+ /** "OK", E_ENVELOPE, a parse code, or the code of the embedded receipt's verification. */
13
+ code: 'OK' | ErrorCode;
14
+ /** The envelope's outcome when ok, else null. */
15
+ outcome: Outcome | null;
16
+ /** The trust label when ok and the envelope carries a receipt, else null. */
17
+ trust: Trust | null;
18
+ /** The embedded receipt's hash once it has been verified (SPEC 10.1 steps 1-6), else null. */
19
+ receiptHash: string | null;
20
+ /** Human-readable explanation. */
21
+ detail: string;
22
+ /** Where the failure was found (a JSON path such as `$.quote.amount`), or null. */
23
+ path: string | null;
24
+ }
25
+ /**
26
+ * Check a mesh response (SPEC 5.5): `status` is the HTTP status, `body` the response body as
27
+ * JSON text or bytes (a parsed object is accepted too, without duplicate-member detection), and
28
+ * `keys` any key source `verify` accepts, used to verify an embedded receipt through step 6.
29
+ * `request` is what the client asked for: `{cell_id, asof?}`, the id (SPEC 4.4) of the requested
30
+ * cell resolved to the fn_hash the client used, and the requested asof if there was one.
31
+ * Steps: strict parse; members (E_ENVELOPE); status against outcome (E_ENVELOPE); the receipt
32
+ * (its own codes); the receipt answers the request (E_ENVELOPE); then `trust` against the receipt
33
+ * and `superseded_by` against its hash (E_ENVELOPE).
34
+ *
35
+ * Never throws for an invalid body or bad key material; throws TypeError only when `status` is
36
+ * not an integer, `request` is malformed, or `keys` is not a key source at all.
37
+ */
38
+ export declare function checkEnvelope(status: number, body: unknown, keys: unknown, request: EnvelopeRequest): EnvelopeResult;
@@ -0,0 +1,240 @@
1
+ // Response envelopes (SPEC.md 5.5): the unsigned JSON body a mesh returns around a receipt.
2
+ // Synchronous, offline; the embedded receipt is verified through step 6 with the given keys.
3
+ import { DatumeshError, ERROR_CODES } from './errors.js';
4
+ import { has, isObject, memberPath, sortedKeys, toJson } from './json.js';
5
+ import { LIMITS, URL_RE, checkNumber, checkString, isHex, isInstant } from './lexical.js';
6
+ import { sha256Hex } from './hash.js';
7
+ import { jcsBytes } from './jcs.js';
8
+ import { collectSources } from './keys.js';
9
+ import { isCurrency } from './units.js';
10
+ import { Stop, verifyEmbedded } from './verify.js';
11
+ /** SPEC 5.5: required and optional members per outcome (besides `outcome`). */
12
+ const MEMBERS = {
13
+ ok: { required: ['receipt', 'trust'], optional: ['superseded_by', 'reason'] },
14
+ stale: { required: ['receipt', 'trust', 'age_seconds'], optional: ['superseded_by', 'reason'] },
15
+ not_covered: { required: [], optional: [] },
16
+ unlicensed: { required: [], optional: [] },
17
+ payment_required: { required: ['quote'], optional: [] },
18
+ rate_limited: { required: ['retry_after_seconds'], optional: [] },
19
+ error: { required: ['error'], optional: [] },
20
+ bad_request: { required: ['error'], optional: [] },
21
+ };
22
+ const STATUS = {
23
+ ok: (s) => s === 200,
24
+ stale: (s) => s === 200,
25
+ not_covered: (s) => s === 404,
26
+ unlicensed: (s) => s === 403,
27
+ payment_required: (s) => s === 402,
28
+ rate_limited: (s) => s === 429,
29
+ error: (s) => s >= 500 && s <= 599,
30
+ bad_request: (s) => s === 400,
31
+ };
32
+ /** SPEC 5.5: the codes `parse` can raise (5.4), the only ones a bad_request carries. */
33
+ const PARSE_CODES = ['E_PARSE_URI', 'E_CANON_NAME', 'E_CANON_ARG_NAME', 'E_CANON_ARG_DUPLICATE', 'E_CANON_ARG_ORDER', 'E_CANON_PERCENT', 'E_CANON_TYPE', 'E_CANON_NUMBER', 'E_CANON_DATE', 'E_CANON_INSTANT', 'E_CANON_STRING', 'E_CANON_BLOB', 'E_CANON_LIMIT', 'E_ARG_UNKNOWN', 'E_ARG_MISSING'];
34
+ const TRUST = ['primary', 'verified', 'unverified'];
35
+ const MAX_SECONDS = 9007199254740991;
36
+ class Bad extends Error {
37
+ constructor(detail, path) {
38
+ super(detail);
39
+ this.detail = detail;
40
+ this.path = path;
41
+ }
42
+ }
43
+ function bad(detail, path) {
44
+ throw new Bad(detail, path);
45
+ }
46
+ /** A rule of 3.x checked inside an envelope: its own code becomes part of the E_ENVELOPE detail. */
47
+ function inner(f, path) {
48
+ try {
49
+ f();
50
+ }
51
+ catch (e) {
52
+ if (e instanceof DatumeshError)
53
+ bad(`${e.code}: ${e.detail}`, path);
54
+ throw e;
55
+ }
56
+ }
57
+ /**
58
+ * SPEC 5.5 integer members: written 0 or [1-9][0-9]*, at most 2^53 - 1. The source text decides
59
+ * (`30.0`, `3e1` and `-0` are rejected); for an already-parsed body, whose text is gone, the
60
+ * value must be a non-negative safe integer other than -0.
61
+ */
62
+ function seconds(v, text, name, path) {
63
+ const why = `${name} is written 0 or [1-9][0-9]*, at most 2^53 - 1`;
64
+ if (typeof v !== 'number')
65
+ bad(why, path);
66
+ if (text !== undefined ? !/^(0|[1-9][0-9]*)$/.test(text) : !Number.isSafeInteger(v) || Object.is(v, -0))
67
+ bad(why, path);
68
+ if (v < 0 || v > MAX_SECONDS)
69
+ bad(why, path);
70
+ }
71
+ function quote(v, path) {
72
+ if (!isObject(v))
73
+ bad('quote is an object {amount, authorise, currency}', path);
74
+ for (const k of ['amount', 'authorise', 'currency'])
75
+ if (!has(v, k))
76
+ bad(`quote has no ${k}`, path);
77
+ for (const k of sortedKeys(v))
78
+ if (!['amount', 'authorise', 'currency'].includes(k))
79
+ bad(`quote has an unexpected member ${JSON.stringify(k)}`, path);
80
+ const amount = v.amount;
81
+ const ap = memberPath(path, 'amount');
82
+ if (typeof amount !== 'string')
83
+ bad('amount is a decimal string (SPEC 3.1)', ap);
84
+ inner(() => checkNumber(amount, ap), ap);
85
+ if (amount.startsWith('-') || amount === '0')
86
+ bad('amount is greater than zero', ap);
87
+ const url = v.authorise;
88
+ const up = memberPath(path, 'authorise');
89
+ if (typeof url !== 'string' || !URL_RE.test(url) || url.length > LIMITS.licence_url)
90
+ bad('authorise is an https:// URL in the form of a licence URL (SPEC 8.1)', up);
91
+ if (!isCurrency(v.currency))
92
+ bad('currency is a currency code from units.md', memberPath(path, 'currency'));
93
+ }
94
+ /** Step 2: members against the table and their rules. Returns the outcome. */
95
+ function members(o, numbers) {
96
+ if (!isObject(o) || typeof o.outcome !== 'string')
97
+ bad('an envelope is an object with a string outcome', '$');
98
+ const outcome = o.outcome;
99
+ if (!Object.prototype.hasOwnProperty.call(MEMBERS, outcome))
100
+ bad(`unknown outcome ${JSON.stringify(outcome)}`, '$.outcome');
101
+ const { required, optional } = MEMBERS[outcome];
102
+ for (const k of required)
103
+ if (!has(o, k))
104
+ bad(`outcome ${outcome} requires ${k}`, '$');
105
+ for (const k of sortedKeys(o))
106
+ if (k !== 'outcome' && !required.includes(k) && !optional.includes(k))
107
+ bad(`member ${JSON.stringify(k)} is not allowed with outcome ${outcome}`, memberPath('$', k));
108
+ if (has(o, 'reason') && !has(o, 'superseded_by'))
109
+ bad('reason is only present with superseded_by', '$.reason');
110
+ for (const k of sortedKeys(o)) {
111
+ const v = o[k];
112
+ const p = memberPath('$', k);
113
+ switch (k) {
114
+ case 'age_seconds':
115
+ case 'retry_after_seconds':
116
+ seconds(v, numbers.get(p), k, p);
117
+ break;
118
+ case 'error':
119
+ if (outcome === 'bad_request') {
120
+ if (typeof v !== 'string' || !PARSE_CODES.includes(v))
121
+ bad('a bad_request carries a code that parsing a cell URI raises (SPEC 5.4)', p);
122
+ }
123
+ else if (typeof v !== 'string' || v === 'E_ENVELOPE' || !ERROR_CODES.includes(v)) {
124
+ bad('error is a code from SPEC.md 10.2 other than E_ENVELOPE', p);
125
+ }
126
+ break;
127
+ case 'quote':
128
+ quote(v, p);
129
+ break;
130
+ case 'reason':
131
+ if (typeof v !== 'string')
132
+ bad('reason is a string', p);
133
+ if (v === '')
134
+ bad('reason must not be empty', p);
135
+ inner(() => checkString(v, p, { limit: LIMITS.summary }), p);
136
+ break;
137
+ case 'receipt':
138
+ if (!isObject(v))
139
+ bad('receipt is a receipt object', p);
140
+ break;
141
+ case 'superseded_by':
142
+ if (typeof v !== 'string' || !isHex(v, 64))
143
+ bad('superseded_by is a receipt hash: 64 lowercase hex digits', p);
144
+ break;
145
+ case 'trust':
146
+ if (typeof v !== 'string' || !TRUST.includes(v))
147
+ bad('trust is one of primary, verified, unverified', p);
148
+ break;
149
+ }
150
+ }
151
+ return outcome;
152
+ }
153
+ function checkRequest(request) {
154
+ const ok = isObject(request) &&
155
+ typeof request.cell_id === 'string' &&
156
+ isHex(request.cell_id, 64) &&
157
+ Object.keys(request).every((k) => k === 'cell_id' || k === 'asof') &&
158
+ (!has(request, 'asof') || (typeof request.asof === 'string' && isInstant(request.asof)));
159
+ if (!ok)
160
+ throw new TypeError('request must be {cell_id: <64 lowercase hex>, asof?: <instant>}: the cell id of the requested cell (resolved to the fn_hash used) and its asof, if the request had one');
161
+ }
162
+ const hostOf = (s, sep) => {
163
+ const i = s.indexOf(sep);
164
+ return i < 0 ? s : s.slice(0, i);
165
+ };
166
+ /**
167
+ * Check a mesh response (SPEC 5.5): `status` is the HTTP status, `body` the response body as
168
+ * JSON text or bytes (a parsed object is accepted too, without duplicate-member detection), and
169
+ * `keys` any key source `verify` accepts, used to verify an embedded receipt through step 6.
170
+ * `request` is what the client asked for: `{cell_id, asof?}`, the id (SPEC 4.4) of the requested
171
+ * cell resolved to the fn_hash the client used, and the requested asof if there was one.
172
+ * Steps: strict parse; members (E_ENVELOPE); status against outcome (E_ENVELOPE); the receipt
173
+ * (its own codes); the receipt answers the request (E_ENVELOPE); then `trust` against the receipt
174
+ * and `superseded_by` against its hash (E_ENVELOPE).
175
+ *
176
+ * Never throws for an invalid body or bad key material; throws TypeError only when `status` is
177
+ * not an integer, `request` is malformed, or `keys` is not a key source at all.
178
+ */
179
+ export function checkEnvelope(status, body, keys, request) {
180
+ if (typeof status !== 'number' || !Number.isInteger(status))
181
+ throw new TypeError('status must be the HTTP status as an integer');
182
+ checkRequest(request);
183
+ const sources = collectSources(keys);
184
+ const fail = (code, detail, path, receiptHash = null) => ({ ok: false, code, outcome: null, trust: null, receiptHash, detail, path });
185
+ let o;
186
+ const numbers = new Map();
187
+ try {
188
+ o = toJson(body, numbers); // step 1
189
+ }
190
+ catch (e) {
191
+ if (e instanceof DatumeshError)
192
+ return fail(e.code, e.detail, e.path);
193
+ throw e;
194
+ }
195
+ let outcome;
196
+ try {
197
+ outcome = members(o, numbers); // step 2
198
+ if (!STATUS[outcome](status))
199
+ bad(`status ${status} does not match outcome ${outcome}`, '$.outcome'); // step 3
200
+ }
201
+ catch (e) {
202
+ if (e instanceof Bad)
203
+ return fail('E_ENVELOPE', e.detail, e.path);
204
+ throw e;
205
+ }
206
+ const env = o;
207
+ if (!has(env, 'receipt'))
208
+ return { ok: true, code: 'OK', outcome, trust: null, receiptHash: null, detail: `outcome ${outcome}`, path: null };
209
+ // Step 4.
210
+ let r;
211
+ try {
212
+ r = verifyEmbedded(env.receipt, sources);
213
+ }
214
+ catch (e) {
215
+ if (e instanceof Stop)
216
+ return fail(e.code, `receipt: ${e.detail}`, e.path === null ? '$.receipt' : e.path.replace(/^\$/, '$.receipt'));
217
+ throw e;
218
+ }
219
+ // Step 5: the receipt answers the request.
220
+ const cellId = sha256Hex(jcsBytes(r.receipt.cell));
221
+ if (cellId !== request.cell_id)
222
+ return fail('E_ENVELOPE', `the receipt is for cell ${cellId}, not the requested ${request.cell_id}`, '$.receipt.cell', r.hash);
223
+ // With a requested asof: equal for ok; at or before it for stale (D31). Instants are fixed
224
+ // width, so string order is time order (SPEC 3.3).
225
+ const asof = r.receipt.asof;
226
+ if (request.asof !== undefined && (outcome === 'stale' ? !(asof <= request.asof) : asof !== request.asof)) {
227
+ const want = outcome === 'stale' ? `at or before the requested ${request.asof}` : `the requested ${request.asof}`;
228
+ return fail('E_ENVELOPE', `the receipt answers asof ${asof}, not ${want}`, '$.receipt.asof', r.hash);
229
+ }
230
+ // Step 6.
231
+ const trust = env.trust;
232
+ const cell = r.receipt.cell;
233
+ const primary = hostOf(r.receipt.computed_by, '#') === hostOf(cell.ns, '/') && r.receipt.inputs.length === 0;
234
+ if ((trust === 'primary') !== primary) {
235
+ return fail('E_ENVELOPE', primary ? 'the receipt is primary (signed by its namespace host, no inputs): trust must be primary' : 'trust primary needs a receipt signed by the host of cell.ns with no inputs', '$.trust', r.hash);
236
+ }
237
+ if (env.superseded_by === r.hash)
238
+ return fail('E_ENVELOPE', 'superseded_by is the receipt itself', '$.superseded_by', r.hash);
239
+ return { ok: true, code: 'OK', outcome, trust, receiptHash: r.hash, detail: `outcome ${outcome}, trust ${trust}, receipt signed by ${r.receipt.computed_by}`, path: null };
240
+ }
@@ -1,7 +1,7 @@
1
1
  /** The spec revision this library conforms to. */
2
- export declare const SPEC_VERSION = "spec-0.1.1";
2
+ export declare const SPEC_VERSION = "spec-0.2.0";
3
3
  /** Every error code defined by SPEC.md 10.2, in the order of that table. */
4
- export declare const ERROR_CODES: readonly ["E_PARSE_JSON", "E_PARSE_DUPLICATE_KEY", "E_PARSE_URI", "E_SCHEMA", "E_CANON_NAME", "E_CANON_ARG_NAME", "E_CANON_ARG_DUPLICATE", "E_CANON_ARG_ORDER", "E_CANON_PERCENT", "E_CANON_TYPE", "E_CANON_NUMBER", "E_CANON_DATE", "E_CANON_INSTANT", "E_CANON_STRING", "E_CANON_HEX", "E_CANON_LICENCE", "E_CANON_SET", "E_CANON_LIMIT", "E_SLICE", "E_PUBLISHER_MISMATCH", "E_VALID_MISMATCH", "E_TIME_ORDER", "E_ARG_UNKNOWN", "E_ARG_MISSING", "E_KEY_UNAVAILABLE", "E_KEY_DOC", "E_KEY_UNKNOWN", "E_KEY_NOT_VALID", "E_SIGNATURE", "E_FN_UNAVAILABLE", "E_FN_HASH_MISMATCH", "E_ARG_TYPE", "E_RETURN_TYPE", "E_INPUT_UNAVAILABLE", "E_INPUT_INVALID", "E_INPUT_UNDECLARED", "E_VALUE_MISMATCH"];
4
+ export declare const ERROR_CODES: readonly ["E_PARSE_JSON", "E_PARSE_DUPLICATE_KEY", "E_PARSE_URI", "E_SCHEMA", "E_CANON_NAME", "E_CANON_ARG_NAME", "E_CANON_ARG_DUPLICATE", "E_CANON_ARG_ORDER", "E_CANON_PERCENT", "E_CANON_TYPE", "E_CANON_NUMBER", "E_CANON_UNIT", "E_CANON_DATE", "E_CANON_INSTANT", "E_CANON_STRING", "E_CANON_BLOB", "E_CANON_HEX", "E_CANON_LICENCE", "E_CANON_SET", "E_CANON_LIMIT", "E_SLICE", "E_PUBLISHER_MISMATCH", "E_VALID_MISMATCH", "E_TIME_ORDER", "E_ARG_UNKNOWN", "E_ARG_MISSING", "E_KEY_UNAVAILABLE", "E_KEY_DOC", "E_KEY_UNKNOWN", "E_KEY_NOT_VALID", "E_SIGNATURE", "E_FN_UNAVAILABLE", "E_FN_HASH_MISMATCH", "E_ARG_TYPE", "E_RETURN_TYPE", "E_INPUT_UNAVAILABLE", "E_INPUT_INVALID", "E_INPUT_UNDECLARED", "E_VALUE_MISMATCH", "E_BLOB_MISMATCH", "E_ENVELOPE"];
5
5
  export type ErrorCode = (typeof ERROR_CODES)[number];
6
6
  /** A Datumesh rule was broken. `code` is a SPEC.md 10.2 code; `path` locates the failure. */
7
7
  export declare class DatumeshError extends Error {
@@ -1,5 +1,5 @@
1
1
  /** The spec revision this library conforms to. */
2
- export const SPEC_VERSION = 'spec-0.1.1';
2
+ export const SPEC_VERSION = 'spec-0.2.0';
3
3
  /** Every error code defined by SPEC.md 10.2, in the order of that table. */
4
4
  export const ERROR_CODES = [
5
5
  'E_PARSE_JSON',
@@ -13,9 +13,11 @@ export const ERROR_CODES = [
13
13
  'E_CANON_PERCENT',
14
14
  'E_CANON_TYPE',
15
15
  'E_CANON_NUMBER',
16
+ 'E_CANON_UNIT',
16
17
  'E_CANON_DATE',
17
18
  'E_CANON_INSTANT',
18
19
  'E_CANON_STRING',
20
+ 'E_CANON_BLOB',
19
21
  'E_CANON_HEX',
20
22
  'E_CANON_LICENCE',
21
23
  'E_CANON_SET',
@@ -39,6 +41,8 @@ export const ERROR_CODES = [
39
41
  'E_INPUT_INVALID',
40
42
  'E_INPUT_UNDECLARED',
41
43
  'E_VALUE_MISMATCH',
44
+ 'E_BLOB_MISMATCH',
45
+ 'E_ENVELOPE',
42
46
  ];
43
47
  /** A Datumesh rule was broken. `code` is a SPEC.md 10.2 code; `path` locates the failure. */
44
48
  export class DatumeshError extends Error {
@@ -1,12 +1,13 @@
1
1
  import { type Kind } from './validate.js';
2
2
  export { DatumeshError, ERROR_CODES, SPEC_VERSION, type ErrorCode } from './errors.js';
3
3
  export { verify, type VerifyResult, type Recompute } from './verify.js';
4
- export { parseUri, formatUri, httpsUrl, type CellRef, type TypedScalar, type Signature } from './uri.js';
4
+ export { checkEnvelope, type EnvelopeResult, type EnvelopeRequest, type Outcome, type Trust } from './envelope.js';
5
+ export { parseUri, formatUri, httpsUrl, type CellRef, type TypedScalar, type Signature, type Unit, type UnitRef } from './uri.js';
5
6
  export { keysFromResponse, keysFromDocument, fetchKeys, FETCH_TIMEOUT_MS, type KeysResult, type KeyEntry, type KeyMap, type KeySource } from './keys.js';
6
7
  export type { Kind } from './validate.js';
7
8
  export type { JsonValue } from './json.js';
8
9
  /** Library version. */
9
- export declare const VERSION = "0.1.1";
10
+ export declare const VERSION = "0.2.0";
10
11
  /**
11
12
  * Validate `input` as `kind` and return its RFC 8785 canonical bytes. `input` is JSON text,
12
13
  * UTF-8 bytes, or an already-parsed value. `kind` is "scalar", "cell", "ref", "function" or
@@ -23,5 +24,14 @@ export declare function functionHash(descriptor: unknown): string;
23
24
  export declare function receiptHash(receipt: unknown): string;
24
25
  /** The signed payload of a receipt (SPEC 8.3): canonical bytes without `sig`. Throws DatumeshError. */
25
26
  export declare function receiptPayload(receipt: unknown): Uint8Array;
27
+ /**
28
+ * The unit of a cell's value (SPEC 7.1, units.md section 3): the descriptor's return unit with
29
+ * the cell's argument values substituted, e.g. "USD/EUR"; null when the function does not
30
+ * return a number. `descriptor` must be the cell's function (E_FN_HASH_MISMATCH otherwise).
31
+ * Both are JSON text, bytes or parsed objects. Throws DatumeshError like canonicalize, and
32
+ * E_ARG_MISSING / E_SLICE / E_ARG_TYPE / E_CANON_UNIT when a referenced argument cannot give a
33
+ * unit.
34
+ */
35
+ export declare function resolvedUnit(descriptor: unknown, cell: unknown): string | null;
26
36
  /** Key id of an Ed25519 public key given as 64 lowercase hex digits (SPEC 9.1). Throws DatumeshError. */
27
37
  export declare function keyId(publicKeyHex: string): string;
package/dist/esm/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @datumesh/verify: verify Datumesh receipts (SPEC.md, revision spec-0.1.1).
2
+ * @datumesh/verify: verify Datumesh receipts (SPEC.md, revision spec-0.2.0).
3
3
  *
4
4
  * Everything here is synchronous and offline except fetchKeys, the only function that uses the
5
5
  * network.
@@ -9,13 +9,14 @@ import { sha256Hex, keyIdOf, fromHex } from './hash.js';
9
9
  import { newObject, toJson } from './json.js';
10
10
  import { jcsBytes } from './jcs.js';
11
11
  import { isHex } from './lexical.js';
12
- import { KINDS, inferKind, validate } from './validate.js';
12
+ import { KINDS, cellUnit, inferKind, validate } from './validate.js';
13
13
  export { DatumeshError, ERROR_CODES, SPEC_VERSION } from './errors.js';
14
14
  export { verify } from './verify.js';
15
+ export { checkEnvelope } from './envelope.js';
15
16
  export { parseUri, formatUri, httpsUrl } from './uri.js';
16
17
  export { keysFromResponse, keysFromDocument, fetchKeys, FETCH_TIMEOUT_MS } from './keys.js';
17
18
  /** Library version. */
18
- export const VERSION = '0.1.1';
19
+ export const VERSION = '0.2.0';
19
20
  function parseAndValidate(input, kind) {
20
21
  if (kind !== undefined && kind !== null && !KINDS.includes(kind))
21
22
  throw new TypeError(`unknown kind ${JSON.stringify(kind)}; expected one of ${KINDS.join(', ')}`);
@@ -55,6 +56,21 @@ export function receiptPayload(receipt) {
55
56
  o[k] = r[k];
56
57
  return jcsBytes(o);
57
58
  }
59
+ /**
60
+ * The unit of a cell's value (SPEC 7.1, units.md section 3): the descriptor's return unit with
61
+ * the cell's argument values substituted, e.g. "USD/EUR"; null when the function does not
62
+ * return a number. `descriptor` must be the cell's function (E_FN_HASH_MISMATCH otherwise).
63
+ * Both are JSON text, bytes or parsed objects. Throws DatumeshError like canonicalize, and
64
+ * E_ARG_MISSING / E_SLICE / E_ARG_TYPE / E_CANON_UNIT when a referenced argument cannot give a
65
+ * unit.
66
+ */
67
+ export function resolvedUnit(descriptor, cell) {
68
+ if (descriptor === undefined || cell === undefined)
69
+ throw new TypeError('resolvedUnit(descriptor, cell) needs both arguments');
70
+ const d = toJson(descriptor);
71
+ const c = toJson(cell);
72
+ return cellUnit(d, c, (x) => sha256Hex(jcsBytes(x)));
73
+ }
58
74
  /** Key id of an Ed25519 public key given as 64 lowercase hex digits (SPEC 9.1). Throws DatumeshError. */
59
75
  export function keyId(publicKeyHex) {
60
76
  if (typeof publicKeyHex !== 'string' || !isHex(publicKeyHex, 64))
@@ -15,9 +15,9 @@ export declare function has(o: JsonObject, k: string): boolean;
15
15
  export declare function sortedKeys(o: JsonObject): string[];
16
16
  export declare function memberPath(path: string, name: string): string;
17
17
  /** Parse JSON text strictly. */
18
- export declare function parseJsonText(text: string): JsonValue;
18
+ export declare function parseJsonText(text: string, numbers?: Map<string, string>): JsonValue;
19
19
  /** Parse UTF-8 JSON bytes strictly: no BOM, no invalid UTF-8. */
20
- export declare function parseJsonBytes(bytes: Uint8Array): JsonValue;
20
+ export declare function parseJsonBytes(bytes: Uint8Array, numbers?: Map<string, string>): JsonValue;
21
21
  /**
22
22
  * Check an already-parsed JavaScript value against the JSON data model and return a private
23
23
  * deep copy (plain data with null-prototype objects), so later checks cannot be affected by
@@ -26,4 +26,4 @@ export declare function parseJsonBytes(bytes: Uint8Array): JsonValue;
26
26
  */
27
27
  export declare function normalizeValue(v: unknown, path?: string, depth?: number): JsonValue;
28
28
  /** JSON text (string), UTF-8 bytes (Uint8Array) or an already-parsed value. */
29
- export declare function toJson(input: unknown): JsonValue;
29
+ export declare function toJson(input: unknown, numbers?: Map<string, string>): JsonValue;
package/dist/esm/json.js CHANGED
@@ -26,8 +26,11 @@ export function memberPath(path, name) {
26
26
  return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? `${path}.${name}` : `${path}[${JSON.stringify(name)}]`;
27
27
  }
28
28
  class Parser {
29
- constructor(s) {
29
+ constructor(s,
30
+ /** When given, receives the source text of every number, by JSON path. */
31
+ numbers) {
30
32
  this.s = s;
33
+ this.numbers = numbers;
31
34
  this.i = 0;
32
35
  /** Path of the first string holding an escaped lone surrogate; reported after parsing. */
33
36
  this.surrogatePath = null;
@@ -77,7 +80,7 @@ class Parser {
77
80
  return null;
78
81
  }
79
82
  if (c === '-' || (c >= '0' && c <= '9'))
80
- return this.number();
83
+ return this.number(path);
81
84
  if (c === undefined)
82
85
  this.err('unexpected end of input');
83
86
  if (s.startsWith('NaN', this.i) || s.startsWith('Infinity', this.i) || s.startsWith('-Infinity', this.i)) {
@@ -219,7 +222,7 @@ class Parser {
219
222
  this.surrogatePath = path;
220
223
  return out;
221
224
  }
222
- number() {
225
+ number(path) {
223
226
  const s = this.s;
224
227
  const m = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y;
225
228
  m.lastIndex = this.i;
@@ -235,30 +238,31 @@ class Parser {
235
238
  const n = Number(text);
236
239
  if (!Number.isFinite(n))
237
240
  fail('E_PARSE_JSON', `number out of IEEE 754 range: ${text.slice(0, 40)}`, '$');
241
+ this.numbers?.set(path, text);
238
242
  return n;
239
243
  }
240
244
  }
241
245
  /** Parse JSON text strictly. */
242
- export function parseJsonText(text) {
246
+ export function parseJsonText(text, numbers) {
243
247
  if (text.charCodeAt(0) === 0xfeff)
244
248
  fail('E_PARSE_JSON', 'byte order mark', '$');
245
249
  if (hasLoneSurrogate(text))
246
250
  fail('E_PARSE_JSON', 'lone surrogate in the text', '$');
247
- const p = new Parser(text);
251
+ const p = new Parser(text, numbers);
248
252
  const v = p.parseDocument();
249
253
  if (p.surrogatePath !== null)
250
254
  fail('E_PARSE_JSON', 'lone surrogate', p.surrogatePath);
251
255
  return v;
252
256
  }
253
257
  /** Parse UTF-8 JSON bytes strictly: no BOM, no invalid UTF-8. */
254
- export function parseJsonBytes(bytes) {
258
+ export function parseJsonBytes(bytes, numbers) {
255
259
  if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
256
260
  fail('E_PARSE_JSON', 'byte order mark', '$');
257
261
  }
258
262
  const text = utf8Decode(bytes);
259
263
  if (text === null)
260
264
  fail('E_PARSE_JSON', 'invalid UTF-8', '$');
261
- return parseJsonText(text);
265
+ return parseJsonText(text, numbers);
262
266
  }
263
267
  /**
264
268
  * Check an already-parsed JavaScript value against the JSON data model and return a private
@@ -298,12 +302,12 @@ export function normalizeValue(v, path = '$', depth = 0) {
298
302
  fail('E_PARSE_JSON', `${typeof v} is not a JSON value`, path);
299
303
  }
300
304
  /** JSON text (string), UTF-8 bytes (Uint8Array) or an already-parsed value. */
301
- export function toJson(input) {
305
+ export function toJson(input, numbers) {
302
306
  if (typeof input === 'string')
303
- return parseJsonText(input);
307
+ return parseJsonText(input, numbers);
304
308
  if (input instanceof Uint8Array)
305
- return parseJsonBytes(input);
309
+ return parseJsonBytes(input, numbers);
306
310
  if (input instanceof ArrayBuffer)
307
- return parseJsonBytes(new Uint8Array(input));
311
+ return parseJsonBytes(new Uint8Array(input), numbers);
308
312
  return normalizeValue(input);
309
313
  }
@@ -11,7 +11,7 @@ export declare const LIMITS: {
11
11
  readonly licence_url: 512;
12
12
  readonly keys: 64;
13
13
  };
14
- export declare const TYPES: readonly ["boolean", "date", "instant", "number", "string"];
14
+ export declare const TYPES: readonly ["blob", "boolean", "date", "instant", "number", "string"];
15
15
  export type TypeName = (typeof TYPES)[number];
16
16
  export declare const VALID_TIME_TYPES: readonly string[];
17
17
  export declare function isTypeName(t: unknown): t is TypeName;
@@ -32,6 +32,8 @@ export declare function checkKeyRef(s: string, path: string): {
32
32
  host: string;
33
33
  id: string;
34
34
  };
35
+ /** SPEC 3.1: a canonical decimal of at most 128 characters. */
36
+ export declare function isNumber(s: string): boolean;
35
37
  /** SPEC 3.1. Grammar (E_CANON_NUMBER) before the 128-character limit (E_CANON_LIMIT). */
36
38
  export declare function checkNumber(s: string, path: string): void;
37
39
  export declare function isDate(s: string): boolean;
@@ -48,6 +50,14 @@ export declare function checkString(s: string, path: string, opts?: {
48
50
  body?: boolean;
49
51
  limit?: number;
50
52
  }): void;
53
+ export declare const MEDIA_TYPE_MAX = 128;
54
+ /** SPEC 3.7. Grammar, then length <= 2^53 - 1, then media type <= 128 characters; all E_CANON_BLOB. */
55
+ export declare function checkBlob(s: string, path: string): {
56
+ sha256: string;
57
+ length: number;
58
+ mediaType: string;
59
+ };
60
+ export declare const URL_RE: RegExp;
51
61
  /**
52
62
  * SPEC 8.1 `licence`: an SPDX licence identifier or an https:// URL. SPEC.md gives no grammar;
53
63
  * this follows schemas/receipt.schema.json (and the reference tool): an identifier matches
@@ -17,7 +17,7 @@ export const LIMITS = {
17
17
  licence_url: 512,
18
18
  keys: 64,
19
19
  };
20
- export const TYPES = ['boolean', 'date', 'instant', 'number', 'string'];
20
+ export const TYPES = ['blob', 'boolean', 'date', 'instant', 'number', 'string'];
21
21
  export const VALID_TIME_TYPES = ['date', 'instant', 'string'];
22
22
  export function isTypeName(t) {
23
23
  return typeof t === 'string' && TYPES.includes(t);
@@ -87,6 +87,10 @@ export function checkKeyRef(s, path) {
87
87
  return { host, id };
88
88
  }
89
89
  const NUMBER_RE = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]*[1-9])?$/;
90
+ /** SPEC 3.1: a canonical decimal of at most 128 characters. */
91
+ export function isNumber(s) {
92
+ return NUMBER_RE.test(s) && s !== '-0' && s.length <= LIMITS.number;
93
+ }
90
94
  /** SPEC 3.1. Grammar (E_CANON_NUMBER) before the 128-character limit (E_CANON_LIMIT). */
91
95
  export function checkNumber(s, path) {
92
96
  if (!NUMBER_RE.test(s) || s === '-0')
@@ -146,8 +150,24 @@ export function checkString(s, path, opts = {}) {
146
150
  function hex4(cp) {
147
151
  return cp.toString(16).toUpperCase().padStart(4, '0');
148
152
  }
153
+ // SPEC 3.7: <sha256>:<length>:<type>/<subtype>, with RFC 6838 restricted names in lowercase.
154
+ const BLOB_RE = /^[0-9a-f]{64}:(0|[1-9][0-9]*):([a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*)$/;
155
+ const MAX_SAFE = '9007199254740991';
156
+ export const MEDIA_TYPE_MAX = 128;
157
+ /** SPEC 3.7. Grammar, then length <= 2^53 - 1, then media type <= 128 characters; all E_CANON_BLOB. */
158
+ export function checkBlob(s, path) {
159
+ const m = BLOB_RE.exec(s);
160
+ if (!m)
161
+ fail('E_CANON_BLOB', `not <sha256>:<length>:<type/subtype>: ${JSON.stringify(s)}`, path);
162
+ const len = m[1];
163
+ if (len.length > MAX_SAFE.length || (len.length === MAX_SAFE.length && len > MAX_SAFE))
164
+ fail('E_CANON_BLOB', `length above ${MAX_SAFE}`, path);
165
+ if (m[2].length > MEDIA_TYPE_MAX)
166
+ fail('E_CANON_BLOB', `media type longer than ${MEDIA_TYPE_MAX} characters`, path);
167
+ return { sha256: s.slice(0, 64), length: Number(len), mediaType: m[2] };
168
+ }
149
169
  const SPDX_RE = /^[A-Za-z0-9][A-Za-z0-9.+-]*$/;
150
- const URL_RE = /^https:\/\/[!-~]+$/;
170
+ export const URL_RE = /^https:\/\/[!-~]+$/;
151
171
  /**
152
172
  * SPEC 8.1 `licence`: an SPDX licence identifier or an https:// URL. SPEC.md gives no grammar;
153
173
  * this follows schemas/receipt.schema.json (and the reference tool): an identifier matches
@@ -182,6 +202,8 @@ export function checkTypedValue(type, value, path) {
182
202
  checkDate(value, path);
183
203
  else if (type === 'instant')
184
204
  checkInstant(value, path);
205
+ else if (type === 'blob')
206
+ checkBlob(value, path);
185
207
  else
186
208
  checkString(value, path);
187
209
  }
@@ -0,0 +1,4 @@
1
+ /** Literal units by group, in units.md order. Index units (units.md 2.4) are a pattern. */
2
+ export declare const UNIT_GROUPS: {
3
+ readonly [group: string]: readonly string[];
4
+ };
@@ -0,0 +1,8 @@
1
+ // GENERATED by tools/gen-units.mjs from spec/units.md (the `units-…` fenced blocks).
2
+ // Do not edit by hand; test/units.test.mjs checks that this file matches units.md.
3
+ /** Literal units by group, in units.md order. Index units (units.md 2.4) are a pattern. */
4
+ export const UNIT_GROUPS = {
5
+ currency: ["AED", "AFN", "ALL", "AMD", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT", "BHD", "BIF", "BMD", "BND", "BOB", "BOV", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD", "CAD", "CDF", "CHE", "CHF", "CHW", "CLF", "CLP", "CNY", "COP", "COU", "CRC", "CUP", "CVE", "CZK", "DJF", "DKK", "DOP", "DZD", "EGP", "ERN", "ETB", "EUR", "FJD", "FKP", "GBP", "GEL", "GHS", "GIP", "GMD", "GNF", "GTQ", "GYD", "HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK", "JMD", "JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", "KZT", "LAK", "LBP", "LKR", "LRD", "LSL", "LYD", "MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN", "MXV", "MYR", "MZN", "NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "OMR", "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", "RSD", "RUB", "RWF", "SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SOS", "SRD", "SSP", "STN", "SVC", "SYP", "SZL", "THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "USD", "USN", "UYI", "UYU", "UYW", "UZS", "VED", "VES", "VND", "VUV", "WST", "XAD", "XAF", "XAG", "XAU", "XBA", "XBB", "XBC", "XBD", "XCD", "XCG", "XDR", "XOF", "XPD", "XPF", "XPT", "XSU", "XTS", "XUA", "YER", "ZAR", "ZMW", "ZWG", "BGN", "CYP", "EEK", "GRD", "HRK", "LTL", "LVL", "MTL", "ROL", "SIT", "SKK", "TRL"],
6
+ physical: ["m", "kg", "s", "A", "K", "mol", "cd", "degC", "m/s", "W", "kWh", "mm", "cm", "km/h", "hPa", "W/m2"],
7
+ ratio: ["percent", "permille", "bp", "1", "count"],
8
+ };
@@ -0,0 +1,24 @@
1
+ import { type JsonObject, type JsonValue } from './json.js';
2
+ /** A currency code from units.md 2.1 (List One and the withdrawn codes). */
3
+ export declare function isCurrency(s: unknown): boolean;
4
+ /** units.md 2.4: index:<YYYY[-MM[-DD]]>=<number greater than 0>, at most 64 characters. */
5
+ export declare function isIndexUnit(s: string): boolean;
6
+ /** A literal unit (units.md section 2): a listed string or an index unit. Byte-for-byte. */
7
+ export declare function isUnitLiteral(s: unknown): boolean;
8
+ /**
9
+ * Step 2 for a unit: a string, {"arg": <string>}, or {"of": side, "per": side} where a side is
10
+ * a string or {"arg": <string>}. Anything else is E_SCHEMA (SPEC 7.1, units.md 1).
11
+ */
12
+ export declare function structUnit(u: JsonValue, path: string): void;
13
+ /**
14
+ * Step 3 for a unit whose shape passed structUnit. `args` is the signature's `args`: a
15
+ * reference must name one of them whose declared type is `string` (E_CANON_UNIT).
16
+ */
17
+ export declare function canonUnit(u: JsonValue, args: JsonObject, path: string): void;
18
+ /**
19
+ * units.md section 3: the unit of one cell's value, for a descriptor and a cell that have
20
+ * passed validation and whose fn_hash matches. `returns` is the signature's `returns`.
21
+ * A referenced argument must be bound (E_ARG_MISSING), not a slice (E_SLICE), of its declared
22
+ * type (E_ARG_TYPE), and a literal unit (E_CANON_UNIT); on a rate side, a currency.
23
+ */
24
+ export declare function resolveUnit(returns: JsonValue, cellArgs: JsonObject, path: string, argsPath: string): string | null;