@factoidal/core 0.20.0 → 0.22.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/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 } = deps;
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
- if (!(wire.graph instanceof Dataset)) {
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, Object.prototype.hasOwnProperty.call(RUNNERS, n.stage));
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
- return { stages, node, pipe, run };
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
- if (options.verify != null) openArgs.push(JSON.stringify({ verify: String(options.verify) }))
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
@@ -2,4 +2,4 @@
2
2
  // needs import attributes, which not every runtime this package targets
3
3
  // reads). test/dts-drift.test.js checks that it equals package.json's
4
4
  // `version`.
5
- export const version = '0.20.0';
5
+ export const version = '0.22.0';
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'),
package/lws/persist.mjs CHANGED
@@ -9,6 +9,8 @@
9
9
  // This file decides only where the one file lives and how it is written:
10
10
  //
11
11
  // DIR/lws-state.json the snapshot, replaced atomically
12
+ // DIR/lws.lock the lock of the one writing process (named by
13
+ // the engine, issue 1135; see the end of this file)
12
14
  //
13
15
  // No request target ever becomes a file name here, so a request cannot
14
16
  // address a file outside DIR.
@@ -26,6 +28,7 @@
26
28
 
27
29
  import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, existsSync } from 'node:fs'
28
30
  import { dirname, join, resolve, sep } from 'node:path'
31
+ import { hostname } from 'node:os'
29
32
 
30
33
  export const STATE_FILE = 'lws-state.json'
31
34
 
@@ -323,3 +326,169 @@ export function persistFilesIfChanged (engine, handle, envelope, dir) {
323
326
  const answered = engine.callBlobIO('lwsPersist', [handle, JSON.stringify({ seed: seedBase64() })], new Uint8Array(0))
324
327
  performPlan(dir, answered.envelope.plan, answered.bytes)
325
328
  }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // ONE WRITING PROCESS PER STORAGE DIRECTORY
332
+ // (https://github.com/danbri/factoidal/issues/1135)
333
+ //
334
+ // The engine decides the lock file name, its contents and what a start does
335
+ // (`lwsDirLock`, formal/lean4/L4Factoidal/LWS/DirLock.lean). This host does
336
+ // three things only: the exclusive create of the file it names (flag `wx`,
337
+ // O_EXCL), the liveness probe of a process id it names (`kill(pid, 0)`), and
338
+ // the removal of the file at a clean exit. Each result goes back to the
339
+ // engine as data.
340
+
341
+ /** Random text of this process, drawn once: it tells a lock this process
342
+ * wrote from the lock of an earlier process with the same process id. */
343
+ let processToken = null
344
+ function processTokenText () {
345
+ if (processToken === null) {
346
+ const bytes = new Uint8Array(18)
347
+ globalThis.crypto.getRandomValues(bytes)
348
+ let text = ''
349
+ for (const b of bytes) text += String.fromCharCode(b)
350
+ processToken = btoa(text)
351
+ }
352
+ return processToken
353
+ }
354
+
355
+ /** The lock files this process holds: absolute path -> contents. */
356
+ const heldLocks = new Map()
357
+ let exitHookSet = false
358
+
359
+ /** Remove a lock file this process holds, when it still holds this
360
+ * process's contents. Pure I/O. */
361
+ function removeHeldLock (file) {
362
+ if (!heldLocks.has(file)) return
363
+ const contents = heldLocks.get(file)
364
+ heldLocks.delete(file)
365
+ try {
366
+ if (readFileSync(file, 'utf8') === contents) unlinkSync(file)
367
+ } catch (_error) {
368
+ // Already gone, or not readable: nothing to remove.
369
+ }
370
+ }
371
+
372
+ /** At process exit (normal end, `process.exit`, and after a SIGINT or
373
+ * SIGTERM handler ends the process), remove every lock still held. */
374
+ function ensureExitHook () {
375
+ if (exitHookSet || globalThis.process === undefined || typeof globalThis.process.on !== 'function') return
376
+ exitHookSet = true
377
+ globalThis.process.on('exit', () => {
378
+ for (const file of [...heldLocks.keys()]) removeHeldLock(file)
379
+ })
380
+ }
381
+
382
+ /** Is process `pid` alive on this host? Pure I/O: `kill(pid, 0)`; EPERM
383
+ * means it exists under another user. */
384
+ function processAlive (pid) {
385
+ try {
386
+ globalThis.process.kill(pid, 0)
387
+ return true
388
+ } catch (error) {
389
+ return error !== null && typeof error === 'object' && error.code === 'EPERM'
390
+ }
391
+ }
392
+
393
+ /** Create `file` with O_EXCL and write `contents`; false when it exists. */
394
+ function createExclusive (file, contents) {
395
+ let fd
396
+ try {
397
+ fd = openSync(file, 'wx', 0o600)
398
+ } catch (error) {
399
+ if (error.code === 'EEXIST') return false
400
+ throw error
401
+ }
402
+ try {
403
+ writeSync(fd, contents)
404
+ fsyncSync(fd)
405
+ } finally {
406
+ closeSync(fd)
407
+ }
408
+ return true
409
+ }
410
+
411
+ /** An error that stops a start because another process holds the storage
412
+ * directory. `envelope` carries the engine's message, as for every other
413
+ * refusal of a start. */
414
+ export class StorageLockedError extends Error {
415
+ constructor (message) {
416
+ super(message)
417
+ this.name = 'StorageLockedError'
418
+ this.envelope = { ok: false, error: message }
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Take the lock of storage directory `dir` for `command` (`lws-serve`,
424
+ * `solid-serve`, `webdav-serve`). Returns `{ file, warning, release }`:
425
+ * `warning` is the engine's text when it took over a stale lock, else null;
426
+ * `release()` removes the lock. Throws `StorageLockedError` with the
427
+ * engine's message when the engine refuses.
428
+ */
429
+ export function acquireDirLock (engine, dir, command) {
430
+ const root = resolve(dir)
431
+ mkdirSync(root, { recursive: true })
432
+ const base = {
433
+ dir: root,
434
+ pid: globalThis.process.pid,
435
+ command,
436
+ started: new Date().toISOString(),
437
+ host: hostname(),
438
+ token: processTokenText()
439
+ }
440
+ let request = { ...base }
441
+ // Each round is one engine answer; a create that loses to another start
442
+ // reads the file again and asks again.
443
+ for (let round = 0; round < 8; round += 1) {
444
+ const told = engine.call('lwsDirLock', [JSON.stringify(request)])
445
+ if (told === null || typeof told !== 'object' || told.ok === false) {
446
+ throw Object.assign(new Error(`lwsDirLock: ${told && told.error ? told.error : 'no answer'}`), { envelope: told })
447
+ }
448
+ switch (told.action) {
449
+ case 'create':
450
+ case 'replace': {
451
+ const file = insideDir(root, told.file)
452
+ if (told.action === 'replace') {
453
+ try { unlinkSync(file) } catch (error) { if (error.code !== 'ENOENT') throw error }
454
+ }
455
+ if (createExclusive(file, told.contents)) {
456
+ heldLocks.set(file, told.contents)
457
+ ensureExitHook()
458
+ return {
459
+ file,
460
+ warning: told.action === 'replace' ? told.warning : null,
461
+ release: () => removeHeldLock(file)
462
+ }
463
+ }
464
+ let present = null
465
+ try { present = readFileSync(file, 'utf8') } catch (error) { if (error.code !== 'ENOENT') throw error }
466
+ request = present === null ? { ...base } : { ...base, present }
467
+ break
468
+ }
469
+ case 'probe':
470
+ request = { ...request, alive: processAlive(Number(told.pid)) }
471
+ break
472
+ case 'refuse':
473
+ throw new StorageLockedError(told.message)
474
+ default:
475
+ throw new Error(`lwsDirLock answered an unknown action ${String(told.action)}`)
476
+ }
477
+ }
478
+ throw new Error('lwsDirLock: the lock file changed on every attempt')
479
+ }
480
+
481
+ /**
482
+ * The start notice about encryption at rest of the storage in `dir`
483
+ * (`lwsAtRestNotice`): `{ sealed, keyId?, notice }`. The key text is passed
484
+ * unread; the engine checks it.
485
+ */
486
+ export function atRestNotice (engine, dir, storageKey) {
487
+ const request = { dir: resolve(dir) }
488
+ if (typeof storageKey === 'string') request.storageKey = storageKey
489
+ const told = engine.call('lwsAtRestNotice', [JSON.stringify(request)])
490
+ if (told === null || typeof told !== 'object' || told.ok === false) {
491
+ throw Object.assign(new Error(`lwsAtRestNotice: ${told && told.error ? told.error : 'no answer'}`), { envelope: told })
492
+ }
493
+ return told
494
+ }