@factoidal/core 0.20.0 → 0.21.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/lib/api.mjs CHANGED
@@ -30,6 +30,8 @@ import { fullEngineMessage, encodeJsValue } from './lean-entry.mjs';
30
30
  import { memoryDeltaLogStore } from './delta-log-hosts.mjs';
31
31
  import { FactoidalError, errorFromEnvelope, envelopeResult } from './errors.mjs';
32
32
  import { buildNodes } from './nodes.mjs';
33
+ import { runWithCipher } from './cipher-host.mjs';
34
+ import { cidFunctions } from './cid.mjs';
33
35
 
34
36
  // The format names, regimes and query outputs are the engine's tables (op
35
37
  // engineTables, formal/lean4/Wasm/Ops/Handles.lean), read once per engine:
@@ -265,6 +267,10 @@ class Store {
265
267
  * Where the deltaLog* functions keep their records
266
268
  * (lib/delta-log-hosts.mjs): IndexedDB in the browser entries. The
267
269
  * default keeps them in the memory of this process.
270
+ * @param {object} [driver.cidHost]
271
+ * Name resolution and the GET of cidRetrieveVerificationMethod
272
+ * (lib/cid-node.mjs under Node and Deno). The default is the browser
273
+ * host of lib/cid.mjs: the page's fetch, no name resolution.
268
274
  */
269
275
  function buildApi(driver) {
270
276
  let entryPromise; // memoized; a failed load is retried on the next call
@@ -655,6 +661,17 @@ function buildApi(driver) {
655
661
  const entail = opts.entail || 'none';
656
662
  const sparql12 = opts.sparql12 === true || String(opts.version || '') === '1.2';
657
663
  const e = c.e;
664
+ if (sparql12 && entail !== 'none' && opts.output === undefined) {
665
+ // SPARQL 1.2 under an entailment regime: the engine closes the stored
666
+ // dataset and runs the SPARQL 1.2 evaluator (op datasetQuery12Entail,
667
+ // https://github.com/danbri/factoidal/issues/1109).
668
+ requireEntryFn(e, 'datasetQuery12Entail', 'query {sparql12, entail}');
669
+ const r = await withExtensionRounds(() => entryResult(
670
+ e.datasetQuery12Entail(ds.handle, sparql, entail), 'query', sparql));
671
+ if (r.kind === 'ask') return r.boolean;
672
+ if (r.kind === 'construct' || r.kind === 'describe') return c.fromNQuads(r.nquads, ds.prefixes);
673
+ return bindingsFromSrj(r.srj);
674
+ }
658
675
  if (entail !== 'none' || opts.output !== undefined) {
659
676
  // queryRaw over the stored dataset: an entailment regime, or a
660
677
  // results format (SPARQL 1.1 Query Results JSON, XML, CSV, TSV).
@@ -664,15 +681,18 @@ function buildApi(driver) {
664
681
  requireEntryFn(e, 'datasetQueryRaw', `query {entail/output}`);
665
682
  if (opts.output === undefined) {
666
683
  // An entailment regime with the default answer shape.
667
- const r = entryResult(e.datasetQueryRaw(ds.handle, sparql, JSON.stringify({
668
- entail, output: graphForm ? 'ntriples' : 'json' })), 'query', sparql);
684
+ // withExtensionRounds: a registered async extension function
685
+ // (SPARQL 1.1 §17.6) is resolved here as in datasetQuery
686
+ // (https://github.com/danbri/factoidal/issues/1090).
687
+ const r = await withExtensionRounds(() => entryResult(e.datasetQueryRaw(ds.handle, sparql,
688
+ JSON.stringify({ entail, output: graphForm ? 'ntriples' : 'json' })), 'query', sparql));
669
689
  if (graphForm) return c.fromNQuads(r.text, ds.prefixes);
670
690
  const srj = JSON.parse(r.text);
671
691
  if (typeof srj.boolean === 'boolean') return srj.boolean;
672
692
  return bindingsFromSrj(srj);
673
693
  }
674
- const r = entryResult(e.datasetQueryRaw(ds.handle, sparql,
675
- JSON.stringify({ entail, output })), 'query', sparql);
694
+ const r = await withExtensionRounds(() => entryResult(e.datasetQueryRaw(ds.handle, sparql,
695
+ JSON.stringify({ entail, output })), 'query', sparql));
676
696
  return output === 'json' && !graphForm ? JSON.parse(r.text) : r.text;
677
697
  }
678
698
  const fn = sparql12 ? e.datasetQuery12 : e.datasetQuery;
@@ -910,17 +930,28 @@ function buildApi(driver) {
910
930
  * name first, so datasets that differ only in their graph names got the
911
931
  * same hash. To hash a graph whatever its name, use
912
932
  * canonicalHash(asDefaultGraph(g)).
913
- * @param {Dataset} dataset
933
+ *
934
+ * The input is what canonicalize() takes: a Dataset, or text (a string,
935
+ * or {text, format, baseIRI}), with the options {format, baseIRI}. Text is
936
+ * a JavaScript string: characters, not bytes. Decode bytes first (the RDF
937
+ * text syntaxes are UTF-8: `new TextDecoder('utf-8', { fatal: true })`);
938
+ * a Uint8Array is refused.
939
+ * @param {Dataset|string|{text: string, format?: string, baseIRI?: string}} data
940
+ * @param {{format?: string, baseIRI?: string}} [options]
914
941
  * @returns {Promise<string>}
915
942
  */
916
- async function canonicalHash(dataset) {
917
- if (!(dataset instanceof Dataset)) {
918
- throw new TypeError('canonicalHash: expected a Dataset');
943
+ async function canonicalHash(data, options) {
944
+ const hashOf = (e, ds) => {
945
+ requireEntryFn(e, 'datasetCanonicalHash', 'canonicalHash');
946
+ return entryResult(e.datasetCanonicalHash(ds.handle), 'canonicalHash').hash;
947
+ };
948
+ if (data instanceof Dataset && options === undefined) {
949
+ data.size; // throws the disposed error
950
+ return hashOf(data._ctx.e, data);
919
951
  }
920
- dataset.size; // throws the disposed error
921
- const c = dataset._ctx;
922
- requireEntryFn(c.e, 'datasetCanonicalHash', 'canonicalHash');
923
- return entryResult(c.e.datasetCanonicalHash(dataset.handle), 'canonicalHash').hash;
952
+ // The options are canonicalize()'s, checked by the engine's table for it.
953
+ const opts = await checkedOptions('canonicalize', optionsOf('canonicalize', options));
954
+ return withDataset(data, opts, 'canonicalHash', (c, ds) => hashOf(c.e, ds));
924
955
  }
925
956
 
926
957
  /**
@@ -960,6 +991,30 @@ function buildApi(driver) {
960
991
  }));
961
992
  }
962
993
 
994
+ /**
995
+ * The RDF merge of two datasets (RDF 1.1 Semantics section 5.2): a new
996
+ * Dataset that holds the quads of both, with the blank nodes of each input
997
+ * kept apart, so `_:b` in `a` and `_:b` in `b` are two blank nodes. The
998
+ * engine computes it (op datasetMerge, formal/lean4/Wasm/Ops/GraphNodes.lean,
999
+ * theorem mergeDatasets_isMerge); the prefixes are those of `a`, then those
1000
+ * of `b` whose label `a` does not declare. A text argument is read as by
1001
+ * parse() (`options.format`, `options.baseIRI`), in its own blank-node
1002
+ * scope. Since 0.21.0 (https://github.com/danbri/factoidal/issues/1130).
1003
+ * @param {Dataset|string|Array} a
1004
+ * @param {Dataset|string|Array} b
1005
+ * @param {{format?: string, baseIRI?: string}} [options] read text arguments
1006
+ * @returns {Promise<Dataset>}
1007
+ */
1008
+ async function datasetMerge(a, b, options) {
1009
+ // An engine built before the op refuses with op.unknown before the
1010
+ // options are read (its option table has no row for this function).
1011
+ requireEntryFn((await ctx()).e, 'datasetMerge', 'datasetMerge');
1012
+ const opts = await checkedOptions('datasetMerge', optionsOf('datasetMerge', options));
1013
+ return withDataset(a, opts, 'datasetMerge', (c, da) =>
1014
+ withDataset(b, opts, 'datasetMerge', (_c, db) =>
1015
+ c.wrap(entryResult(c.e.datasetMerge(da.handle, db.handle), 'datasetMerge'))));
1016
+ }
1017
+
963
1018
  /**
964
1019
  * A Dataset holding RDF/JS quads (op datasetFromQuadsJson). Each term is
965
1020
  * sent as its components; the engine reads no text.
@@ -1120,6 +1175,28 @@ function buildApi(driver) {
1120
1175
  return withDataset(data, opts, 'closure', (c, ds) => closureOnHandle(c, ds, regime));
1121
1176
  }
1122
1177
 
1178
+ /**
1179
+ * SPARQL 1.2 RL (SPARQL-RL) infer: apply a rule set to the default graph
1180
+ * of `data` and answer the inference graph (the triples the rules derive,
1181
+ * the rule set's DATA blocks included, that are not in `data`) as a new
1182
+ * Dataset. The rule set is SPARQL-RL text (https://w3c.github.io/data-shapes/sparql12-rl/).
1183
+ * A rule set that is not well-formed, not stratifiable, or has IMPORTS is
1184
+ * refused. Engine op: datasetSparqlRlInfer.
1185
+ * @param {Dataset|string|Array} data
1186
+ * @param {string} rules
1187
+ * @param {{format?: string, baseIRI?: string}} [options]
1188
+ * @returns {Promise<Dataset>}
1189
+ */
1190
+ async function sparqlRlInfer(data, rules, options) {
1191
+ const opts = await checkedOptions('sparqlRlInfer', optionsOf('sparqlRlInfer', options));
1192
+ if (typeof rules !== 'string') throw new TypeError('sparqlRlInfer: rules must be a string');
1193
+ return withDataset(data, opts, 'sparqlRlInfer(data)', (c, ds) => {
1194
+ requireEntryFn(c.e, 'datasetSparqlRlInfer', 'sparqlRlInfer');
1195
+ const r = entryResult(c.e.datasetSparqlRlInfer(ds.handle, rules), 'sparqlRlInfer');
1196
+ return c.wrap(r, ds.prefixes);
1197
+ });
1198
+ }
1199
+
1123
1200
  /**
1124
1201
  * RDFS or OWL 2 RL closure of the default graph, as a new Dataset.
1125
1202
  * @param {Dataset|string|Array} data
@@ -1652,6 +1729,136 @@ function buildApi(driver) {
1652
1729
  e.vcEd25519Verify(publicKeyHex, messageHex, signatureHex), 'vcEd25519Verify').valid;
1653
1730
  }
1654
1731
 
1732
+ // -----------------------------------------------------------------
1733
+ // XML Encryption 1.1 and JWE (https://github.com/danbri/factoidal/issues/1084,
1734
+ // https://github.com/danbri/factoidal/issues/1085). Every decision is in
1735
+ // Lean (formal/lean4/Wasm/Ops/XmlEnc.lean, Wasm/Ops/Jwe.lean); RSA, P-256
1736
+ // and X25519 run in the wasm (HACL*). AES runs in this host's WebCrypto:
1737
+ // runWithCipher (lib/cipher-host.mjs) answers each request the engine
1738
+ // makes and calls the op again. This code converts bytes to base64 and
1739
+ // draws random bytes; it reads no document and chooses nothing.
1740
+ // -----------------------------------------------------------------
1741
+
1742
+ // The most random octets any of these ops reads (Jwe.randomBytesNeeded is
1743
+ // at most 112; xmlEncrypt reads at most 76).
1744
+ const CIPHER_RANDOM_BYTES = 112;
1745
+
1746
+ function bytesToBase64(bytes) {
1747
+ let bin = '';
1748
+ for (let i = 0; i < bytes.length; i += 0x8000) {
1749
+ bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
1750
+ }
1751
+ return btoa(bin);
1752
+ }
1753
+
1754
+ function base64ToBytes(text) {
1755
+ const bin = atob(text);
1756
+ const out = new Uint8Array(bin.length);
1757
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
1758
+ return out;
1759
+ }
1760
+
1761
+ function randomBase64(n) {
1762
+ const b = new Uint8Array(n);
1763
+ globalThis.crypto.getRandomValues(b);
1764
+ return bytesToBase64(b);
1765
+ }
1766
+
1767
+ // One op that may ask for AES: the engine's envelope after every request
1768
+ // is answered; a refusal throws a FactoidalError.
1769
+ async function cipherOp(fnName, what, args) {
1770
+ const e = await entry();
1771
+ requireEntryFn(e, fnName, what);
1772
+ const res = await runWithCipher((a) => JSON.parse(e[fnName](JSON.stringify(a))), args);
1773
+ return entryResult(JSON.stringify(res), fnName);
1774
+ }
1775
+
1776
+ function checkKeys(fnName, keys) {
1777
+ if (!Array.isArray(keys) || !keys.every((k) => k !== null && typeof k === 'object')) {
1778
+ throw new TypeError(`${fnName}: keys must be an array of JWK objects`);
1779
+ }
1780
+ }
1781
+
1782
+ /**
1783
+ * Decrypt every xenc:EncryptedData of an XML document (XML Encryption 1.1).
1784
+ * Allowlist and key forms: API.md "XML Encryption and JWE".
1785
+ * @param {string} document the XML document
1786
+ * @param {{keys: object[], allowCbc?: boolean}} options private JWKs
1787
+ * (`kid` is the ds:KeyName); `allowCbc` admits aes128-cbc and aes256-cbc
1788
+ * @returns {Promise<{document: string, canonical: string}>}
1789
+ */
1790
+ async function xmlDecrypt(document, options) {
1791
+ if (typeof document !== 'string') throw new TypeError('xmlDecrypt: document must be a string');
1792
+ const o = options || {};
1793
+ checkKeys('xmlDecrypt', o.keys);
1794
+ const r = await cipherOp('xmlDecrypt', 'XML decryption',
1795
+ { document, keys: o.keys, allowCbc: o.allowCbc === true });
1796
+ return { document: r.document, canonical: r.canonical };
1797
+ }
1798
+
1799
+ /**
1800
+ * Encrypt the document element (or, with `content`, its content) of an XML
1801
+ * document for one recipient (XML Encryption 1.1, AES-GCM content).
1802
+ * @param {string} document the XML document
1803
+ * @param {{recipient: object, algorithm?: 'aes128-gcm'|'aes256-gcm',
1804
+ * keyTransport?: 'rsa-oaep-mgf1p'|'rsa-oaep-sha256', keyWrap?: 'kw-aes128'|'kw-aes256',
1805
+ * content?: boolean, saml?: boolean}} options `recipient` is a public JWK
1806
+ * (RSA, EC P-256, OKP X25519) or an `oct` key; `saml` wraps the result in
1807
+ * saml:EncryptedAssertion
1808
+ * @returns {Promise<{document: string, canonical: string}>}
1809
+ */
1810
+ async function xmlEncrypt(document, options) {
1811
+ if (typeof document !== 'string') throw new TypeError('xmlEncrypt: document must be a string');
1812
+ const o = options || {};
1813
+ if (o.recipient === null || typeof o.recipient !== 'object') {
1814
+ throw new TypeError('xmlEncrypt: options.recipient must be a JWK object');
1815
+ }
1816
+ const args = { document, recipient: o.recipient, random: randomBase64(CIPHER_RANDOM_BYTES) };
1817
+ for (const k of ['algorithm', 'keyTransport', 'keyWrap']) if (o[k] !== undefined) args[k] = o[k];
1818
+ if (o.content === true) args.content = true;
1819
+ if (o.saml === true) args.saml = true;
1820
+ const r = await cipherOp('xmlEncrypt', 'XML encryption', args);
1821
+ return { document: r.document, canonical: r.canonical };
1822
+ }
1823
+
1824
+ /**
1825
+ * Decrypt a JWE in the Compact Serialization (RFC 7516).
1826
+ * @param {string} token the compact JWE
1827
+ * @param {{jwk: object}} options the recipient's private JWK
1828
+ * @returns {Promise<{header: object, plaintext: Uint8Array}>}
1829
+ */
1830
+ async function jweDecrypt(token, options) {
1831
+ if (typeof token !== 'string') throw new TypeError('jweDecrypt: token must be a string');
1832
+ const o = options || {};
1833
+ if (o.jwk === null || typeof o.jwk !== 'object') throw new TypeError('jweDecrypt: options.jwk must be a JWK object');
1834
+ const r = await cipherOp('jweDecrypt', 'JWE decryption', { token, jwk: o.jwk });
1835
+ return { header: r.header, plaintext: base64ToBytes(r.plaintext) };
1836
+ }
1837
+
1838
+ /**
1839
+ * Encrypt to a JWE in the Compact Serialization (RFC 7516).
1840
+ * @param {Uint8Array|string} plaintext bytes, or text written as UTF-8
1841
+ * @param {{alg: string, enc: string, jwk: object, header?: object}} options
1842
+ * the recipient's public JWK (or the `oct` key for A128KW, A256KW, dir);
1843
+ * `header`: more protected header members
1844
+ * @returns {Promise<string>} the compact JWE
1845
+ */
1846
+ async function jweEncrypt(plaintext, options) {
1847
+ let bytes;
1848
+ if (typeof plaintext === 'string') bytes = new TextEncoder().encode(plaintext);
1849
+ else if (plaintext instanceof Uint8Array) bytes = plaintext;
1850
+ else throw new TypeError('jweEncrypt: plaintext must be a Uint8Array or a string');
1851
+ const o = options || {};
1852
+ if (typeof o.alg !== 'string' || typeof o.enc !== 'string') {
1853
+ throw new TypeError('jweEncrypt: options.alg and options.enc must be strings');
1854
+ }
1855
+ if (o.jwk === null || typeof o.jwk !== 'object') throw new TypeError('jweEncrypt: options.jwk must be a JWK object');
1856
+ const args = { alg: o.alg, enc: o.enc, jwk: o.jwk, plaintext: bytesToBase64(bytes),
1857
+ random: randomBase64(CIPHER_RANDOM_BYTES) };
1858
+ if (o.header !== undefined) args.header = o.header;
1859
+ return (await cipherOp('jweEncrypt', 'JWE encryption', args)).token;
1860
+ }
1861
+
1655
1862
  /**
1656
1863
  * Create an eddsa-rdfc-2022 Data Integrity proofValue over an
1657
1864
  * already-canonicalized document + proof config (entry_jsoo.ml's
@@ -2282,11 +2489,14 @@ function buildApi(driver) {
2282
2489
  // ops it actually has.
2283
2490
  shacl: has('shaclValidate'),
2284
2491
  shex: has('shexValidate'),
2492
+ sparqlRl: has('datasetSparqlRlInfer'),
2285
2493
  owlClosure: has('owlClosure'),
2286
2494
  explain: has('explainGraph'),
2287
2495
  stages: has('stageList'),
2288
2496
  pipelineCheck: has('pipelineCheck'),
2289
2497
  pipelineCheckNodes: has('pipelineCheckNodes'),
2498
+ graphCheck: has('graphCheckNodes'),
2499
+ datasetMerge: has('datasetMerge'),
2290
2500
  entailment: has('queryRaw'),
2291
2501
  tableau: has('tableauMaterialise') && has('tableauDlInconsistent'),
2292
2502
  rml: has('rmlMap'),
@@ -2318,6 +2528,7 @@ function buildApi(driver) {
2318
2528
  store: has('storeOpen') && has('storeHandleQuery'),
2319
2529
  deltaLog: has('deltaLogEncode') && has('deltaLogDecode') && has('deltaLogMerge'),
2320
2530
  literalValues: has('literalValue') && has('literalFromValue'),
2531
+ cid: has('cidValidate') && has('cidRetrieve'),
2321
2532
  };
2322
2533
  }
2323
2534
 
@@ -2421,8 +2632,12 @@ function buildApi(driver) {
2421
2632
  const nodeApi = buildNodes({
2422
2633
  entry, entryResult, requireEntryFn, Dataset,
2423
2634
  parse, closure, shaclValidate, query, serialize, canonicalize, canonicalHash, explain, csvwToRdf,
2635
+ datasetMerge,
2424
2636
  });
2425
2637
 
2638
+ // Controlled Identifiers v1.0 (lib/cid.mjs, issue 1100 items 7 and 9).
2639
+ const cid = cidFunctions({ entry, entryResult, requireEntryFn, host: driver.cidHost });
2640
+
2426
2641
  return {
2427
2642
  parse,
2428
2643
  query,
@@ -2443,6 +2658,7 @@ function buildApi(driver) {
2443
2658
  asDefaultGraph,
2444
2659
  shaclValidate,
2445
2660
  shexValidate,
2661
+ sparqlRlInfer,
2446
2662
  owlClosure,
2447
2663
  coreRdfsClosure,
2448
2664
  coreRdfsCheck,
@@ -2450,6 +2666,7 @@ function buildApi(driver) {
2450
2666
  stages: nodeApi.stages,
2451
2667
  node: nodeApi.node,
2452
2668
  pipe: nodeApi.pipe,
2669
+ graph: nodeApi.graph,
2453
2670
  run: nodeApi.run,
2454
2671
  rdfsPlusClosure,
2455
2672
  rhoDfClosure,
@@ -2484,12 +2701,18 @@ function buildApi(driver) {
2484
2701
  vcEd25519SecretToPublic,
2485
2702
  vcEd25519Sign,
2486
2703
  vcEd25519Verify,
2704
+ xmlDecrypt,
2705
+ xmlEncrypt,
2706
+ jweDecrypt,
2707
+ jweEncrypt,
2487
2708
  vcEddsaCreateFromCanonical,
2488
2709
  vcEddsaVerifyFromCanonical,
2489
2710
  vcCheckCredential,
2490
2711
  vcCheckCredentialSubject,
2491
2712
  vcCheckNoDataLoss,
2492
2713
  vcCheckRelatedResourceDigests,
2714
+ cidValidate: cid.cidValidate,
2715
+ cidRetrieveVerificationMethod: cid.cidRetrieveVerificationMethod,
2493
2716
  capabilities,
2494
2717
  openStore,
2495
2718
  queryStore,
@@ -2503,6 +2726,7 @@ function buildApi(driver) {
2503
2726
  _deltaLogCorruptLastForTest,
2504
2727
  queryRaw,
2505
2728
  isomorphic,
2729
+ datasetMerge,
2506
2730
  fromQuads,
2507
2731
  literalValue,
2508
2732
  isValidLiteral,
@@ -13,6 +13,18 @@
13
13
  //
14
14
  // No Node built-ins: the browser imports this file as it is.
15
15
 
16
+ import { checkEngineStamp } from './engine-integrity.mjs';
17
+
18
+ // version.json next to the engine, or null (absent, unreadable, not JSON).
19
+ async function stampOf(url) {
20
+ try {
21
+ const r = await fetch(url);
22
+ return r.ok ? await r.json() : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
16
28
  async function instantiate(url, imports) {
17
29
  try {
18
30
  return await WebAssembly.instantiateStreaming(fetch(url), imports);
@@ -59,7 +71,22 @@ export function browserEngine(name, defaultAssetsUrl) {
59
71
  // newer loader (the loader's own locateFile does the same).
60
72
  const version = loader.wasmVersion ? `?v=${loader.wasmVersion}` : '';
61
73
  const wasmUrl = new URL(`${name}.wasm`, assetsUrl).href + version;
62
- return loader.loadL4({ instantiateWasm: (imports) => instantiate(wasmUrl, imports) });
74
+ // The release stamp check (engine-integrity.mjs, issue 1106): with a
75
+ // stamped version.json next to the full engine, its bytes are hashed
76
+ // before they are compiled (no streaming compile then). Without one,
77
+ // the streaming path as before.
78
+ const stamp = name === 'l4factoidal'
79
+ ? await stampOf(new URL('version.json', assetsUrl).href + version) : null;
80
+ if (!stamp || stamp.stamped !== true) {
81
+ return loader.loadL4({ instantiateWasm: (imports) => instantiate(wasmUrl, imports) });
82
+ }
83
+ const response = await fetch(wasmUrl);
84
+ if (!response.ok) {
85
+ throw new Error(`factoidal: fetching ${wasmUrl} failed: ${response.status} ${response.statusText}`);
86
+ }
87
+ const bytes = new Uint8Array(await response.arrayBuffer());
88
+ await checkEngineStamp(bytes, stamp, { where: wasmUrl });
89
+ return loader.loadL4({ instantiateWasm: (imports) => WebAssembly.instantiate(bytes, imports) });
63
90
  })();
64
91
  enginePromise.catch(() => { enginePromise = null; });
65
92
  return enginePromise;
@@ -0,0 +1,77 @@
1
+ // The Node and Deno host of cidRetrieveVerificationMethod (lib/cid.mjs,
2
+ // https://github.com/danbri/factoidal/issues/1100 item 9): name resolution,
3
+ // and a GET that connects to the one address the engine chose, so a second
4
+ // answer of a name server cannot move the request (the authorization
5
+ // server's fetch, authz/server.mjs). Pure I/O: the engine decides which
6
+ // URL, which address, and what the answer means
7
+ // (formal/lean4/L4Factoidal/CID/Fetch.lean). No redirect is followed.
8
+
9
+ const FETCH_TIMEOUT_MS = 10000;
10
+ const MAX_DOCUMENT_BYTES = 1048576;
11
+
12
+ /** Every address of a name, as the system resolver gives them. */
13
+ async function resolve(host) {
14
+ try {
15
+ const dns = await import('node:dns');
16
+ const found = await dns.promises.lookup(host, { all: true, verbatim: true });
17
+ return found.map((a) => a.address);
18
+ } catch (_error) {
19
+ return [];
20
+ }
21
+ }
22
+
23
+ // A Response-like answer: status, headers.get, text().
24
+ function answer(status, contentType, body) {
25
+ return {
26
+ status,
27
+ headers: { get: (name) => (String(name).toLowerCase() === 'content-type' ? contentType : null) },
28
+ text: async () => body,
29
+ };
30
+ }
31
+
32
+ /**
33
+ * GET `url` from `init.address`, sending `init.headers`. With no address
34
+ * (the engine named none), the system resolver connects.
35
+ */
36
+ async function fetch(url, init) {
37
+ let target;
38
+ try {
39
+ target = new URL(url);
40
+ } catch (_error) {
41
+ return answer(0, '', '');
42
+ }
43
+ const mod = target.protocol === 'https:' ? await import('node:https') : await import('node:http');
44
+ const address = init && typeof init.address === 'string' ? init.address : null;
45
+ const family = address && address.indexOf(':') >= 0 ? 6 : 4;
46
+ const options = { method: 'GET', headers: (init && init.headers) || {}, timeout: FETCH_TIMEOUT_MS };
47
+ if (address) {
48
+ options.lookup = (_host, opts, callback) => {
49
+ if (opts && opts.all) callback(null, [{ address, family }]);
50
+ else callback(null, address, family);
51
+ };
52
+ }
53
+ return new Promise((done) => {
54
+ const request = mod.request(target, options, (response) => {
55
+ const chunks = [];
56
+ let size = 0;
57
+ response.on('data', (chunk) => {
58
+ size += chunk.length;
59
+ if (size > MAX_DOCUMENT_BYTES) {
60
+ response.destroy();
61
+ done(answer(0, '', ''));
62
+ return;
63
+ }
64
+ chunks.push(chunk);
65
+ });
66
+ response.on('end', () => done(answer(response.statusCode || 0,
67
+ String(response.headers['content-type'] || ''), Buffer.concat(chunks).toString('utf8'))));
68
+ response.on('error', () => done(answer(0, '', '')));
69
+ });
70
+ request.on('timeout', () => request.destroy(new Error('timeout')));
71
+ request.on('error', () => done(answer(0, '', '')));
72
+ request.end();
73
+ });
74
+ }
75
+
76
+ /** The host index.mjs passes to the API. */
77
+ export const nodeCidHost = Object.freeze({ resolve, fetch });
package/lib/cid.mjs ADDED
@@ -0,0 +1,146 @@
1
+ // Controlled Identifiers v1.0 (https://www.w3.org/TR/cid-1.0/):
2
+ // cidValidate and cidRetrieveVerificationMethod
3
+ // (https://github.com/danbri/factoidal/issues/1100, items 7 and 9).
4
+ //
5
+ // The engine decides everything (formal/lean4/L4Factoidal/CID/Validate.lean,
6
+ // CID/Fetch.lean, over the ops cidValidate and cidRetrieve of
7
+ // formal/lean4/Wasm/Ops/Cid.lean): the §2 checks; which URL to fetch; whether
8
+ // its scheme and addresses may be fetched (the private-address guard of the
9
+ // authorization server); whether the status and the Content-Type of the
10
+ // answer are accepted; §3.3; expiry and revocation with `now`. This file
11
+ // moves bytes: it resolves the host name the engine names, GETs the URL the
12
+ // engine names from the address the engine names, and passes the status,
13
+ // the Content-Type and the body back.
14
+ //
15
+ // ES module, no Node built-ins: the browser loads this file as it is. The
16
+ // Node and Deno host functions (name resolution, a GET pinned to one
17
+ // address) are lib/cid-node.mjs, which index.mjs passes in.
18
+
19
+ import { FactoidalError } from './errors.mjs';
20
+
21
+ // The engine names at most one resolution and one fetch; a longer loop is
22
+ // a defect, not a document.
23
+ const MAX_STEPS = 4;
24
+
25
+ /**
26
+ * The browser host: no name resolution (a page cannot resolve names; the
27
+ * engine then checks only IP literals and localhost names), and the page's
28
+ * fetch, which does not follow redirects and sends no credentials.
29
+ */
30
+ export const browserCidHost = Object.freeze({
31
+ resolve: null,
32
+ fetch: (url, init) => globalThis.fetch(url, {
33
+ method: 'GET',
34
+ headers: init.headers,
35
+ redirect: 'manual',
36
+ credentials: 'omit',
37
+ }),
38
+ });
39
+
40
+ // The status, Content-Type and body of a fetch answer: a WHATWG Response,
41
+ // or an object of the same shape.
42
+ async function answerOf(url, response) {
43
+ const status = Number(response && response.status) || 0;
44
+ const headers = response && response.headers;
45
+ const contentType = headers && typeof headers.get === 'function'
46
+ ? (headers.get('content-type') || '') : '';
47
+ const body = response && typeof response.text === 'function' ? String(await response.text()) : '';
48
+ return { url, status, contentType, body };
49
+ }
50
+
51
+ function nowSeconds(now) {
52
+ if (now === undefined || now === null) return undefined;
53
+ if (now instanceof Date) return Math.floor(now.getTime() / 1000);
54
+ if (typeof now === 'number' && Number.isFinite(now)) return Math.floor(now);
55
+ throw new TypeError('cidRetrieveVerificationMethod: options.now must be a Date or a number of seconds');
56
+ }
57
+
58
+ /**
59
+ * The two functions over an entry (lib/lean-entry.mjs) and a host.
60
+ * @param {object} deps
61
+ * @param {() => Promise<object>} deps.entry
62
+ * @param {(jsonText: string, what: string) => object} deps.entryResult
63
+ * @param {(e: object, fn: string, what: string) => void} deps.requireEntryFn
64
+ * @param {{resolve: ((host: string) => Promise<string[]>)|null, fetch: Function}} [deps.host]
65
+ */
66
+ export function cidFunctions({ entry, entryResult, requireEntryFn, host }) {
67
+ const defaults = host || browserCidHost;
68
+
69
+ /**
70
+ * CID 1.0 §2: check a controlled identifier document.
71
+ * @param {string|object} doc JSON text, or a value JSON.stringify writes
72
+ * @returns {Promise<{valid: boolean, errors: Array<{code: string, message: string, path: string}>}>}
73
+ */
74
+ async function cidValidate(doc) {
75
+ const text = typeof doc === 'string' ? doc : JSON.stringify(doc);
76
+ if (typeof text !== 'string') throw new TypeError('cidValidate: doc must be JSON text or a JSON value');
77
+ const e = await entry();
78
+ requireEntryFn(e, 'cidValidate', 'CID 1.0 document check');
79
+ const r = entryResult(e.cidValidate(text), 'cidValidate');
80
+ return { valid: r.valid === true, errors: Array.isArray(r.errors) ? r.errors : [] };
81
+ }
82
+
83
+ /**
84
+ * CID 1.0 §3.3 Retrieve Verification Method, with the fetch done here.
85
+ * @param {string} url the verification method URL
86
+ * @param {string} relationship a verification relationship of §2.3
87
+ * @param {object} [options]
88
+ * @returns {Promise<{ok: true, verificationMethod: object, controllerDocument: string}
89
+ * | {ok: false, code: string, problem: {type: string, title: string, detail: string}}>}
90
+ */
91
+ async function cidRetrieveVerificationMethod(url, relationship, options) {
92
+ if (typeof url !== 'string' || typeof relationship !== 'string') {
93
+ throw new TypeError('cidRetrieveVerificationMethod: url and relationship must be strings');
94
+ }
95
+ const o = options || {};
96
+ const fetchFn = typeof o.fetch === 'function' ? o.fetch : defaults.fetch;
97
+ // `resolve: null` says the caller cannot resolve names.
98
+ const resolveFn = 'resolve' in o ? o.resolve : defaults.resolve;
99
+ const request = {
100
+ vm: url,
101
+ relationship,
102
+ allowHttp: o.allowHttp === true,
103
+ allowPrivateFetch: o.allowPrivateFetch === true,
104
+ resolver: typeof resolveFn === 'function',
105
+ resolved: [],
106
+ fetched: [],
107
+ };
108
+ const now = nowSeconds(o.now);
109
+ if (now !== undefined) request.now = now;
110
+ const e = await entry();
111
+ requireEntryFn(e, 'cidRetrieve', 'CID 1.0 Retrieve Verification Method');
112
+ for (let i = 0; i < MAX_STEPS; i += 1) {
113
+ const r = entryResult(e.cidRetrieve(JSON.stringify(request)), 'cidRetrieve');
114
+ if (r.step === 'method') {
115
+ return { ok: true, verificationMethod: r.verificationMethod, controllerDocument: r.controllerDocument };
116
+ }
117
+ if (r.step === 'error') return { ok: false, code: r.code, problem: r.problem };
118
+ if (r.step === 'resolve') {
119
+ let addresses = [];
120
+ try {
121
+ const got = await resolveFn(r.host);
122
+ addresses = Array.isArray(got) ? got.map(String) : [];
123
+ } catch (_error) {
124
+ addresses = [];
125
+ }
126
+ request.resolved.push({ host: r.host, addresses });
127
+ } else if (r.step === 'fetch') {
128
+ let answer;
129
+ try {
130
+ const init = { method: 'GET', headers: { accept: r.accept }, redirect: 'manual' };
131
+ if (r.address !== null && r.address !== undefined) init.address = r.address;
132
+ answer = await answerOf(r.url, await fetchFn(r.url, init));
133
+ } catch (_error) {
134
+ answer = { url: r.url, status: 0, contentType: '', body: '' };
135
+ }
136
+ request.fetched.push(answer);
137
+ } else {
138
+ break;
139
+ }
140
+ }
141
+ throw new FactoidalError('cidRetrieveVerificationMethod: the engine asked for more steps than one retrieval needs',
142
+ { code: 'op.failed' });
143
+ }
144
+
145
+ return { cidValidate, cidRetrieveVerificationMethod };
146
+ }