@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/CHANGELOG.md +49 -0
- package/README.md +38 -2
- package/authz/server.mjs +23 -2
- package/bin/engine.mjs +22 -1
- package/bin/factoidal.mjs +710 -38
- package/bin/pack-host.mjs +5 -0
- package/bin/serve.mjs +19 -3
- package/browser.d.ts +10 -0
- package/browser.js +12 -0
- package/index.d.ts +248 -9
- package/index.mjs +15 -0
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/api.mjs +236 -12
- package/lib/browser-engine.mjs +28 -1
- package/lib/cid-node.mjs +77 -0
- package/lib/cid.mjs +146 -0
- package/lib/cipher-host.mjs +126 -0
- package/lib/engine-integrity.mjs +56 -0
- package/lib/nodes.mjs +234 -11
- package/lib/sbm11.mjs +14 -1
- package/lib/version.mjs +1 -1
- package/lws/README.md +9 -0
- package/lws/client.mjs +19 -2
- package/lws/persist.mjs +169 -0
- package/lws/server.mjs +82 -29
- package/package.json +2 -1
- package/solid/server/index.mjs +79 -13
- package/webdav/server.mjs +154 -25
- package/xmpp/client.mjs +332 -0
- package/xmpp/proxy.mjs +114 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// cipher-host.mjs — the JavaScript host side of the AES call-out
|
|
2
|
+
// (https://github.com/danbri/factoidal/issues/1081,
|
|
3
|
+
// https://github.com/danbri/factoidal/issues/677; design record
|
|
4
|
+
// docs/designissues/2026-10-27-xml-encryption-and-jwe.md, section "The AES
|
|
5
|
+
// call-out on JavaScript hosts"; request format
|
|
6
|
+
// formal/lean4/L4Factoidal/Crypto/CipherCall.lean).
|
|
7
|
+
//
|
|
8
|
+
// The engine (Lean, in the wasm) makes every decision: the algorithm, the key,
|
|
9
|
+
// the IV, the lengths (`Req.wellFormed`), the document structure. When it needs
|
|
10
|
+
// AES it answers `{need: {op, key, iv, aad, data}}` (bytes as base64). This
|
|
11
|
+
// module runs exactly that one WebCrypto operation and gives back
|
|
12
|
+
// `{ok: base64}` or `{refused: true}`. It reads no document, chooses no
|
|
13
|
+
// algorithm and checks no length. Every failure becomes the same refusal with
|
|
14
|
+
// no message, so no error text of the host's crypto library reaches the caller
|
|
15
|
+
// (padding oracle, Jager and Somorovsky 2011).
|
|
16
|
+
//
|
|
17
|
+
// Only `globalThis.crypto.subtle` is used, so the same code runs in Node 22,
|
|
18
|
+
// Deno and browsers (Node: OpenSSL; Chromium: BoringSSL; Firefox: NSS).
|
|
19
|
+
//
|
|
20
|
+
// PBKDF2 (https://github.com/danbri/factoidal/issues/1108, the login of
|
|
21
|
+
// `factoidal webdav-serve` and `factoidal webdav-user`; owner, 2026-10-28:
|
|
22
|
+
// PBKDF2-HMAC-SHA256 from WebCrypto, as AES): the engine answers
|
|
23
|
+
// `{need: {op: 'pbkdf2-hmac-sha256', key, data, iterations, bits}}` with the
|
|
24
|
+
// password as `key` and the salt as `data`. This module runs exactly that
|
|
25
|
+
// `deriveBits` call; Lean chose the salt, the count and the output size
|
|
26
|
+
// (formal/lean4/L4Factoidal/WebDAV/Login.lean) and compares the result.
|
|
27
|
+
//
|
|
28
|
+
// Key Wrap: WebCrypto wraps a CryptoKey, not bytes. The bytes to wrap are
|
|
29
|
+
// imported as an extractable raw HMAC key (any byte length), wrapped with
|
|
30
|
+
// `wrapKey('raw', ...)`; an unwrap gives an HMAC key whose raw export is the
|
|
31
|
+
// unwrapped bytes.
|
|
32
|
+
|
|
33
|
+
const subtle = () => globalThis.crypto.subtle;
|
|
34
|
+
|
|
35
|
+
/** The most requests one engine operation may make (CipherCall.maxRequests). */
|
|
36
|
+
export const MAX_CIPHER_REQUESTS = 64;
|
|
37
|
+
|
|
38
|
+
function fromBase64(s) {
|
|
39
|
+
const bin = atob(s ?? '');
|
|
40
|
+
const out = new Uint8Array(bin.length);
|
|
41
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toBase64(buf) {
|
|
46
|
+
const b = new Uint8Array(buf);
|
|
47
|
+
let bin = '';
|
|
48
|
+
for (let i = 0; i < b.length; i += 0x8000) {
|
|
49
|
+
bin += String.fromCharCode.apply(null, b.subarray(i, i + 0x8000));
|
|
50
|
+
}
|
|
51
|
+
return btoa(bin);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const HMAC = { name: 'HMAC', hash: 'SHA-256' };
|
|
55
|
+
|
|
56
|
+
// One entry per op name of CipherCall.Op.name: the WebCrypto call, nothing more.
|
|
57
|
+
const OPS = {
|
|
58
|
+
'aes-gcm-encrypt': async (k, iv, aad, d) => {
|
|
59
|
+
const key = await subtle().importKey('raw', k, 'AES-GCM', false, ['encrypt']);
|
|
60
|
+
return subtle().encrypt({ name: 'AES-GCM', iv, additionalData: aad, tagLength: 128 }, key, d);
|
|
61
|
+
},
|
|
62
|
+
'aes-gcm-decrypt': async (k, iv, aad, d) => {
|
|
63
|
+
const key = await subtle().importKey('raw', k, 'AES-GCM', false, ['decrypt']);
|
|
64
|
+
return subtle().decrypt({ name: 'AES-GCM', iv, additionalData: aad, tagLength: 128 }, key, d);
|
|
65
|
+
},
|
|
66
|
+
'aes-kw-wrap': async (k, _iv, _aad, d) => {
|
|
67
|
+
const kek = await subtle().importKey('raw', k, 'AES-KW', false, ['wrapKey']);
|
|
68
|
+
const inner = await subtle().importKey('raw', d, HMAC, true, ['sign']);
|
|
69
|
+
return subtle().wrapKey('raw', inner, kek, 'AES-KW');
|
|
70
|
+
},
|
|
71
|
+
'aes-kw-unwrap': async (k, _iv, _aad, d) => {
|
|
72
|
+
const kek = await subtle().importKey('raw', k, 'AES-KW', false, ['unwrapKey']);
|
|
73
|
+
const inner = await subtle().unwrapKey('raw', d, kek, 'AES-KW', HMAC, true, ['sign']);
|
|
74
|
+
return subtle().exportKey('raw', inner);
|
|
75
|
+
},
|
|
76
|
+
'aes-cbc-encrypt': async (k, iv, _aad, d) => {
|
|
77
|
+
const key = await subtle().importKey('raw', k, 'AES-CBC', false, ['encrypt']);
|
|
78
|
+
return subtle().encrypt({ name: 'AES-CBC', iv }, key, d);
|
|
79
|
+
},
|
|
80
|
+
'aes-cbc-decrypt': async (k, iv, _aad, d) => {
|
|
81
|
+
const key = await subtle().importKey('raw', k, 'AES-CBC', false, ['decrypt']);
|
|
82
|
+
return subtle().decrypt({ name: 'AES-CBC', iv }, key, d);
|
|
83
|
+
},
|
|
84
|
+
'pbkdf2-hmac-sha256': async (k, _iv, _aad, d, req) => {
|
|
85
|
+
const key = await subtle().importKey('raw', k, 'PBKDF2', false, ['deriveBits']);
|
|
86
|
+
return subtle().deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: d, iterations: req.iterations }, key, req.bits);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Answer one cipher request of the engine (AES, or the PBKDF2 of a login).
|
|
92
|
+
* @param {{op: string, key: string, iv?: string, aad?: string, data: string, iterations?: number, bits?: number}} req bytes as base64
|
|
93
|
+
* @returns {Promise<{ok: string} | {refused: true}>}
|
|
94
|
+
*/
|
|
95
|
+
export async function answerCipherRequest(req) {
|
|
96
|
+
try {
|
|
97
|
+
const run = Object.prototype.hasOwnProperty.call(OPS, req?.op) ? OPS[req.op] : null;
|
|
98
|
+
if (!run) return { refused: true };
|
|
99
|
+
const out = await run(fromBase64(req.key), fromBase64(req.iv), fromBase64(req.aad), fromBase64(req.data), req);
|
|
100
|
+
return { ok: toBase64(out) };
|
|
101
|
+
} catch {
|
|
102
|
+
return { refused: true };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run an engine operation that may ask for AES. `callOp(args)` calls the wasm
|
|
108
|
+
* op (sync or async) with `args` plus `answers`; while its result has `need`,
|
|
109
|
+
* the request is answered and the op is called again with the same inputs and
|
|
110
|
+
* the answer list extended by `{request, answer}`. The engine's format code is
|
|
111
|
+
* deterministic, so each call gets one request further.
|
|
112
|
+
* @param {(args: object) => any} callOp
|
|
113
|
+
* @param {object} args
|
|
114
|
+
*/
|
|
115
|
+
export async function runWithCipher(callOp, args) {
|
|
116
|
+
const answers = [];
|
|
117
|
+
for (let asked = 0; ; asked++) {
|
|
118
|
+
const res = await callOp({ ...args, answers: answers.slice() });
|
|
119
|
+
if (!res || res.need === undefined) return res;
|
|
120
|
+
// The request bound is the one check the host makes (design record step 4).
|
|
121
|
+
if (asked === MAX_CIPHER_REQUESTS) {
|
|
122
|
+
throw new Error(`runWithCipher: more than ${MAX_CIPHER_REQUESTS} cipher requests`);
|
|
123
|
+
}
|
|
124
|
+
answers.push({ request: res.need, answer: await answerCipherRequest(res.need) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The load-time check of the engine against its release stamp
|
|
2
|
+
// (https://github.com/danbri/factoidal/issues/1106, layer 1).
|
|
3
|
+
//
|
|
4
|
+
// A release stamps l4-assets/version.json with the SHA-256 and the size of
|
|
5
|
+
// l4factoidal.wasm (tools/stamp-l4-version.mjs). Before an engine is
|
|
6
|
+
// instantiated, its bytes are hashed and compared with the stamp: a stamped
|
|
7
|
+
// copy whose bytes differ does not load. An unstamped copy (a source
|
|
8
|
+
// checkout, a local build that did not stamp) loads as before.
|
|
9
|
+
//
|
|
10
|
+
// This catches accidents (a stale wasm next to a new loader, a partial
|
|
11
|
+
// upgrade, a hand edit). It is not a defence against someone who can edit
|
|
12
|
+
// the package: they can edit this file too. See issue 1106 for the checks
|
|
13
|
+
// made from outside the package.
|
|
14
|
+
//
|
|
15
|
+
// ES module, no Node built-ins: Node, Deno and the browser load it as it is.
|
|
16
|
+
// The digest is Web Crypto's SHA-256 (globalThis.crypto.subtle).
|
|
17
|
+
|
|
18
|
+
/** Lowercase hex SHA-256 of `bytes` (a Uint8Array or an ArrayBuffer). */
|
|
19
|
+
export async function sha256Hex(bytes) {
|
|
20
|
+
const subtle = globalThis.crypto && globalThis.crypto.subtle;
|
|
21
|
+
if (!subtle) throw new Error('factoidal: no Web Crypto SHA-256 to check the engine with');
|
|
22
|
+
const digest = new Uint8Array(await subtle.digest('SHA-256', bytes));
|
|
23
|
+
let hex = '';
|
|
24
|
+
for (const b of digest) hex += b.toString(16).padStart(2, '0');
|
|
25
|
+
return hex;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check engine bytes against a version.json stamp.
|
|
30
|
+
* @param {Uint8Array|ArrayBuffer} bytes the wasm
|
|
31
|
+
* @param {object|null} stamp the parsed version.json, or null when absent
|
|
32
|
+
* @param {{where?: string, allowModified?: boolean, warn?: (msg: string) => void}} [opts]
|
|
33
|
+
* @returns {Promise<{checked: boolean, sha256?: string}>} `checked` false for
|
|
34
|
+
* an absent or unstamped version.json
|
|
35
|
+
*/
|
|
36
|
+
export async function checkEngineStamp(bytes, stamp, opts = {}) {
|
|
37
|
+
if (!stamp || stamp.stamped !== true || typeof stamp.wasmSha256 !== 'string') {
|
|
38
|
+
return { checked: false };
|
|
39
|
+
}
|
|
40
|
+
const size = bytes.byteLength;
|
|
41
|
+
const sha256 = await sha256Hex(bytes);
|
|
42
|
+
const sizeOk = !Number.isInteger(stamp.wasmBytes) || stamp.wasmBytes === size;
|
|
43
|
+
if (sha256 === stamp.wasmSha256.toLowerCase() && sizeOk) return { checked: true, sha256 };
|
|
44
|
+
const where = opts.where || 'l4factoidal.wasm';
|
|
45
|
+
const msg = `factoidal: ${where} is not the engine of this release: version.json ` +
|
|
46
|
+
`(build ${stamp.release ?? stamp.gitSha ?? '?'}) says sha256 ${stamp.wasmSha256}, ${stamp.wasmBytes ?? '?'} bytes; ` +
|
|
47
|
+
`the file has sha256 ${sha256}, ${size} bytes. Reinstall @factoidal/core, ` +
|
|
48
|
+
'or set FACTOIDAL_ALLOW_MODIFIED_ENGINE=1 to load a changed engine on purpose.';
|
|
49
|
+
if (opts.allowModified) {
|
|
50
|
+
(opts.warn || (() => {}))(`${msg} Loading it because FACTOIDAL_ALLOW_MODIFIED_ENGINE is set.`);
|
|
51
|
+
return { checked: false, sha256 };
|
|
52
|
+
}
|
|
53
|
+
const err = new Error(msg);
|
|
54
|
+
err.code = 'engine.modified';
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
package/lib/nodes.mjs
CHANGED
|
@@ -38,6 +38,22 @@
|
|
|
38
38
|
// {"$nquads": its N-Quads text}. With an older engine, records carry no
|
|
39
39
|
// settings and the evaluator's output wire is the registry's.
|
|
40
40
|
//
|
|
41
|
+
// Graphs (https://github.com/danbri/factoidal/issues/1130, stage 1b):
|
|
42
|
+
// graph({inputs, nodes}) is a pipeline whose nodes read named input ports,
|
|
43
|
+
// each port from a graph input or from the output of an earlier node, so a
|
|
44
|
+
// node can read two values (datasetMerge) and one value can feed two nodes.
|
|
45
|
+
// The engine decides the check (op graphCheckNodes, formal/lean4/Wasm/Ops/
|
|
46
|
+
// GraphNodes.lean, computed by graphIssues of formal/lean4/L4Factoidal/
|
|
47
|
+
// Dataflow/Graph.lean): unknown graph inputs, references to the node itself
|
|
48
|
+
// or to a later node, the number of sources per node, and the wires. graph()
|
|
49
|
+
// refuses a graph with an issue. The engine also gives each node's ports and
|
|
50
|
+
// output wire, the union of the named assumptions (the premises of
|
|
51
|
+
// Graph.guarantee_sound) and the stages with more than one port (node()
|
|
52
|
+
// reads a port stage such as datasetMerge from that op, as stageList has one
|
|
53
|
+
// input wire per stage). PORT_RUNNERS binds a port stage to the package
|
|
54
|
+
// function that runs it; tools/runners-gate.py checks it against the Lean
|
|
55
|
+
// list portStageNames. This file makes no RDF decision and no graph check.
|
|
56
|
+
//
|
|
41
57
|
// ES module, no Node built-ins: the browser loads this file as it is.
|
|
42
58
|
|
|
43
59
|
import { FactoidalError } from './errors.mjs';
|
|
@@ -49,7 +65,8 @@ import { refusalFor, makeRecord, summarize, unionAssumptions, unionLists } from
|
|
|
49
65
|
*/
|
|
50
66
|
export function buildNodes(deps) {
|
|
51
67
|
const { entry, entryResult, requireEntryFn, Dataset,
|
|
52
|
-
parse, closure, shaclValidate, query, serialize, canonicalize, canonicalHash, explain, csvwToRdf
|
|
68
|
+
parse, closure, shaclValidate, query, serialize, canonicalize, canonicalHash, explain, csvwToRdf,
|
|
69
|
+
datasetMerge } = deps;
|
|
53
70
|
|
|
54
71
|
const reader = (format) => async (wire, params) => {
|
|
55
72
|
if (typeof wire.graph !== 'string') {
|
|
@@ -109,12 +126,12 @@ export function buildNodes(deps) {
|
|
|
109
126
|
// String × String: the canonical N-Quads text and its SHA-256 hex
|
|
110
127
|
// (RdfcContract, Dataflow/PhaseE.lean). canonicalHash is the SHA-256 of
|
|
111
128
|
// the text canonicalize answers (op datasetCanonicalHash).
|
|
129
|
+
// The input is a Dataset or text (a string or {text, format, baseIRI}:
|
|
130
|
+
// characters, not bytes; the settings `format` and `baseIRI` read it).
|
|
112
131
|
rdfcStage: async (wire, params) => {
|
|
113
|
-
|
|
114
|
-
throw new TypeError(`node rdfcStage: the stage takes a Dataset on its wire (got ${describe(wire.graph)})`);
|
|
115
|
-
}
|
|
132
|
+
const isDataset = wire.graph instanceof Dataset;
|
|
116
133
|
const canonical = await canonicalize(wire.graph, params);
|
|
117
|
-
const sha256 = await canonicalHash(wire.graph);
|
|
134
|
+
const sha256 = await canonicalHash(wire.graph, isDataset && Object.keys(params).length === 0 ? undefined : params);
|
|
118
135
|
return { graph: [canonical, sha256], artefact: { canonical, sha256 } };
|
|
119
136
|
},
|
|
120
137
|
'CSVW (tabular data to RDF)': async (wire, params) => {
|
|
@@ -135,6 +152,23 @@ export function buildNodes(deps) {
|
|
|
135
152
|
},
|
|
136
153
|
};
|
|
137
154
|
|
|
155
|
+
// Port stage name (the Lean list portStageNames, formal/lean4/Wasm/Ops/
|
|
156
|
+
// GraphNodes.lean) -> the package function that runs it. A runner of a
|
|
157
|
+
// stage with more than one port gets the values of its ports, in port
|
|
158
|
+
// order, as the array `wire.graph`.
|
|
159
|
+
const PORT_RUNNERS = {
|
|
160
|
+
// RDF 1.1 Semantics section 5.2 merge: the blank nodes of the two
|
|
161
|
+
// inputs are kept apart (op datasetMerge, theorem mergeDatasets_isMerge).
|
|
162
|
+
datasetMerge: async (wire) => {
|
|
163
|
+
const [left, right] = wire.graph;
|
|
164
|
+
return { graph: await datasetMerge(left, right), artefact: { ports: ['left', 'right'] } };
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const hasRunner = (n) => Object.prototype.hasOwnProperty.call(n.ports && n.ports.length > 1
|
|
169
|
+
? PORT_RUNNERS : RUNNERS, n.stage);
|
|
170
|
+
const runnerOf = (n) => (n.ports && n.ports.length > 1 ? PORT_RUNNERS : RUNNERS)[n.stage];
|
|
171
|
+
|
|
138
172
|
// Whether `value` represents the wire type `wire` (a wire name of the
|
|
139
173
|
// engine's registry, Dataflow/Wire.lean) in this package: a Dataset for the
|
|
140
174
|
// graph wires, text for String, and so on. A wire this table does not name
|
|
@@ -232,6 +266,29 @@ export function buildNodes(deps) {
|
|
|
232
266
|
}
|
|
233
267
|
|
|
234
268
|
const hasNodeCheck = () => !!engine && typeof engine.pipelineCheckNodes === 'function';
|
|
269
|
+
const hasGraphCheck = () => !!engine && typeof engine.graphCheckNodes === 'function';
|
|
270
|
+
|
|
271
|
+
// The engine's check of a graph request (op graphCheckNodes).
|
|
272
|
+
function graphCheck(inputs, items) {
|
|
273
|
+
return entryResult(engine.graphCheckNodes(JSON.stringify({
|
|
274
|
+
inputs,
|
|
275
|
+
nodes: items.map((it) => ({ stage: it.node.stage, settings: it.node.settingsWire, from: it.from })),
|
|
276
|
+
})), 'graph');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// A node of a port stage (a stage with more than one input port), read
|
|
280
|
+
// from the engine's answer for a graph of that one node; null when the
|
|
281
|
+
// engine has no op graphCheckNodes or no port stage of this name. The
|
|
282
|
+
// answer's portCount issue (the probe gives no source) is not read.
|
|
283
|
+
function portNodeInfo(name, settingsWire) {
|
|
284
|
+
if (!hasGraphCheck()) return null;
|
|
285
|
+
const r = JSON.parse(engine.graphCheckNodes(JSON.stringify({
|
|
286
|
+
inputs: [], nodes: [{ stage: name, settings: settingsWire, from: [] }],
|
|
287
|
+
})));
|
|
288
|
+
if (!r.ok || !Array.isArray(r.nodes) || r.nodes.length !== 1) return null;
|
|
289
|
+
const s = r.nodes[0];
|
|
290
|
+
return s.ports.length > 1 ? s : null;
|
|
291
|
+
}
|
|
235
292
|
|
|
236
293
|
// The engine's check of nodes with settings (op pipelineCheckNodes).
|
|
237
294
|
function nodeCheck(items) {
|
|
@@ -253,14 +310,38 @@ export function buildNodes(deps) {
|
|
|
253
310
|
throw new TypeError('node: params must be an object');
|
|
254
311
|
}
|
|
255
312
|
const info = (await stages()).find((s) => s.name === name);
|
|
256
|
-
if (!info) {
|
|
257
|
-
throw new FactoidalError(`node: no registered stage named '${name}' (see stages())`,
|
|
258
|
-
{ code: 'option.invalid-value' });
|
|
259
|
-
}
|
|
260
313
|
// The copy first, then its wire form: the engine checks and digests
|
|
261
314
|
// exactly the settings the node keeps.
|
|
262
315
|
const own = snapshot({ ...(params || {}) });
|
|
263
316
|
const settingsWire = snapshot(marshal(own, 'params'));
|
|
317
|
+
if (!info) {
|
|
318
|
+
const p = portNodeInfo(name, settingsWire);
|
|
319
|
+
if (!p) {
|
|
320
|
+
throw new FactoidalError(`node: no registered stage named '${name}' (see stages(); ` +
|
|
321
|
+
'an engine with the op graphCheckNodes also has the stages with more than one port)',
|
|
322
|
+
{ code: 'option.invalid-value' });
|
|
323
|
+
}
|
|
324
|
+
return Object.freeze({
|
|
325
|
+
[NODE]: true,
|
|
326
|
+
stage: p.name,
|
|
327
|
+
kind: p.kind,
|
|
328
|
+
status: p.status,
|
|
329
|
+
registryStatus: p.registryStatus,
|
|
330
|
+
contract: p.contract,
|
|
331
|
+
assumptions: Object.freeze([...p.assumptions]),
|
|
332
|
+
ports: Object.freeze(p.ports.map((x) => Object.freeze({ ...x }))),
|
|
333
|
+
wireIn: null,
|
|
334
|
+
wireOut: p.wireOut,
|
|
335
|
+
runnable: p.status !== 'missing',
|
|
336
|
+
bound: Object.prototype.hasOwnProperty.call(PORT_RUNNERS, p.name),
|
|
337
|
+
params: own,
|
|
338
|
+
settingsWire,
|
|
339
|
+
settings: p.settings,
|
|
340
|
+
settingsDigest: p.settingsDigest,
|
|
341
|
+
outside: Object.freeze(p.outside.map((o) => Object.freeze({ ...o }))),
|
|
342
|
+
derived: Object.freeze({ ...p.derived }),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
264
345
|
// The engine's reading of the settings, when it has the op.
|
|
265
346
|
let s = null;
|
|
266
347
|
if (hasNodeCheck()) s = nodeCheck([{ stage: info.name, settingsWire }]).stages[0];
|
|
@@ -272,6 +353,7 @@ export function buildNodes(deps) {
|
|
|
272
353
|
registryStatus: info.status,
|
|
273
354
|
contract: info.contract,
|
|
274
355
|
assumptions: info.assumptions,
|
|
356
|
+
ports: Object.freeze([Object.freeze({ name: 'in', wire: info.wireIn })]),
|
|
275
357
|
wireIn: info.wireIn,
|
|
276
358
|
wireOut: s ? s.wireOut : info.wireOut,
|
|
277
359
|
runnable: info.runnable,
|
|
@@ -288,6 +370,10 @@ export function buildNodes(deps) {
|
|
|
288
370
|
function nodesOf(items, who) {
|
|
289
371
|
const out = [];
|
|
290
372
|
for (const n of items) {
|
|
373
|
+
if (n && n[NODE] && n.ports && n.ports.length > 1) {
|
|
374
|
+
throw new TypeError(`${who}: node ${n.stage} has ${n.ports.length} input ports ` +
|
|
375
|
+
`(${n.ports.map((x) => x.name).join(', ')}); a list pipeline gives each node one input; use graph()`);
|
|
376
|
+
}
|
|
291
377
|
if (n && n[NODE]) out.push(n);
|
|
292
378
|
else if (n && Array.isArray(n.nodes)) out.push(...nodesOf(n.nodes, who));
|
|
293
379
|
else throw new TypeError(`${who}: expected nodes from node() or pipelines from pipe()`);
|
|
@@ -363,9 +449,10 @@ export function buildNodes(deps) {
|
|
|
363
449
|
throw new TypeError('run: options must be an object');
|
|
364
450
|
}
|
|
365
451
|
const unchecked = !!(options && options.unchecked);
|
|
452
|
+
if (pipeline && pipeline[GRAPH]) return runGraph(pipeline, input, unchecked);
|
|
366
453
|
const nodes = nodesOf([pipeline], 'run');
|
|
367
454
|
for (const n of nodes) {
|
|
368
|
-
const why = refusalFor(n, unchecked,
|
|
455
|
+
const why = refusalFor(n, unchecked, hasRunner(n));
|
|
369
456
|
if (why) throw why;
|
|
370
457
|
assertSettingsUnchanged(n);
|
|
371
458
|
}
|
|
@@ -414,5 +501,141 @@ export function buildNodes(deps) {
|
|
|
414
501
|
};
|
|
415
502
|
}
|
|
416
503
|
|
|
417
|
-
|
|
504
|
+
const GRAPH = Symbol('factoidal.graph');
|
|
505
|
+
|
|
506
|
+
// The text of one issue of the engine's check (op graphCheckNodes).
|
|
507
|
+
function issueText(x) {
|
|
508
|
+
switch (x.kind) {
|
|
509
|
+
case 'unknownInput':
|
|
510
|
+
return `node ${x.node} port ${x.port} (${x.portName}) reads graph input ${x.input}, which does not exist`;
|
|
511
|
+
case 'forwardRef':
|
|
512
|
+
return `node ${x.node} port ${x.port} (${x.portName}) reads node ${x.ref}, which is not before it ` +
|
|
513
|
+
'(a cycle or a forward reference)';
|
|
514
|
+
case 'portCount':
|
|
515
|
+
return `node ${x.node} has ${x.ports} input ports and ${x.sources} sources`;
|
|
516
|
+
case 'wireMismatch':
|
|
517
|
+
return `node ${x.node} port ${x.port} (${x.portName}) reads ${x.portWire}, its source writes ${x.source}`;
|
|
518
|
+
default:
|
|
519
|
+
return JSON.stringify(x);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* A graph: nodes with named input ports (https://github.com/danbri/factoidal/issues/1130).
|
|
525
|
+
* `spec.inputs`: the wire of each graph input (a wire label, e.g. 'String';
|
|
526
|
+
* see stages() wireIn/wireOut). `spec.nodes`: in order, `{ node, from }`,
|
|
527
|
+
* where `node` comes from node() and `from` has one source per input port of
|
|
528
|
+
* the node: `{ input: k }` (graph input k) or `{ node: j }` (the output of
|
|
529
|
+
* node j, which must come before it). The engine's op graphCheckNodes checks
|
|
530
|
+
* the graph; a graph with an issue is refused (FactoidalError code
|
|
531
|
+
* op.failed, `err.issues` the engine's list). An engine without the op is
|
|
532
|
+
* refused with code op.unknown. The last node's output is the graph's output.
|
|
533
|
+
* @param {{inputs?: string[], nodes: Array<{node: object, from: object[]}>}} spec
|
|
534
|
+
* @returns {object}
|
|
535
|
+
*/
|
|
536
|
+
function graph(spec) {
|
|
537
|
+
if (spec === null || typeof spec !== 'object') throw new TypeError('graph: spec must be an object');
|
|
538
|
+
const inputs = spec.inputs === undefined ? [] : spec.inputs;
|
|
539
|
+
if (!Array.isArray(inputs) || !inputs.every((w) => typeof w === 'string')) {
|
|
540
|
+
throw new TypeError('graph: spec.inputs must be an array of wire labels (strings)');
|
|
541
|
+
}
|
|
542
|
+
if (!Array.isArray(spec.nodes) || spec.nodes.length === 0) {
|
|
543
|
+
throw new TypeError('graph: spec.nodes must be a non-empty array');
|
|
544
|
+
}
|
|
545
|
+
const items = spec.nodes.map((it, i) => {
|
|
546
|
+
if (!it || typeof it !== 'object' || !it.node || !it.node[NODE]) {
|
|
547
|
+
throw new TypeError(`graph: spec.nodes[${i}].node must be a node from node()`);
|
|
548
|
+
}
|
|
549
|
+
if (!Array.isArray(it.from)) throw new TypeError(`graph: spec.nodes[${i}].from must be an array`);
|
|
550
|
+
return Object.freeze({ node: it.node, from: snapshot(marshal(it.from, `nodes[${i}].from`)) });
|
|
551
|
+
});
|
|
552
|
+
requireEntryFn(engine || {}, 'graphCheckNodes', 'graph');
|
|
553
|
+
const r = graphCheck(inputs, items);
|
|
554
|
+
if (r.issues.length > 0) {
|
|
555
|
+
const err = new FactoidalError(`graph: the engine's check (op graphCheckNodes) found ` +
|
|
556
|
+
`${r.issues.length} issue${r.issues.length === 1 ? '' : 's'}: ${r.issues.map(issueText).join('; ')}`,
|
|
557
|
+
{ code: 'op.failed' });
|
|
558
|
+
err.issues = Object.freeze(r.issues.map((x) => Object.freeze({ ...x })));
|
|
559
|
+
throw err;
|
|
560
|
+
}
|
|
561
|
+
const check = Object.freeze({
|
|
562
|
+
issues: Object.freeze([]),
|
|
563
|
+
wireOut: r.wireOut,
|
|
564
|
+
assumptions: Object.freeze([...r.assumptions]),
|
|
565
|
+
contract: Object.freeze({ ...r.contract }),
|
|
566
|
+
nodes: Object.freeze(r.nodes.map((s) => Object.freeze({
|
|
567
|
+
...s,
|
|
568
|
+
theorems: Object.freeze([...s.theorems]),
|
|
569
|
+
assumptions: Object.freeze([...s.assumptions]),
|
|
570
|
+
ports: Object.freeze(s.ports.map((x) => Object.freeze({ ...x }))),
|
|
571
|
+
outside: Object.freeze(s.outside.map((o) => Object.freeze({ ...o }))),
|
|
572
|
+
}))),
|
|
573
|
+
});
|
|
574
|
+
return Object.freeze({
|
|
575
|
+
[GRAPH]: true,
|
|
576
|
+
inputs: Object.freeze([...inputs]),
|
|
577
|
+
nodes: Object.freeze(items),
|
|
578
|
+
wireOut: r.wireOut,
|
|
579
|
+
assumptions: check.assumptions,
|
|
580
|
+
check,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// run() of a graph: the nodes in order, each on the values of its sources.
|
|
585
|
+
async function runGraph(g, inputs, unchecked) {
|
|
586
|
+
if (!Array.isArray(inputs)) {
|
|
587
|
+
throw new TypeError('run: the input of a graph is an array, one value per graph input');
|
|
588
|
+
}
|
|
589
|
+
if (inputs.length !== g.inputs.length) {
|
|
590
|
+
throw new TypeError(`run: the graph has ${g.inputs.length} inputs, got ${inputs.length} values`);
|
|
591
|
+
}
|
|
592
|
+
for (const it of g.nodes) {
|
|
593
|
+
const why = refusalFor(it.node, unchecked, hasRunner(it.node));
|
|
594
|
+
if (why) throw why;
|
|
595
|
+
assertSettingsUnchanged(it.node);
|
|
596
|
+
}
|
|
597
|
+
// A graph input given as a wire value {graph, evidence} keeps its records.
|
|
598
|
+
const prior = [];
|
|
599
|
+
const inputValues = inputs.map((x, k) => {
|
|
600
|
+
const v = isWire(x) ? x.graph : x;
|
|
601
|
+
if (isWire(x)) prior.push(...x.evidence);
|
|
602
|
+
if (!wireHolds(g.inputs[k], v)) {
|
|
603
|
+
throw new FactoidalError(`run: graph input ${k} is declared ${g.inputs[k]}, got ${describe(v)}`,
|
|
604
|
+
{ code: 'op.failed' });
|
|
605
|
+
}
|
|
606
|
+
return v;
|
|
607
|
+
});
|
|
608
|
+
const values = [];
|
|
609
|
+
const records = [];
|
|
610
|
+
for (let i = 0; i < g.nodes.length; i++) {
|
|
611
|
+
const { node: n, from } = g.nodes[i];
|
|
612
|
+
// graph() refused every graph whose sources are not all before the node.
|
|
613
|
+
const vals = from.map((src) => (Object.prototype.hasOwnProperty.call(src, 'input')
|
|
614
|
+
? inputValues[src.input] : values[src.node]));
|
|
615
|
+
const wire = { graph: n.ports.length > 1 ? vals : vals[0], evidence: [] };
|
|
616
|
+
const out = await runnerOf(n)(wire, n.params);
|
|
617
|
+
const value = out.graph;
|
|
618
|
+
const wireOut = g.check.nodes[i].wireOut;
|
|
619
|
+
if (!wireHolds(wireOut, value)) {
|
|
620
|
+
throw new FactoidalError(`run: node ${i} (${n.stage}) declares the output wire ${wireOut} ` +
|
|
621
|
+
`but its runner gave ${describe(value)}`, { code: 'op.failed' });
|
|
622
|
+
}
|
|
623
|
+
values.push(value);
|
|
624
|
+
records.push(Object.freeze({ ...makeRecord(n, out.artefact, unchecked), node: i, from }));
|
|
625
|
+
}
|
|
626
|
+
const s = summarize(records);
|
|
627
|
+
return {
|
|
628
|
+
graph: values[values.length - 1],
|
|
629
|
+
values,
|
|
630
|
+
evidence: [...prior, ...records],
|
|
631
|
+
assumptions: prior.length === 0 ? [...g.check.assumptions]
|
|
632
|
+
: unionLists([unionAssumptions(prior), g.check.assumptions]),
|
|
633
|
+
proved: s.proved && summarize(prior).proved,
|
|
634
|
+
uncheckedSteps: [...summarize(prior).uncheckedSteps, ...s.uncheckedSteps],
|
|
635
|
+
wellTyped: true,
|
|
636
|
+
issues: [],
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return { stages, node, pipe, graph, run };
|
|
418
641
|
}
|
package/lib/sbm11.mjs
CHANGED
|
@@ -121,13 +121,22 @@ export function sbm11Api (loadEngine) {
|
|
|
121
121
|
* it decodes against the manifest's Merkle roots, and refuses a changed
|
|
122
122
|
* file with its name) or 'none' (lengths only). The engine decides; this
|
|
123
123
|
* file passes the string.
|
|
124
|
+
*
|
|
125
|
+
* `options.manifestSha256`: the trust anchor, the SHA-256 of the bytes of
|
|
126
|
+
* `manifest.sbm11` as 64 hex digits (https://github.com/danbri/factoidal/issues/1105).
|
|
127
|
+
* The engine computes the digest and refuses the open when it differs;
|
|
128
|
+
* the error names manifest.sbm11 and both digests. `store.manifestSha256`
|
|
129
|
+
* is the digest the engine computed, so that a caller can pin it.
|
|
124
130
|
*/
|
|
125
131
|
async function openSbm11 (source, options = {}) {
|
|
126
132
|
installBridge()
|
|
127
133
|
if (options.reader) registerSbm11Source(source, options.reader)
|
|
128
134
|
const eng = await loadEngine()
|
|
129
135
|
const openArgs = [String(source)]
|
|
130
|
-
|
|
136
|
+
const o = {}
|
|
137
|
+
if (options.verify != null) o.verify = String(options.verify)
|
|
138
|
+
if (options.manifestSha256 != null) o.manifestSha256 = String(options.manifestSha256)
|
|
139
|
+
if (Object.keys(o).length > 0) openArgs.push(JSON.stringify(o))
|
|
131
140
|
const opened = envelope(eng.call('sbm11Open', openArgs))
|
|
132
141
|
const handle = opened.handle
|
|
133
142
|
let open = true
|
|
@@ -145,6 +154,10 @@ export function sbm11Api (loadEngine) {
|
|
|
145
154
|
/** how the engine checks reads: 'merkle' or 'none' (undefined from a
|
|
146
155
|
* wasm module built before the check) */
|
|
147
156
|
verify: opened.verify,
|
|
157
|
+
/** the SHA-256 of the bytes of manifest.sbm11 (64 lowercase hex
|
|
158
|
+
* digits), as the engine computed it at open; undefined from a wasm
|
|
159
|
+
* module built before the trust anchor (issue 1105) */
|
|
160
|
+
manifestSha256: opened.manifestSha256,
|
|
148
161
|
query (sparql, qopts = {}) {
|
|
149
162
|
if (!open) throw new Error('sbm11: the store is closed')
|
|
150
163
|
const o = {}
|
package/lib/version.mjs
CHANGED
package/lws/README.md
CHANGED
|
@@ -175,6 +175,15 @@ factoidal lws-serve /tmp/dev --port 3000 --insecure-no-auth
|
|
|
175
175
|
One of `--issuer`, `--auth-config FILE` or `--insecure-no-auth` is
|
|
176
176
|
required.
|
|
177
177
|
|
|
178
|
+
With `--insecure-no-auth` every request is served, so the storage answers
|
|
179
|
+
only its own host names (421 for any other `Host`: DNS rebinding), refuses a
|
|
180
|
+
write from another origin (403: a cross-site form or `fetch`), and sends no
|
|
181
|
+
CORS fields unless `--cors-origin ORIGIN` names a page. `--allow-host NAME`
|
|
182
|
+
and `--allow-origin ORIGIN` add names and origins (a reverse proxy, a
|
|
183
|
+
Tailscale name). The engine decides all of this
|
|
184
|
+
(`L4Factoidal/HTTP/LocalGuard.lean`,
|
|
185
|
+
https://github.com/danbri/factoidal/issues/1124).
|
|
186
|
+
|
|
178
187
|
`--port 0` takes an ephemeral port and the command prints the one it got;
|
|
179
188
|
`--base IRI` sets `baseIri`, which defaults to the bound origin. `DIR`
|
|
180
189
|
names the storage directory, but the first slice keeps the resource tree
|
package/lws/client.mjs
CHANGED
|
@@ -148,6 +148,12 @@ export async function lwsClientOpsAvailable (engine) {
|
|
|
148
148
|
* @param {string} [options.idToken] an OpenID Connect ID Token to exchange
|
|
149
149
|
* @param {object} [options.bearer] `{ token, realm? }`: an access token the
|
|
150
150
|
* caller already has; with a realm it is sent only under it
|
|
151
|
+
* @param {boolean} [options.legacyChallenge] follow the 401 challenge
|
|
152
|
+
* `Bearer as_uri="…", realm="…"` of the drafts before 2026-10-19 (default
|
|
153
|
+
* false: it skips two of the discovery checks of lws10-core)
|
|
154
|
+
* @param {boolean} [options.requireHttps] send no request to a URL whose
|
|
155
|
+
* scheme is not https, in any phase; such an operation fails with code
|
|
156
|
+
* `insecureTransport` (default false; the engine decides)
|
|
151
157
|
* @param {object} [options.session] a session from an earlier client
|
|
152
158
|
* @param {number} [options.maxSteps] the loop cap of one operation
|
|
153
159
|
* @returns {Promise<object>} the client
|
|
@@ -168,6 +174,11 @@ export async function createLwsClient (options = {}) {
|
|
|
168
174
|
// Read by the engine's session reader (`Machine.sessionOfJson`), unread here.
|
|
169
175
|
if (options.idToken !== undefined) session.idToken = options.idToken
|
|
170
176
|
if (options.bearer !== undefined) session.bearer = options.bearer
|
|
177
|
+
// Follow the `Bearer as_uri, realm` challenge of the drafts before
|
|
178
|
+
// 2026-10-19 (`Machine.on401`); the engine decides, off by default.
|
|
179
|
+
if (options.legacyChallenge === true) session.legacyChallenge = true
|
|
180
|
+
// Refuse every request to a URL that is not https (`Machine.enforce`).
|
|
181
|
+
if (options.requireHttps === true) session.requireHttps = true
|
|
171
182
|
|
|
172
183
|
function call (op, args) {
|
|
173
184
|
try {
|
|
@@ -248,7 +259,9 @@ export async function createLwsClient (options = {}) {
|
|
|
248
259
|
head: with_('head'),
|
|
249
260
|
/** `create(containerUrl, { body, mediaType, slug, container, types, links })` */
|
|
250
261
|
create: with_('create'),
|
|
251
|
-
/** `update(url, { body, mediaType, precondition, tag, retry412, links })
|
|
262
|
+
/** `update(url, { body, mediaType, precondition, tag, ifNoneMatchTags, retry412, links })`;
|
|
263
|
+
* `ifNoneMatchTags` (entity tags, or field values of them) sends
|
|
264
|
+
* `If-None-Match`, alone or with the `If-Match` of `precondition` */
|
|
252
265
|
update: with_('update'),
|
|
253
266
|
patch: with_('patch'),
|
|
254
267
|
/** `delete(url, { recursive, precondition, tag })` */
|
|
@@ -267,10 +280,14 @@ export async function createLwsClient (options = {}) {
|
|
|
267
280
|
* the service endpoint, `{ onePage: true }` for one page */
|
|
268
281
|
search: with_('search'),
|
|
269
282
|
queryFormats: with_('queryFormats'),
|
|
270
|
-
/** `subscribe(url, { topics, inbox, expires })`
|
|
283
|
+
/** `subscribe(url, { topics, inbox, expires })`, or with `{ endpoint }`
|
|
284
|
+
* when the caller has the notification service endpoint: the
|
|
285
|
+
* subscription is POSTed to it with no discovery */
|
|
271
286
|
subscribe: with_('subscribe'),
|
|
272
287
|
getSubscription: with_('getSubscription'),
|
|
273
288
|
unsubscribe: with_('unsubscribe'),
|
|
289
|
+
/** `requestAccess(url, { policies: [{ actions, assignee, targets, constraint }], inbox })`,
|
|
290
|
+
* or one policy as `{ actions, assignee, targets, constraint }` */
|
|
274
291
|
requestAccess: with_('requestAccess'),
|
|
275
292
|
grantAccess: with_('grantAccess'),
|
|
276
293
|
getAccess: with_('getAccess'),
|