@factoidal/core 0.17.0 → 0.19.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 +21 -0
- package/README.md +16 -1
- package/authz/server.mjs +5 -0
- package/bin/factoidal.mjs +122 -10
- package/bin/store.mjs +67 -11
- package/index.d.ts +13 -1
- 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 +26 -11
- package/lib/dataset.mjs +2 -1
- package/lib/lean-entry.mjs +40 -1
- package/lib/version.mjs +1 -1
- package/lws/client.mjs +50 -8
- package/package.json +6 -2
- package/rdfjs.mjs +10 -1
- package/webdav/server.mjs +293 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.19.0 (2026-10-27)
|
|
6
|
+
|
|
7
|
+
- Notice (owner decision, 2026-10-27): "EXPERIMENTAL UNREVIEWED CODE - USE WITH CARE - DO NOT CREATE PUBLIC SERVICES USING THIS WITHOUT CAREFUL INDEPENDENT EXPERT REVIEW." It is at the top of both READMEs and the hub, in the package description, in every `factoidal` help text, and on standard error when `serve`, `lws-serve`, `solid-serve`, `solid-client`, `lws-client`, `authz-serve`, `authz-keys`, `xmpp-serve` or `webdav-serve` starts.
|
|
8
|
+
- Authorization server (https://github.com/danbri/factoidal-core/pull/150): SAML 2.0 subject tokens (lws10-authn-saml, token type `urn:ietf:params:oauth:token-type:saml2`) from the identity providers the configuration trusts: `authz-serve --saml-idp FILE` (repeatable; also `lws-serve --authz`), config member `samlIdps` (`[{ entityId, jwk }]`, an RSA public JWK of 2048 bits or more). The assertion needs one enveloped XML Signature in the SAML Core §5 profile (Exclusive XML Canonicalization 1.0, rsa-sha256, SHA-256 digest, the reference `#` + the assertion's `ID`), verified under the configured key, never a key from `ds:KeyInfo`; a document type declaration, comments, processing instructions, a second signature or a wrapped assertion are refused. `saml:NameID` is the subject, the bearer `Recipient` the client; `Conditions` times and every `AudienceRestriction` must name this server. Encrypted assertions are refused (https://github.com/danbri/factoidal/issues/1073). The token type is advertised only when a provider is configured (https://github.com/danbri/factoidal/issues/926). Lean: `XML/ExcC14n.lean`, `XML/DSig.lean`, `SAML/Assertion.lean`; record `docs/designissues/2026-10-27-saml-xml-signature.md`.
|
|
9
|
+
|
|
10
|
+
Fixes for defects found by the echo test ports (https://github.com/danbri/echochamber: Jena-, RDFLib-, N3.js- and RDF4J-style test suites run against this package).
|
|
11
|
+
|
|
12
|
+
- SPARQL (https://github.com/danbri/factoidal-core/pull/146): blank nodes written by SPARQL Update get valid N-Triples labels, so CONSTRUCT, DESCRIBE and `shaclValidate` on that Dataset no longer fail (https://github.com/danbri/factoidal/issues/1074); IN and NOT IN raise an error when a comparison errs and no other element decides, so `2 IN (3, 1/0)` is an error, not false (§17.4.1.9, https://github.com/danbri/factoidal/issues/1049); ORDER BY an aggregate (`ORDER BY DESC(COUNT(?s))`) orders the groups of a grouped query (https://github.com/danbri/factoidal/issues/1050); SUBSTR takes start and length of types derived from xsd:integer, such as xsd:int (https://github.com/danbri/factoidal/issues/1060); no XSD constructor function casts a language-tagged literal: `xsd:string("foo"@en)` and `xsd:byte("1"@en)` are errors, use `STR` (§17.5, https://github.com/danbri/factoidal/issues/1062); `true.` and `false.` read as the keyword and the triple terminator (https://github.com/danbri/factoidal/issues/1075); a variable used only inside MINUS is not in scope, so `(1 AS ?z)` is allowed (§18.2.1, https://github.com/danbri/factoidal/issues/1069).
|
|
13
|
+
- Readers and writers (https://github.com/danbri/factoidal-core/pull/147): N-Triples 1.2 and N-Quads 1.2 read the VERSION directive (https://github.com/danbri/factoidal/issues/1055); TriG refuses a top-level triple with no final '.' (https://github.com/danbri/factoidal/issues/1056); Turtle and TriG read white space or a comment after `^^`, and a prefix named `true` or `false` (https://github.com/danbri/factoidal/issues/1063); one RFC 3987 IRI check: the readers, `fromQuads`/`Dataset.add`, the SPARQL parser and the writers refuse a string that is not an IRI, such as `<a_b:c>`, `<http://www:example.org/>` or `<http://a/%zz>`, with `term.invalid-iri`, so no writer writes text that no reader reads (https://github.com/danbri/factoidal/issues/1057); RDF/XML writer: CR in a literal is written `
` (https://github.com/danbri/factoidal/issues/1047), a predicate such as `urn:example:p` is written with namespace `urn:example:` (https://github.com/danbri/factoidal/issues/1048); RDF/JS: `dataFactory.literal` gives the language tag in lowercase, and the `Literal` type has `direction` (https://github.com/danbri/factoidal/issues/1054); JSON-LD: a `"@foo"` key in a value object is ignored with or without a base (https://github.com/danbri/factoidal/issues/1064); `parse()` with no format: JSON-LD only for JSON text, N-Quads when a statement names a graph; the rules are in API.md (https://github.com/danbri/factoidal/issues/1058).
|
|
14
|
+
- Cost: the IRI check makes parsing slower: 100,000 triples, median of 3, 0.18 → 0.19: N-Triples 581 → 646 ms (+11%), Turtle 606 → 718 ms (+19%), N-Quads 1.2 616 → 681 ms (+10%). Work to recover it is in progress.
|
|
15
|
+
|
|
16
|
+
## 0.18.0 (2026-10-27)
|
|
17
|
+
|
|
18
|
+
- XSD constructor functions for datatypes outside the SPARQL 1.1 §17.5 table (`xsd:byte`, `xsd:int`, `xsd:unsignedInt`, `xsd:date`, `xsd:time`, `xsd:gYear` and the other `xsd:g*` types, the duration types, `xsd:token`, …) now cast as XPath F&O 3.1 §19 says. Before, they changed only the datatype and gave ill-typed literals such as `"HaHa"^^xsd:byte`. An invalid value or a cast that F&O does not allow is now an error (the variable is unbound) (https://github.com/danbri/factoidal/issues/1041, https://github.com/danbri/factoidal-core/pull/142).
|
|
19
|
+
- SPARQL: STRSTARTS, STRENDS, CONTAINS, STRBEFORE and STRAFTER compare the base direction of `rdf:dirLangString` arguments (SPARQL 1.2 §17.4.3: same language tag and same base direction). `CONTAINS("abc"@en--ltr, "a"@en)` is now an error. STRBEFORE and STRAFTER keep the base direction of the first argument: `STRBEFORE("abc"@en--ltr, "b")` is `"a"@en--ltr` (https://github.com/danbri/factoidal/issues/1042, https://github.com/danbri/factoidal-core/pull/136).
|
|
20
|
+
- Store open sends the manifest to the engine as bytes, not as hexadecimal inside the JSON arguments, and decodes it once for the plan and the open (new ops `storeManifestInspectBytes`, `storeQueryPlanBytes`, `storeOpenBytes`; the hexadecimal ops stay). On the londat store (1,046 blocks, 753 kB manifest) a scoped `openStoreHandle` takes 198 ms, not 305 ms with 0.17.0 (median of 9) (https://github.com/danbri/factoidal/issues/1039, https://github.com/danbri/factoidal-core/pull/141).
|
|
21
|
+
- Tests: the W3C manifest runner `l4w3c` reads `.n3` expected results when they are Turtle-compatible (else the test is unsupported, with the reason) and runs Jena's `mfx:TestQuery` as a query evaluation test; Jena ARQ manifests: 31 pass (was 13) of 308 (https://github.com/danbri/factoidal-core/pull/138). The Jena ARQ expression suite drops the 62 expected failures these fixes remove. `tools/split-read-differential.sh` runs on macOS and fails when it checks no file (https://github.com/danbri/factoidal-core/pull/133).
|
|
22
|
+
- LWS client (https://github.com/danbri/factoidal/issues/1065): `read` takes `rangeStart`, `rangeEnd`, `ifNoneMatch` and `prefer` and reports `notModified` (304) and `contentRange`; `verifyNotification` takes `trustedStorages` and never asks an untrusted storage for its description (theorem `step_start_verify_trusted`); `search` and `typeIndex` take `onePage` (one page with its `next`, theorem `onPage_onePage_no_fetch`) and `endpoint` (the service endpoint, no discovery); `describe`, `readPage`, `findLinkset`, `putLinkset`, `getSubscription` and `queryFormats` (OPTIONS, `Accept-Query`) are new kinds; `subscribe` takes `expires`; `updateLinkset` takes `linksetUrl`; a session key may be Ed25519 (EdDSA) as well as P-256; the session takes `bearer` (`{token, realm?}`, a token with no realm goes to every request) and `idToken`. New wasm op `lwsClientNewKey` and `createDidKey({ algorithm })` make a P-256 or Ed25519 `did:key` agent from host random bytes (theorems `multikeyJwk?_p256`, `multikeyJwk?_ed25519`). Each `done` document has a `report` (Metadata, Item, Page, Storage, Subscription shapes read in Lean, `Client/Report.lean`) and each `fail` document a `report` with the driver error kind (`LwsClientError.report`). The lws-driver/1 adapter `deploy/lws-client-adapter/adapter.mjs` only translates JSON now; checker result in its README. Needs a wasm with the op `lwsClientNewKey`.
|
|
23
|
+
- WebDAV: `factoidal webdav-serve CONFIGDIR [--port N] [--host ADDRESS]` serves LWS storages to file browsers (macOS Finder, Windows Explorer, GNOME Files, KDE Dolphin) over WebDAV, RFC 4918 classes 1 and 2 (https://github.com/danbri/factoidal/issues/1066). The root collection holds one folder per mount; a mount is one Markdown file `CONFIGDIR/NAME.md` (`- kind: local` with `- path:` an `lws-serve` storage directory, or `- kind: lws` with `- url:` a remote storage and `- auth: none | did-key | bearer`). A local mount is opened by the ops `lws-serve` uses, as the storage's owner; a remote mount is reached through the LWS client, with a self-issued `did:key` credential from a P-256 key file (made when absent). Every decision is in Lean (`L4Factoidal/WebDAV/`); the host `webdav/server.mjs` carries sockets, files and fetches. New wasm ops `webdavOpen`, `webdavLoad`, `webdavStep`, `webdavResume`, `webdavClose`. JS: `listen({ configDir })` and `openWebdav({ configDir })` from `@factoidal/core/webdav`. litmus 0.13: 208 pass, 0 fail (out of 208: basic, copymove, props, locks, http on a local and a remote mount). Not yet: dead properties and locks kept across a restart (https://github.com/danbri/factoidal/issues/1078); checks with the real file browsers (https://github.com/danbri/factoidal/issues/1079). Design record: `docs/designissues/2026-10-27-webdav.md`.
|
|
24
|
+
- JSON-LD-star (RDF 1.2 triple terms, reifiers, annotations; JSON-LD-star draft, json-ld/json-ld-star `1.2-tests`): option `rdfstar` on `jsonldToRdf`, `jsonldFromRdf`, `parse` and `serialize` with `format: 'jsonld'` reads and writes `@triple`, `@reifies` and `@annotation`; without it a triple term is refused when writing JSON-LD. 170 of 171 json-ld-star tests pass (the one fail is a defective fixture, fromRdf tst07); W3C json-ld-api unchanged (791 pass, 2 local-override, of 793) (https://github.com/danbri/factoidal-core/pull/134, https://github.com/danbri/factoidal-core/pull/140).
|
|
25
|
+
|
|
5
26
|
## 0.17.0 (2026-10-27)
|
|
6
27
|
|
|
7
28
|
- **Breaking:** `canonicalHash(graph)` now returns the SHA-256 (64 lowercase hex digits) of the RDFC-1.0 canonical N-Quads, as the RDF Dataset Canonicalization specification uses it. Before, it returned the canonical text. Use `canonicalize()` for the text. The engine computes the digest in Lean (new op `datasetCanonicalHash`) (https://github.com/danbri/factoidal/issues/1040, https://github.com/danbri/factoidal-core/pull/127).
|
package/README.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @factoidal/core
|
|
2
2
|
|
|
3
|
+
> ⚠️ **EXPERIMENTAL UNREVIEWED CODE - USE WITH CARE - DO NOT CREATE PUBLIC SERVICES USING THIS WITHOUT CAREFUL INDEPENDENT EXPERT REVIEW.**
|
|
4
|
+
>
|
|
5
|
+
> This covers the whole core library and every command line tool, and in
|
|
6
|
+
> particular all protocol, authentication and security code (the LWS, Solid,
|
|
7
|
+
> SPARQL Protocol, WebDAV and XMPP servers and clients, the authorization
|
|
8
|
+
> server, JOSE, XML Signature and SAML, the crypto bindings). Owner decision,
|
|
9
|
+
> 2026-10-27.
|
|
10
|
+
|
|
3
11
|
RDF, SPARQL, reasoning, validation and Linked Data servers in one package.
|
|
4
12
|
Runs in Node 22.12+, Deno 2 and browsers. No dependencies.
|
|
5
13
|
|
|
@@ -43,7 +51,13 @@ RDF 1.2 input uses `turtle12`, `ntriples12`, `nquads12` or `trig12`.
|
|
|
43
51
|
- Canonicalization: RDFC-1.0; Verifiable Credentials Data Integrity (eddsa-rdfc-2022), did:key
|
|
44
52
|
- XML: XML 1.0, XPath 1.0, XSLT 1.0, XForms
|
|
45
53
|
- Servers: SPARQL 1.1 Protocol and Graph Store (Fuseki-compatible paths,
|
|
46
|
-
Jena TDB2 read only), Linked Web Storage 1.0, Solid
|
|
54
|
+
Jena TDB2 read only), Linked Web Storage 1.0, Solid, an OAuth 2.0
|
|
55
|
+
authorization server (token exchange for OpenID Connect ID tokens,
|
|
56
|
+
self-issued CID credentials and SAML 2.0 assertions)
|
|
57
|
+
- Security: JWS/JWT (ES256, RS256, EdDSA), XML Signature verification in the
|
|
58
|
+
SAML Core profile (Exclusive XML Canonicalization 1.0, rsa-sha256), with
|
|
59
|
+
HACL\* primitives. XML Encryption and JWE are not implemented yet:
|
|
60
|
+
https://github.com/danbri/factoidal/issues/1081
|
|
47
61
|
- A persisted store with a delta log for updates (early stage)
|
|
48
62
|
- Terms: a JavaScript extension function can answer with an exact term
|
|
49
63
|
(`{value: "007", datatype: XSD + "integer"}`, an RDF/JS term, a triple
|
|
@@ -189,6 +203,7 @@ $ npx factoidal query store 'SELECT * WHERE { ?s ?p ?o } LIMIT 5'
|
|
|
189
203
|
$ npx factoidal query "$(npx factoidal sample-store)" 'SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }'
|
|
190
204
|
$ npx factoidal lws-serve DIR --issuer IRI # Linked Web Storage
|
|
191
205
|
$ npx factoidal solid-serve DIR # Solid Protocol
|
|
206
|
+
$ npx factoidal authz-serve DIR --resource IRI --saml-idp idp.json # OAuth 2.0 authorization server
|
|
192
207
|
```
|
|
193
208
|
|
|
194
209
|
`npx factoidal --help` lists every command. Replacing Apache Jena Fuseki:
|
package/authz/server.mjs
CHANGED
|
@@ -169,6 +169,8 @@ async function fetchFrom (url, accept, address) {
|
|
|
169
169
|
* @param {string|null} [options.storageKey] the key text; default FACTOIDAL_LWS_KEY; null reads none
|
|
170
170
|
* @param {boolean|string[]} [options.cors] false for no CORS fields, or the origins to answer;
|
|
171
171
|
* default: echo any valid request Origin (issue 923)
|
|
172
|
+
* @param {{entityId: string, jwk: object}[]} [options.samlIdps] the SAML 2.0 identity providers
|
|
173
|
+
* whose signed assertions are accepted, each with its RSA public JWK (issue 926)
|
|
172
174
|
* @param {object} [options.engine] an engine to reuse
|
|
173
175
|
*/
|
|
174
176
|
export async function openAuthorizationServer (options = {}) {
|
|
@@ -192,6 +194,9 @@ export async function openAuthorizationServer (options = {}) {
|
|
|
192
194
|
// CORS (issue 923): false or a list of origins, passed unread; absent, the
|
|
193
195
|
// engine echoes the request Origin (L4Factoidal/Authz/Server.lean stepCors).
|
|
194
196
|
if (options.cors !== undefined) config.cors = options.cors
|
|
197
|
+
// SAML 2.0 identity providers (issue 926): [{ entityId, jwk }], passed
|
|
198
|
+
// unread; the engine checks each (Wasm/Ops/Authz.lean samlIdpsOf?).
|
|
199
|
+
if (options.samlIdps !== undefined) config.samlIdps = options.samlIdps
|
|
195
200
|
const key = storageKeyText(options.storageKey)
|
|
196
201
|
if (key !== undefined) config.storageKey = key
|
|
197
202
|
if (dir !== null) {
|
package/bin/factoidal.mjs
CHANGED
|
@@ -90,6 +90,15 @@ function err (line) { console.error(line) }
|
|
|
90
90
|
|
|
91
91
|
// ---------------------------------------------------------------- usage
|
|
92
92
|
|
|
93
|
+
// Owner, 2026-10-27: every protocol, authentication and security component,
|
|
94
|
+
// the CLIs and the core library carry this notice. It is printed with every
|
|
95
|
+
// help text and on standard error when a server, client or key command starts.
|
|
96
|
+
const REVIEW_WARNING = 'EXPERIMENTAL UNREVIEWED CODE - USE WITH CARE - DO NOT CREATE PUBLIC SERVICES USING THIS WITHOUT CAREFUL INDEPENDENT EXPERT REVIEW.'
|
|
97
|
+
|
|
98
|
+
/** The commands that serve, speak a protocol, or handle keys and tokens. */
|
|
99
|
+
const WARNED_COMMANDS = new Set(['serve', 'lws-serve', 'solid-serve', 'solid-client', 'lws-client',
|
|
100
|
+
'authz-serve', 'authz-keys', 'xmpp-serve', 'webdav-serve'])
|
|
101
|
+
|
|
93
102
|
const USAGE = `factoidal - the Factoidal persisted store, from Node or Deno
|
|
94
103
|
|
|
95
104
|
usage: factoidal <command> [options]
|
|
@@ -113,6 +122,8 @@ commands:
|
|
|
113
122
|
(RFC 8414 metadata, JWK Set, RFC 8693 token exchange)
|
|
114
123
|
authz-keys DIR print the public JWK Set of an authorization server
|
|
115
124
|
xmpp-serve DIR serve XMPP (RFC 6120/6121) over TCP or direct TLS
|
|
125
|
+
webdav-serve CONFIGDIR serve local and remote LWS storages to file
|
|
126
|
+
browsers over WebDAV (RFC 4918)
|
|
116
127
|
|
|
117
128
|
global options:
|
|
118
129
|
-h, --help print this text, or a command's own help
|
|
@@ -353,6 +364,7 @@ usage: factoidal authz-serve DIR --resource IRI[,IRI...] [--issuer IRI]
|
|
|
353
364
|
[--token-lifetime SECONDS] [--rotate-after SECONDS]
|
|
354
365
|
[--allow-http] [--allow-private-fetch]
|
|
355
366
|
[--cors-origin ORIGIN]... [--no-cors]
|
|
367
|
+
[--saml-idp FILE]...
|
|
356
368
|
|
|
357
369
|
An authorization server as a component (https://github.com/danbri/factoidal/issues/916),
|
|
358
370
|
for lws-serve and any resource server that reads RFC 8414 metadata:
|
|
@@ -361,8 +373,17 @@ for lws-serve and any resource server that reads RFC 8414 metadata:
|
|
|
361
373
|
POST /token RFC 8693 token exchange for an RFC 9068
|
|
362
374
|
access token whose aud is the resource
|
|
363
375
|
Subject tokens: OpenID Connect ID tokens (lws10-authn-openid) and
|
|
364
|
-
self-issued JWTs for https and did:key subjects (lws10-authn-ssi-cid)
|
|
365
|
-
2.0
|
|
376
|
+
self-issued JWTs for https and did:key subjects (lws10-authn-ssi-cid), and
|
|
377
|
+
SAML 2.0 assertions (lws10-authn-saml) from the identity providers --saml-idp
|
|
378
|
+
names (https://github.com/danbri/factoidal/issues/926).
|
|
379
|
+
|
|
380
|
+
--saml-idp FILE (repeatable) trusts one SAML 2.0 identity provider. FILE is
|
|
381
|
+
JSON: {"entityId": "<the saml:Issuer value>", "jwk": <its RSA public JWK>}.
|
|
382
|
+
An assertion must carry one enveloped XML Signature (exclusive
|
|
383
|
+
canonicalization, rsa-sha256) that verifies under that key, an audience that
|
|
384
|
+
names the issuer of this server, and bearer subject confirmation; its NameID
|
|
385
|
+
is the subject and its Recipient the client. Encrypted assertions are
|
|
386
|
+
refused. Without --saml-idp the token type is refused and not advertised.
|
|
366
387
|
|
|
367
388
|
--resource names the resource IRIs (for lws-serve: BASE.well-known/lws-storage)
|
|
368
389
|
tokens may be issued for; any other is refused with invalid_target.
|
|
@@ -518,7 +539,8 @@ is required):
|
|
|
518
539
|
BASE.well-known/lws-token, the JWK Set at
|
|
519
540
|
BASE.well-known/lws-jwks, signing keys in DIR (as
|
|
520
541
|
authz-serve; --alg, --token-lifetime, --rotate-after,
|
|
521
|
-
--allow-http
|
|
542
|
+
--allow-http, --allow-private-fetch and --saml-idp as
|
|
543
|
+
there). The
|
|
522
544
|
storage trusts it, and any --issuer too. Each request
|
|
523
545
|
is the authorization server's when the engine says so
|
|
524
546
|
(L4Factoidal/Authz/Mount.lean); every other is the
|
|
@@ -644,6 +666,47 @@ MUC/MIX/GC3 rooms.
|
|
|
644
666
|
Specifications: https://www.rfc-editor.org/rfc/rfc6120 and
|
|
645
667
|
https://www.rfc-editor.org/rfc/rfc6121`,
|
|
646
668
|
|
|
669
|
+
'webdav-serve': `factoidal webdav-serve - serve LWS storages to file browsers over WebDAV
|
|
670
|
+
|
|
671
|
+
usage: factoidal webdav-serve CONFIGDIR [--port N] [--host ADDRESS]
|
|
672
|
+
|
|
673
|
+
Starts a WebDAV server (RFC 4918, classes 1 and 2) that macOS Finder,
|
|
674
|
+
Windows Explorer, GNOME Files and KDE Dolphin can connect to. Its root
|
|
675
|
+
collection holds one folder per mount; a mount is one Markdown file
|
|
676
|
+
CONFIGDIR/NAME.md (README.md is not a mount):
|
|
677
|
+
|
|
678
|
+
# Display name
|
|
679
|
+
- kind: local | lws
|
|
680
|
+
- path: ./alice-storage (local: an lws-serve storage directory)
|
|
681
|
+
- url: https://storage.example/alice/ (lws: the storage root)
|
|
682
|
+
- auth: none | did-key | bearer
|
|
683
|
+
- key-file: ./alice.jwk (did-key: a P-256 private JWK; made when absent)
|
|
684
|
+
- token-file: ./alice.token (bearer: an access token)
|
|
685
|
+
- writable: true | false (default true for local, false for lws)
|
|
686
|
+
- storage: snapshot | files (local: default what the directory holds)
|
|
687
|
+
- base: IRI (local: the storage root's IRI, default http://localhost/)
|
|
688
|
+
|
|
689
|
+
Only the first "# " heading and the first "- key: value" list are read; the
|
|
690
|
+
words in brackets above are notes, not part of a value. A malformed file
|
|
691
|
+
stops the start with its name and line number.
|
|
692
|
+
|
|
693
|
+
A local mount opens the directory with the operations lws-serve uses, as
|
|
694
|
+
the storage's owner (do not serve the same directory with lws-serve at the
|
|
695
|
+
same time). An lws mount reaches the storage through the LWS client.
|
|
696
|
+
|
|
697
|
+
--port N the port to bind (default 8090; 0 takes an ephemeral one)
|
|
698
|
+
--host ADDRESS the address to bind (default 127.0.0.1)
|
|
699
|
+
|
|
700
|
+
Connect: macOS Finder, Go > Connect to Server, http://127.0.0.1:8090/ ;
|
|
701
|
+
Windows, Map network drive, http://127.0.0.1:8090/ ; GNOME Files,
|
|
702
|
+
dav://127.0.0.1:8090/ .
|
|
703
|
+
|
|
704
|
+
The server makes no protocol decision: every method, status, property, lock
|
|
705
|
+
and mapping is decided by formal/lean4/L4Factoidal/WebDAV/. A WebAssembly
|
|
706
|
+
module built before the WebDAV operations landed makes this command exit 3
|
|
707
|
+
and say so. Design: docs/designissues/2026-10-27-webdav.md
|
|
708
|
+
(https://github.com/danbri/factoidal/issues/1066).`,
|
|
709
|
+
|
|
647
710
|
'solid-client': `factoidal solid-client - run one Solid client operation
|
|
648
711
|
|
|
649
712
|
usage: factoidal solid-client <get|put|post|delete|discover> URL [options]
|
|
@@ -764,7 +827,7 @@ function parseArguments (args, valueOptions) {
|
|
|
764
827
|
class UsageError extends Error {}
|
|
765
828
|
|
|
766
829
|
/** Options that may be given more than once; their value is the list. */
|
|
767
|
-
const REPEATABLE_OPTIONS = new Set(['cors-origin'])
|
|
830
|
+
const REPEATABLE_OPTIONS = new Set(['cors-origin', 'saml-idp'])
|
|
768
831
|
|
|
769
832
|
const VALUE_OPTIONS = {
|
|
770
833
|
version: new Set([]),
|
|
@@ -778,7 +841,8 @@ const VALUE_OPTIONS = {
|
|
|
778
841
|
serve: new Set(['port', 'host', 'query-path', 'gsp-path', 'file', 'layout', 'tdb2']),
|
|
779
842
|
'lws-serve': new Set(['port', 'host', 'base', 'issuer', 'auth-config', 'owner', 'root-acl',
|
|
780
843
|
'audience', 'page-size', 'moves', 'controller-inbox', 'delivery-limit', 'delivery-window',
|
|
781
|
-
'redirects-max', 'storage', 'cors-origin', 'authz', 'alg', 'token-lifetime', 'rotate-after'
|
|
844
|
+
'redirects-max', 'storage', 'cors-origin', 'authz', 'alg', 'token-lifetime', 'rotate-after',
|
|
845
|
+
'saml-idp']),
|
|
782
846
|
'solid-serve': new Set(['port', 'host', 'base', 'owner', 'issuer', 'auth-config']),
|
|
783
847
|
'solid-client': new Set(['body', 'file', 'content-type', 'interpret']),
|
|
784
848
|
'lws-client': new Set(['body', 'file', 'content-type', 'slug', 'type', 'link', 'if-match',
|
|
@@ -786,9 +850,10 @@ const VALUE_OPTIONS = {
|
|
|
786
850
|
'target', 'subject-token-file', 'subject-token-type', 'access-token', 'storage', 'root',
|
|
787
851
|
'inbox']),
|
|
788
852
|
'authz-serve': new Set(['port', 'host', 'issuer', 'resource', 'alg', 'token-lifetime', 'rotate-after',
|
|
789
|
-
'cors-origin']),
|
|
853
|
+
'cors-origin', 'saml-idp']),
|
|
790
854
|
'authz-keys': new Set([]),
|
|
791
|
-
'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key'])
|
|
855
|
+
'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key']),
|
|
856
|
+
'webdav-serve': new Set(['port', 'host'])
|
|
792
857
|
}
|
|
793
858
|
|
|
794
859
|
// ------------------------------------------------------------- commands
|
|
@@ -1700,6 +1765,16 @@ function corsConfig (options) {
|
|
|
1700
1765
|
return {}
|
|
1701
1766
|
}
|
|
1702
1767
|
|
|
1768
|
+
// --saml-idp FILE (repeatable): a SAML 2.0 identity provider whose signed
|
|
1769
|
+
// assertions the authorization server accepts, as the config member
|
|
1770
|
+
// `samlIdps`. FILE is JSON {"entityId": ID, "jwk": RSA public JWK}; passed
|
|
1771
|
+
// unread, the engine checks it (Wasm/Ops/Authz.lean, issue 926).
|
|
1772
|
+
function samlConfig (options) {
|
|
1773
|
+
const files = options['saml-idp']
|
|
1774
|
+
if (!Array.isArray(files)) return {}
|
|
1775
|
+
return { samlIdps: files.map((f) => readJsonFile(f)) }
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1703
1778
|
function serveHost (options) {
|
|
1704
1779
|
if (options.host === undefined) return '127.0.0.1'
|
|
1705
1780
|
if (typeof options.host !== 'string' || options.host.length === 0) {
|
|
@@ -1833,7 +1908,8 @@ async function commandLwsServe (positional, options) {
|
|
|
1833
1908
|
...(options['rotate-after'] !== undefined ? { rotateAfter: natOption('rotate-after', options['rotate-after']) } : {}),
|
|
1834
1909
|
...(options['allow-http'] === true ? { allowHttp: true } : {}),
|
|
1835
1910
|
...(options['allow-private-fetch'] === true ? { allowPrivateFetch: true } : {}),
|
|
1836
|
-
...corsConfig(options)
|
|
1911
|
+
...corsConfig(options),
|
|
1912
|
+
...samlConfig(options)
|
|
1837
1913
|
}
|
|
1838
1914
|
let auth
|
|
1839
1915
|
if (authFile !== null) {
|
|
@@ -1920,7 +1996,8 @@ async function commandAuthzServe (positional, options) {
|
|
|
1920
1996
|
...(options['rotate-after'] !== undefined ? { rotateAfter: natOption('rotate-after', options['rotate-after']) } : {}),
|
|
1921
1997
|
...(options['allow-http'] === true ? { allowHttp: true } : {}),
|
|
1922
1998
|
...(options['allow-private-fetch'] === true ? { allowPrivateFetch: true } : {}),
|
|
1923
|
-
...corsConfig(options)
|
|
1999
|
+
...corsConfig(options),
|
|
2000
|
+
...samlConfig(options)
|
|
1924
2001
|
})
|
|
1925
2002
|
} catch (error) {
|
|
1926
2003
|
if (error !== null && typeof error === 'object' && error.envelope !== undefined) {
|
|
@@ -2027,6 +2104,34 @@ async function commandSolidServe (positional, options) {
|
|
|
2027
2104
|
return untilInterrupted(running, quiet)
|
|
2028
2105
|
}
|
|
2029
2106
|
|
|
2107
|
+
// webdav-serve CONFIGDIR (https://github.com/danbri/factoidal/issues/1066):
|
|
2108
|
+
// the host carries sockets, files and fetches; the engine reads the mount
|
|
2109
|
+
// files and decides every answer (L4Factoidal/WebDAV/).
|
|
2110
|
+
async function commandWebdavServe (positional, options) {
|
|
2111
|
+
if (positional.length !== 1) throw new UsageError('webdav-serve needs one CONFIGDIR')
|
|
2112
|
+
const quiet = options.quiet === true
|
|
2113
|
+
const { listen, WebdavHostError } = await import('../webdav/server.mjs')
|
|
2114
|
+
let running
|
|
2115
|
+
try {
|
|
2116
|
+
running = await listen({ configDir: positional[0], port: servePort(options, 8090), host: serveHost(options) })
|
|
2117
|
+
} catch (error) {
|
|
2118
|
+
if (error instanceof WebdavHostError && error.unknownOp) return notWired('webdav-serve', error.message)
|
|
2119
|
+
if (error instanceof WebdavHostError || (error !== null && typeof error === 'object' && error.code === 'ENOENT')) {
|
|
2120
|
+
err(`factoidal webdav-serve: ${error.message}`)
|
|
2121
|
+
return EXIT_FAILURE
|
|
2122
|
+
}
|
|
2123
|
+
throw error
|
|
2124
|
+
}
|
|
2125
|
+
if (!quiet) {
|
|
2126
|
+
err(`webdav-serve: listening on ${running.origin} for ${positional[0]}`)
|
|
2127
|
+
for (const m of running.mounts) {
|
|
2128
|
+
err(`webdav-serve: ${running.origin}/${encodeURIComponent(m.name)}/ - ${m.title} (${m.kind}${m.writable ? '' : ', read-only'}${m.agent ? ', as ' + m.agent : ''})`)
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
out(running.origin)
|
|
2132
|
+
return untilInterrupted(running, quiet)
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2030
2135
|
async function commandXmppServe (positional, options) {
|
|
2031
2136
|
if (positional.length !== 1) throw new UsageError('xmpp-serve needs one DIR')
|
|
2032
2137
|
const quiet = options.quiet === true
|
|
@@ -2207,19 +2312,22 @@ const COMMANDS = {
|
|
|
2207
2312
|
'lws-client': commandLwsClient,
|
|
2208
2313
|
'authz-serve': commandAuthzServe,
|
|
2209
2314
|
'authz-keys': commandAuthzKeys,
|
|
2210
|
-
'xmpp-serve': commandXmppServe
|
|
2315
|
+
'xmpp-serve': commandXmppServe,
|
|
2316
|
+
'webdav-serve': commandWebdavServe
|
|
2211
2317
|
}
|
|
2212
2318
|
|
|
2213
2319
|
// ----------------------------------------------------------------- main
|
|
2214
2320
|
|
|
2215
2321
|
async function main () {
|
|
2216
2322
|
if (argv.length === 0) {
|
|
2323
|
+
out(REVIEW_WARNING + '\n')
|
|
2217
2324
|
out(USAGE)
|
|
2218
2325
|
return EXIT_USAGE
|
|
2219
2326
|
}
|
|
2220
2327
|
const command = argv[0]
|
|
2221
2328
|
if (command === '--help' || command === '-h' || command === 'help') {
|
|
2222
2329
|
const topic = argv[1]
|
|
2330
|
+
out(REVIEW_WARNING + '\n')
|
|
2223
2331
|
if (typeof topic === 'string' && Object.prototype.hasOwnProperty.call(COMMAND_USAGE, topic)) {
|
|
2224
2332
|
out(COMMAND_USAGE[topic])
|
|
2225
2333
|
} else {
|
|
@@ -2244,9 +2352,13 @@ async function main () {
|
|
|
2244
2352
|
throw error
|
|
2245
2353
|
}
|
|
2246
2354
|
if (parsed.options.help) {
|
|
2355
|
+
out(REVIEW_WARNING + '\n')
|
|
2247
2356
|
out(COMMAND_USAGE[command])
|
|
2248
2357
|
return EXIT_OK
|
|
2249
2358
|
}
|
|
2359
|
+
if (WARNED_COMMANDS.has(command)) {
|
|
2360
|
+
err(`factoidal ${command}: WARNING: ${REVIEW_WARNING}`)
|
|
2361
|
+
}
|
|
2250
2362
|
try {
|
|
2251
2363
|
return await COMMANDS[command](parsed.positional, parsed.options)
|
|
2252
2364
|
} catch (error) {
|
package/bin/store.mjs
CHANGED
|
@@ -123,7 +123,7 @@ export const STACK_REMEDY = {
|
|
|
123
123
|
export function openStore (root, generationName = null) {
|
|
124
124
|
if (typeof generationName !== 'string') {
|
|
125
125
|
const opened = openCollection(root)
|
|
126
|
-
return { ...opened,
|
|
126
|
+
return withManifestHex({ ...opened, activated: true })
|
|
127
127
|
}
|
|
128
128
|
const generationDir = joinPath(root, generationName)
|
|
129
129
|
for (const manifestName of MANIFEST_NAMES) {
|
|
@@ -133,23 +133,67 @@ export function openStore (root, generationName = null) {
|
|
|
133
133
|
} catch (_error) {
|
|
134
134
|
continue
|
|
135
135
|
}
|
|
136
|
-
return {
|
|
136
|
+
return withManifestHex({
|
|
137
137
|
root,
|
|
138
138
|
generation: generationName,
|
|
139
139
|
generationDir,
|
|
140
140
|
manifestName,
|
|
141
141
|
manifest,
|
|
142
|
-
manifestHex: hexOfBytes(manifest),
|
|
143
142
|
activated: false
|
|
144
|
-
}
|
|
143
|
+
})
|
|
145
144
|
}
|
|
146
145
|
throw new StoreOperationError(
|
|
147
146
|
`${generationDir} has none of ${MANIFEST_NAMES.join(', ')}`)
|
|
148
147
|
}
|
|
149
148
|
|
|
149
|
+
/**
|
|
150
|
+
* `store.manifestHex`, the hexadecimal the older manifest ops take, computed
|
|
151
|
+
* on first read. The byte ops (`hasManifestBytesOps`) do not read it, so a
|
|
152
|
+
* store opened for them never builds the 1.5 million digits of a large
|
|
153
|
+
* manifest (https://github.com/danbri/factoidal/issues/1039).
|
|
154
|
+
*/
|
|
155
|
+
function withManifestHex (store) {
|
|
156
|
+
let hex = null
|
|
157
|
+
return Object.defineProperty(store, 'manifestHex', {
|
|
158
|
+
enumerable: true,
|
|
159
|
+
get () {
|
|
160
|
+
if (hex === null) hex = hexOfBytes(store.manifest)
|
|
161
|
+
return hex
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The byte forms of the manifest ops (formal/lean4/Wasm/Ops/StoreHandles.lean). */
|
|
167
|
+
const MANIFEST_BYTES_OPS = ['storeManifestInspectBytes', 'storeQueryPlanBytes', 'storeOpenBytes']
|
|
168
|
+
const manifestBytesSupport = new WeakMap()
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Whether the engine serves the byte forms of the manifest ops. They take
|
|
172
|
+
* the manifest as the byte region, not as hexadecimal inside the JSON
|
|
173
|
+
* arguments, and the engine decodes it once for a plan and the open that
|
|
174
|
+
* follows. An engine built before them has only the hexadecimal ops, and
|
|
175
|
+
* this host then uses those, as before.
|
|
176
|
+
*/
|
|
177
|
+
function hasManifestBytesOps (engine) {
|
|
178
|
+
let known = manifestBytesSupport.get(engine)
|
|
179
|
+
if (known === undefined) {
|
|
180
|
+
known = false
|
|
181
|
+
if (typeof engine.callBlobIO === 'function') {
|
|
182
|
+
const reflection = engine.call('ops', [])
|
|
183
|
+
const blobIoOps = Array.isArray(reflection && reflection.blobIoOps) ? reflection.blobIoOps : []
|
|
184
|
+
known = MANIFEST_BYTES_OPS.every((op) => blobIoOps.includes(op))
|
|
185
|
+
}
|
|
186
|
+
manifestBytesSupport.set(engine, known)
|
|
187
|
+
}
|
|
188
|
+
return known
|
|
189
|
+
}
|
|
190
|
+
|
|
150
191
|
/** `storeManifestInspect` — decode one manifest. */
|
|
151
192
|
export function inspectManifest (engine, store) {
|
|
152
193
|
try {
|
|
194
|
+
if (hasManifestBytesOps(engine)) {
|
|
195
|
+
return engine.callBlobIO('storeManifestInspectBytes', [], store.manifest).envelope
|
|
196
|
+
}
|
|
153
197
|
return engine.call('storeManifestInspect', [store.manifestHex])
|
|
154
198
|
} catch (error) {
|
|
155
199
|
throw asStoreError(error)
|
|
@@ -159,6 +203,9 @@ export function inspectManifest (engine, store) {
|
|
|
159
203
|
/** `storeQueryPlan` — the artifact keys this query needs, and the mode. */
|
|
160
204
|
export function planQuery (engine, store, sparql) {
|
|
161
205
|
try {
|
|
206
|
+
if (hasManifestBytesOps(engine)) {
|
|
207
|
+
return engine.callBlobIO('storeQueryPlanBytes', [sparql], store.manifest).envelope
|
|
208
|
+
}
|
|
162
209
|
return engine.call('storeQueryPlan', [store.manifestHex, sparql])
|
|
163
210
|
} catch (error) {
|
|
164
211
|
throw asStoreError(error)
|
|
@@ -194,16 +241,20 @@ function capDecision (engine, store, sparql) {
|
|
|
194
241
|
}
|
|
195
242
|
}
|
|
196
243
|
|
|
197
|
-
/** Read the named artifacts and concatenate them into one region.
|
|
198
|
-
|
|
244
|
+
/** Read the named artifacts and concatenate them into one region. With a
|
|
245
|
+
* `prefix` (the manifest, for `storeOpenBytes`) the region starts with it,
|
|
246
|
+
* and the windows still count from the first artifact byte. */
|
|
247
|
+
function regionOf (store, keys, prefix = null) {
|
|
199
248
|
const chunks = keys.map((key) => readWhole(joinPath(store.generationDir, key)))
|
|
249
|
+
const start = prefix ? prefix.length : 0
|
|
200
250
|
let total = 0
|
|
201
251
|
for (const chunk of chunks) total += chunk.length
|
|
202
|
-
const blob = new Uint8Array(total)
|
|
252
|
+
const blob = new Uint8Array(start + total)
|
|
253
|
+
if (prefix) blob.set(prefix, 0)
|
|
203
254
|
const artifacts = []
|
|
204
255
|
let offset = 0
|
|
205
256
|
for (let index = 0; index < chunks.length; index += 1) {
|
|
206
|
-
blob.set(chunks[index], offset)
|
|
257
|
+
blob.set(chunks[index], start + offset)
|
|
207
258
|
artifacts.push({ key: keys[index], offset, len: chunks[index].length })
|
|
208
259
|
offset += chunks[index].length
|
|
209
260
|
}
|
|
@@ -374,11 +425,16 @@ export function openStoreHandle (engine, store, options = {}) {
|
|
|
374
425
|
} else {
|
|
375
426
|
keys = manifestKeys(engine, store)
|
|
376
427
|
}
|
|
377
|
-
const
|
|
428
|
+
const bytesOps = hasManifestBytesOps(engine)
|
|
429
|
+
// The byte op takes the manifest as the first bytes of the region.
|
|
430
|
+
const { blob, artifacts } = regionOf(store, keys, bytesOps ? store.manifest : null)
|
|
378
431
|
let envelope
|
|
379
432
|
try {
|
|
380
|
-
envelope =
|
|
381
|
-
|
|
433
|
+
envelope = bytesOps
|
|
434
|
+
? engine.callBlobIO('storeOpenBytes',
|
|
435
|
+
[String(store.manifest.length), JSON.stringify(artifacts)], blob).envelope
|
|
436
|
+
: engine.callBlobIO('storeOpen',
|
|
437
|
+
[store.manifestHex, JSON.stringify(artifacts)], blob).envelope
|
|
382
438
|
} catch (error) {
|
|
383
439
|
throw asStoreError(error)
|
|
384
440
|
}
|
package/index.d.ts
CHANGED
|
@@ -27,8 +27,11 @@ export interface BlankNode {
|
|
|
27
27
|
export interface Literal {
|
|
28
28
|
termType: 'Literal';
|
|
29
29
|
value: string;
|
|
30
|
-
/** Language tag ('' when none). */
|
|
30
|
+
/** Language tag, lowercase ('' when none). */
|
|
31
31
|
language: string;
|
|
32
|
+
/** Base direction of a directional language-tagged string (RDF 1.2):
|
|
33
|
+
* 'ltr' or 'rtl'; '' when none. RDF/JS Data Model, Literal.direction. */
|
|
34
|
+
direction: '' | 'ltr' | 'rtl';
|
|
32
35
|
/** Datatype IRI term (xsd:string for plain literals). */
|
|
33
36
|
datatype: NamedNode;
|
|
34
37
|
equals(other: Term | null | undefined): boolean;
|
|
@@ -212,6 +215,9 @@ export interface WriteOptions {
|
|
|
212
215
|
useRdfType?: boolean;
|
|
213
216
|
/** 'jsonld' only. */
|
|
214
217
|
rdfDirection?: string;
|
|
218
|
+
/** 'jsonld' only: write RDF 1.2 triple terms as JSON-LD-star (`@triple`,
|
|
219
|
+
* `@reifies`, `@annotation`). Without it a triple term is refused. */
|
|
220
|
+
rdfstar?: boolean;
|
|
215
221
|
}
|
|
216
222
|
|
|
217
223
|
export type WriteFormat =
|
|
@@ -1011,6 +1017,9 @@ export interface JsonLdOptions {
|
|
|
1011
1017
|
rdfDirection?: string;
|
|
1012
1018
|
expandContext?: string;
|
|
1013
1019
|
processingMode?: string;
|
|
1020
|
+
/** JSON-LD-star: read `@triple`, `@reifies`, `@annotation` as RDF 1.2
|
|
1021
|
+
* triple terms and reifiers. */
|
|
1022
|
+
rdfstar?: boolean;
|
|
1014
1023
|
}
|
|
1015
1024
|
|
|
1016
1025
|
/**
|
|
@@ -1027,6 +1036,9 @@ export function jsonldToRdf(
|
|
|
1027
1036
|
export interface JsonLdFromRdfOptions {
|
|
1028
1037
|
useNativeTypes?: boolean;
|
|
1029
1038
|
useRdfType?: boolean;
|
|
1039
|
+
/** JSON-LD-star: write RDF 1.2 triple terms (`@triple`, `@reifies`,
|
|
1040
|
+
* `@annotation`). Without it a triple term is refused. */
|
|
1041
|
+
rdfstar?: boolean;
|
|
1030
1042
|
format?: DataFormat;
|
|
1031
1043
|
}
|
|
1032
1044
|
|
package/l4-assets/l4factoidal.js
CHANGED
|
Binary file
|
package/l4-assets/version.json
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
"engine": "lean4",
|
|
3
3
|
"note": "The Lean 4 (L4Factoidal) engine build provenance and claims: the only engine of @factoidal/core since 0.9.0 (issue 713). This committed file is a placeholder. tools/fetch-builds.sh --set web (through tools/stamp-l4-version.mjs) writes the provenance members from the danbri/factoidal-builds release that built the wasm; formal/lean4/Wasm/build-wasm.sh step 9 writes them for a local build. Do not commit a stamped copy.",
|
|
4
4
|
"stamped": true,
|
|
5
|
-
"release": "danbri/factoidal-builds build-
|
|
6
|
-
"releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/
|
|
7
|
-
"gitSha": "
|
|
8
|
-
"builtAt": "2026-10-
|
|
5
|
+
"release": "danbri/factoidal-builds build-b8c6b64b8dc1-web",
|
|
6
|
+
"releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/40525043015",
|
|
7
|
+
"gitSha": "b8c6b64b8dc18a6daa2151a61e28dbb9a4707ade",
|
|
8
|
+
"builtAt": "2026-10-27T15:28:08Z",
|
|
9
9
|
"leanToolchain": "leanprover/lean4:v4.33.1",
|
|
10
10
|
"emscripten": "6.0.8",
|
|
11
11
|
"abiVersion": "1",
|
|
12
12
|
"engineVersion": "l4factoidal-wasm 0.2.0 (dispatch: RDF parse/serialize, SPARQL query+update, RDFS/OWL-RL closures, RDFC-1.0, CL/IKL)",
|
|
13
13
|
"version": "l4factoidal-wasm 0.2.0 (dispatch: RDF parse/serialize, SPARQL query+update, RDFS/OWL-RL closures, RDFC-1.0, CL/IKL)",
|
|
14
|
-
"wasmSha256": "
|
|
15
|
-
"wasmBytes":
|
|
14
|
+
"wasmSha256": "6dbf38660cb9966ae7c65ecdb68a38e83d09c1283415c1486b618f203f3f2f39",
|
|
15
|
+
"wasmBytes": 17694204,
|
|
16
16
|
"claims": {
|
|
17
17
|
"source": "formal/lean4 (L4Factoidal): no sorry, no user axioms, no native_decide; W3C behaviour pinned by build-time #guard",
|
|
18
18
|
"suitesAtBuildSha": "see docs/test-results and formal/lean4/PORT_NOTES.md at gitSha"
|
package/lib/api.mjs
CHANGED
|
@@ -182,11 +182,10 @@ function utf16Diagnostics(text, diagnostics) {
|
|
|
182
182
|
// is never one of a partial generation.
|
|
183
183
|
// ---------------------------------------------------------------------
|
|
184
184
|
class Store {
|
|
185
|
-
constructor(e, env, gen,
|
|
185
|
+
constructor(e, env, gen, fromNQuads, withExtensionRounds) {
|
|
186
186
|
this._e = e;
|
|
187
187
|
this._env = env;
|
|
188
188
|
this._gen = gen;
|
|
189
|
-
this._manifestHex = manifestHex;
|
|
190
189
|
this._closed = false;
|
|
191
190
|
this._fromNQuads = fromNQuads;
|
|
192
191
|
this._withExtensionRounds = withExtensionRounds;
|
|
@@ -236,7 +235,9 @@ class Store {
|
|
|
236
235
|
|
|
237
236
|
/** The manifest, decoded by the engine (op storeManifestInspect). */
|
|
238
237
|
async inspect() {
|
|
239
|
-
const r = entryResult(this._e.
|
|
238
|
+
const r = entryResult(typeof this._e.storeManifestInspectBytes === 'function'
|
|
239
|
+
? this._e.storeManifestInspectBytes(this._gen.manifest)
|
|
240
|
+
: this._e.storeManifestInspect(hexOfBytes(this._gen.manifest)), 'Store.inspect');
|
|
240
241
|
const { ok: _ok, ...rest } = r;
|
|
241
242
|
return rest;
|
|
242
243
|
}
|
|
@@ -841,7 +842,7 @@ function buildApi(driver) {
|
|
|
841
842
|
* @param {Dataset|string|Array} data
|
|
842
843
|
* @param {{format?: string, version?: '1.1'|'1.2', prefixes?: object,
|
|
843
844
|
* literalShorthand?: boolean, useNativeTypes?: boolean,
|
|
844
|
-
* useRdfType?: boolean, rdfDirection?: string,
|
|
845
|
+
* useRdfType?: boolean, rdfDirection?: string, rdfstar?: boolean,
|
|
845
846
|
* inputFormat?: string, baseIRI?: string}} [options]
|
|
846
847
|
* format: nquads (default), ntriples, turtle, trig, rdfxml, jsonld,
|
|
847
848
|
* rdfc. inputFormat and baseIRI read text `data`.
|
|
@@ -1210,7 +1211,9 @@ function buildApi(driver) {
|
|
|
1210
1211
|
* A remote `@context` is not fetched.
|
|
1211
1212
|
* @param {string} jsonldText
|
|
1212
1213
|
* @param {{base?: string, rdfDirection?: string, expandContext?: string,
|
|
1213
|
-
* processingMode?: string}} [options]
|
|
1214
|
+
* processingMode?: string, rdfstar?: boolean}} [options]
|
|
1215
|
+
* rdfstar: JSON-LD-star (`@triple`, `@reifies`, `@annotation` as RDF 1.2
|
|
1216
|
+
* triple terms and reifiers).
|
|
1214
1217
|
* @returns {Promise<Dataset>}
|
|
1215
1218
|
*/
|
|
1216
1219
|
async function jsonldToRdf(jsonldText, options) {
|
|
@@ -1223,6 +1226,7 @@ function buildApi(driver) {
|
|
|
1223
1226
|
...(opts.rdfDirection ? { rdfDirection: opts.rdfDirection } : {}),
|
|
1224
1227
|
...(opts.expandContext ? { expandContext: opts.expandContext } : {}),
|
|
1225
1228
|
...(opts.processingMode ? { processingMode: opts.processingMode } : {}),
|
|
1229
|
+
...(opts.rdfstar ? { rdfstar: true } : {}),
|
|
1226
1230
|
});
|
|
1227
1231
|
return c.wrap(entryResult(c.e.datasetFromJsonLd(jsonldText, optionsJson), 'jsonldToRdf'), {},
|
|
1228
1232
|
{ format: 'jsonld', formatInferred: false });
|
|
@@ -1250,7 +1254,8 @@ function buildApi(driver) {
|
|
|
1250
1254
|
* The text form is `serialize(data, {format: 'jsonld'})`.
|
|
1251
1255
|
* @param {Dataset|string|Array} data
|
|
1252
1256
|
* @param {{useNativeTypes?: boolean, useRdfType?: boolean,
|
|
1253
|
-
* rdfDirection?: string,
|
|
1257
|
+
* rdfDirection?: string, rdfstar?: boolean, format?: string,
|
|
1258
|
+
* baseIRI?: string}} [options]
|
|
1254
1259
|
* @returns {Promise<any>}
|
|
1255
1260
|
*/
|
|
1256
1261
|
async function jsonldFromRdf(data, options) {
|
|
@@ -1907,20 +1912,30 @@ function buildApi(driver) {
|
|
|
1907
1912
|
const e = await entry();
|
|
1908
1913
|
requireEntryFn(e, 'storeOpen', 'openStore (storeOpen)');
|
|
1909
1914
|
const gen = await openGeneration(reader, opts.generation);
|
|
1910
|
-
|
|
1915
|
+
// The byte ops when the engine has them (issue 1039): the manifest
|
|
1916
|
+
// crosses as bytes and is decoded once for the plan and the open. An
|
|
1917
|
+
// older engine gets the hexadecimal ops, as before.
|
|
1918
|
+
const bytesOps = typeof e.storeOpenBytes === 'function';
|
|
1919
|
+
const manifestHex = bytesOps ? null : hexOfBytes(gen.manifest);
|
|
1911
1920
|
let keys;
|
|
1912
1921
|
if (Array.isArray(opts.keys)) {
|
|
1913
1922
|
keys = opts.keys.map(String);
|
|
1914
1923
|
} else if (typeof opts.sparql === 'string') {
|
|
1915
|
-
const plan = entryResult(
|
|
1924
|
+
const plan = entryResult(bytesOps
|
|
1925
|
+
? e.storeQueryPlanBytes(gen.manifest, opts.sparql)
|
|
1926
|
+
: e.storeQueryPlan(manifestHex, opts.sparql), 'openStore(plan)');
|
|
1916
1927
|
keys = plan.keys.concat(plan.sidecarKeys || [], plan.blobKeys || []);
|
|
1917
1928
|
} else {
|
|
1918
|
-
keys = manifestKeys(entryResult(
|
|
1929
|
+
keys = manifestKeys(entryResult(bytesOps
|
|
1930
|
+
? e.storeManifestInspectBytes(gen.manifest)
|
|
1931
|
+
: e.storeManifestInspect(manifestHex), 'openStore(inspect)'));
|
|
1919
1932
|
}
|
|
1920
1933
|
const { blob, artifacts } = await readRegion(reader, gen.generation, keys);
|
|
1921
|
-
const env = entryResult(
|
|
1934
|
+
const env = entryResult(bytesOps
|
|
1935
|
+
? e.storeOpenBytes(gen.manifest, JSON.stringify(artifacts), blob)
|
|
1936
|
+
: e.storeOpen(manifestHex, JSON.stringify(artifacts), blob), 'openStore');
|
|
1922
1937
|
const c = await ctx();
|
|
1923
|
-
return new Store(e, env, gen,
|
|
1938
|
+
return new Store(e, env, gen, (nq) => c.fromNQuads(nq, {}), withExtensionRounds);
|
|
1924
1939
|
}
|
|
1925
1940
|
|
|
1926
1941
|
/**
|
package/lib/dataset.mjs
CHANGED
|
@@ -279,7 +279,7 @@ class Dataset {
|
|
|
279
279
|
* The dataset as a document.
|
|
280
280
|
* @param {{format?: string, version?: '1.1'|'1.2', prefixes?: object,
|
|
281
281
|
* literalShorthand?: boolean, useNativeTypes?: boolean,
|
|
282
|
-
* useRdfType?: boolean, rdfDirection?: string}} [options]
|
|
282
|
+
* useRdfType?: boolean, rdfDirection?: string, rdfstar?: boolean}} [options]
|
|
283
283
|
* format: nquads (default), ntriples, turtle, trig, rdfxml, jsonld,
|
|
284
284
|
* rdfc (RDFC-1.0 canonical N-Quads).
|
|
285
285
|
* @returns {Promise<string>}
|
|
@@ -315,6 +315,7 @@ class Dataset {
|
|
|
315
315
|
...(opts.useNativeTypes ? { useNativeTypes: true } : {}),
|
|
316
316
|
...(opts.useRdfType ? { useRdfType: true } : {}),
|
|
317
317
|
...(opts.rdfDirection ? { rdfDirection: String(opts.rdfDirection) } : {}),
|
|
318
|
+
...(opts.rdfstar ? { rdfstar: true } : {}),
|
|
318
319
|
...(suffix ? { version: String(opts.version) } : {}),
|
|
319
320
|
})), 'serialize');
|
|
320
321
|
return r.jsonld;
|
package/lib/lean-entry.mjs
CHANGED
|
@@ -125,6 +125,9 @@ function installExtensionBridge(eng, entry) {
|
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/** The byte forms of the manifest ops (Wasm/Ops/StoreHandles.lean). */
|
|
129
|
+
const MANIFEST_BYTES_OPS = ['storeManifestInspectBytes', 'storeQueryPlanBytes', 'storeOpenBytes'];
|
|
130
|
+
|
|
128
131
|
/**
|
|
129
132
|
* Build the entry object lib/api.mjs drives, from a loaded engine handle.
|
|
130
133
|
*
|
|
@@ -199,9 +202,45 @@ export function entryFromEngine(eng) {
|
|
|
199
202
|
}
|
|
200
203
|
};
|
|
201
204
|
}
|
|
205
|
+
const blobIoOps = Array.isArray(reflection.blobIoOps) ? reflection.blobIoOps : [];
|
|
206
|
+
// The byte forms of the manifest ops (https://github.com/danbri/factoidal/issues/1039):
|
|
207
|
+
// the manifest crosses as the byte region, not as hexadecimal inside the
|
|
208
|
+
// JSON arguments, and the engine decodes it once for a plan and the open
|
|
209
|
+
// that follows. An engine without them has only the hexadecimal ops; the
|
|
210
|
+
// methods are then absent and lib/api.mjs uses the hexadecimal ops.
|
|
211
|
+
if (typeof eng.callBlobIO === 'function' &&
|
|
212
|
+
MANIFEST_BYTES_OPS.every((op) => blobIoOps.includes(op))) {
|
|
213
|
+
entry.storeManifestInspectBytes = (manifestBytes) => {
|
|
214
|
+
try {
|
|
215
|
+
return JSON.stringify(eng.callBlobIO('storeManifestInspectBytes', [], manifestBytes).envelope);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
return failEnvelope(err);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
entry.storeQueryPlanBytes = (manifestBytes, sparql) => {
|
|
221
|
+
try {
|
|
222
|
+
return JSON.stringify(
|
|
223
|
+
eng.callBlobIO('storeQueryPlanBytes', [String(sparql)], manifestBytes).envelope);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return failEnvelope(err);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
entry.storeOpenBytes = (manifestBytes, artifactsJson, bytes) => {
|
|
229
|
+
try {
|
|
230
|
+
const region = new Uint8Array(manifestBytes.length + bytes.length);
|
|
231
|
+
region.set(manifestBytes, 0);
|
|
232
|
+
region.set(bytes, manifestBytes.length);
|
|
233
|
+
return JSON.stringify(eng.callBlobIO('storeOpenBytes',
|
|
234
|
+
[String(manifestBytes.length), String(artifactsJson)], region).envelope);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return failEnvelope(err);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
} else {
|
|
240
|
+
for (const op of MANIFEST_BYTES_OPS) delete entry[op];
|
|
241
|
+
}
|
|
202
242
|
// datasetQuadsBin answers a byte region (formal/lean4/L4Factoidal/RDF/QuadsBin.lean):
|
|
203
243
|
// this method answers {envelope, bytes} and throws on a refusal.
|
|
204
|
-
const blobIoOps = Array.isArray(reflection.blobIoOps) ? reflection.blobIoOps : [];
|
|
205
244
|
if (typeof eng.callBlobIO === 'function' && blobIoOps.includes('datasetQuadsBin')) {
|
|
206
245
|
entry.datasetQuadsBin = (handle, optsJson) => {
|
|
207
246
|
try {
|
package/lib/version.mjs
CHANGED
package/lws/client.mjs
CHANGED
|
@@ -38,14 +38,15 @@ import { loadEngine } from '../bin/engine.mjs'
|
|
|
38
38
|
import { FactoidalError } from '../lib/errors.mjs'
|
|
39
39
|
|
|
40
40
|
/** The ops this host needs from the engine. */
|
|
41
|
-
export const LWS_CLIENT_OPS = ['lwsClientStart', 'lwsClientStep']
|
|
41
|
+
export const LWS_CLIENT_OPS = ['lwsClientStart', 'lwsClientStep', 'lwsClientNewKey']
|
|
42
42
|
|
|
43
43
|
/** The operation kinds of the ABI. Listed so a caller can be checked
|
|
44
44
|
* against the contract; this host attaches no meaning to any of them. */
|
|
45
45
|
export const LWS_CLIENT_KINDS = [
|
|
46
|
-
'discover', 'read', 'head', 'create', 'update', 'patch', 'delete',
|
|
47
|
-
'
|
|
48
|
-
'
|
|
46
|
+
'discover', 'describe', 'read', 'readPage', 'head', 'create', 'update', 'patch', 'delete',
|
|
47
|
+
'findLinkset', 'readLinkset', 'updateLinkset', 'putLinkset', 'move', 'list', 'typeIndex',
|
|
48
|
+
'search', 'queryFormats', 'subscribe', 'getSubscription', 'unsubscribe', 'requestAccess',
|
|
49
|
+
'grantAccess', 'getAccess', 'revokeAccess', 'verifyNotification'
|
|
49
50
|
]
|
|
50
51
|
|
|
51
52
|
/** The error an operation ends with. `reason` is the engine's code
|
|
@@ -58,6 +59,9 @@ export class LwsClientError extends FactoidalError {
|
|
|
58
59
|
this.name = 'LwsClientError'
|
|
59
60
|
this.reason = reason
|
|
60
61
|
this.detail = fail
|
|
62
|
+
// The engine's name for the failure in the lws-driver/1 adapter
|
|
63
|
+
// protocol ({ kind, message, ... }), as the engine wrote it.
|
|
64
|
+
this.report = fail && typeof fail.report === 'object' ? fail.report : undefined
|
|
61
65
|
this.unknownOp = extra.unknownOp === true
|
|
62
66
|
}
|
|
63
67
|
}
|
|
@@ -138,8 +142,12 @@ export async function lwsClientOpsAvailable (engine) {
|
|
|
138
142
|
* @param {string} [options.subjectToken] the authentication credential to
|
|
139
143
|
* exchange for access tokens (RFC 8693 subject_token)
|
|
140
144
|
* @param {string} [options.subjectTokenType] its token type URI
|
|
141
|
-
* @param {object} [options.selfIssued] `{ subject, keyId, jwk }`: a P-256
|
|
142
|
-
* to sign self-issued credentials with, instead of a subject
|
|
145
|
+
* @param {object} [options.selfIssued] `{ subject, keyId, jwk }`: a P-256 or
|
|
146
|
+
* Ed25519 key to sign self-issued credentials with, instead of a subject
|
|
147
|
+
* token (`createDidKey` makes one)
|
|
148
|
+
* @param {string} [options.idToken] an OpenID Connect ID Token to exchange
|
|
149
|
+
* @param {object} [options.bearer] `{ token, realm? }`: an access token the
|
|
150
|
+
* caller already has; with a realm it is sent only under it
|
|
143
151
|
* @param {object} [options.session] a session from an earlier client
|
|
144
152
|
* @param {number} [options.maxSteps] the loop cap of one operation
|
|
145
153
|
* @returns {Promise<object>} the client
|
|
@@ -157,6 +165,9 @@ export async function createLwsClient (options = {}) {
|
|
|
157
165
|
// `{ subject, keyId, jwk }`: a P-256 key the engine signs self-issued
|
|
158
166
|
// credentials with (lws10-authn-ssi-cid), passed through unread.
|
|
159
167
|
if (options.selfIssued !== undefined) session.selfIssued = options.selfIssued
|
|
168
|
+
// Read by the engine's session reader (`Machine.sessionOfJson`), unread here.
|
|
169
|
+
if (options.idToken !== undefined) session.idToken = options.idToken
|
|
170
|
+
if (options.bearer !== undefined) session.bearer = options.bearer
|
|
160
171
|
|
|
161
172
|
function call (op, args) {
|
|
162
173
|
try {
|
|
@@ -230,7 +241,10 @@ export async function createLwsClient (options = {}) {
|
|
|
230
241
|
/** The session (access tokens and subject token), for a later client. */
|
|
231
242
|
session: () => ({ ...session }),
|
|
232
243
|
discover: with_('discover'),
|
|
244
|
+
describe: with_('describe'),
|
|
245
|
+
/** `read(url, { accept, rangeStart, rangeEnd, ifNoneMatch, prefer })` */
|
|
233
246
|
read: with_('read'),
|
|
247
|
+
readPage: with_('readPage'),
|
|
234
248
|
head: with_('head'),
|
|
235
249
|
/** `create(containerUrl, { body, mediaType, slug, container, types, links })` */
|
|
236
250
|
create: with_('create'),
|
|
@@ -239,26 +253,54 @@ export async function createLwsClient (options = {}) {
|
|
|
239
253
|
patch: with_('patch'),
|
|
240
254
|
/** `delete(url, { recursive, precondition, tag })` */
|
|
241
255
|
delete: with_('delete'),
|
|
256
|
+
findLinkset: with_('findLinkset'),
|
|
242
257
|
readLinkset: with_('readLinkset'),
|
|
258
|
+
/** `updateLinkset(resourceUrl, { body })`, or with `{ linksetUrl, body }`
|
|
259
|
+
* when the caller has the linkset resource. */
|
|
243
260
|
updateLinkset: with_('updateLinkset'),
|
|
261
|
+
putLinkset: with_('putLinkset'),
|
|
244
262
|
/** `move(url, { destination })` */
|
|
245
263
|
move: with_('move'),
|
|
246
264
|
list: with_('list'),
|
|
247
265
|
typeIndex: with_('typeIndex'),
|
|
248
|
-
/** `search(anyUrlInTheStorage, { query })`
|
|
266
|
+
/** `search(anyUrlInTheStorage, { query })`, or `{ endpoint, query }` with
|
|
267
|
+
* the service endpoint, `{ onePage: true }` for one page */
|
|
249
268
|
search: with_('search'),
|
|
269
|
+
queryFormats: with_('queryFormats'),
|
|
270
|
+
/** `subscribe(url, { topics, inbox, expires })` */
|
|
250
271
|
subscribe: with_('subscribe'),
|
|
272
|
+
getSubscription: with_('getSubscription'),
|
|
251
273
|
unsubscribe: with_('unsubscribe'),
|
|
252
274
|
requestAccess: with_('requestAccess'),
|
|
253
275
|
grantAccess: with_('grantAccess'),
|
|
254
276
|
getAccess: with_('getAccess'),
|
|
255
277
|
revokeAccess: with_('revokeAccess'),
|
|
256
|
-
/** `verifyNotification(baseUrl, { requestTarget, method, headers, body })`:
|
|
278
|
+
/** `verifyNotification(baseUrl, { requestTarget, method, headers, body, trustedStorages })`:
|
|
257
279
|
* the engine's verdict `{ accept, status, reason? }`. */
|
|
258
280
|
verifyNotification: with_('verifyNotification')
|
|
259
281
|
}
|
|
260
282
|
}
|
|
261
283
|
|
|
284
|
+
/**
|
|
285
|
+
* A new `did:key` agent: the engine makes the key pair and the identifier from
|
|
286
|
+
* 64 random bytes of this host's random source. The host reads none of it.
|
|
287
|
+
*
|
|
288
|
+
* @param {object} [options]
|
|
289
|
+
* @param {object} [options.engine] an engine handle to reuse
|
|
290
|
+
* @param {string} [options.algorithm] the engine's name of the algorithm
|
|
291
|
+
* (default `ES256`, a P-256 key; `EdDSA` makes an Ed25519 key)
|
|
292
|
+
* @returns {Promise<{agent: string, kid: string, selfIssued: object}>}
|
|
293
|
+
* `selfIssued` is the `createLwsClient` option of that name
|
|
294
|
+
*/
|
|
295
|
+
export async function createDidKey (options = {}) {
|
|
296
|
+
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
297
|
+
const algorithm = typeof options.algorithm === 'string' ? options.algorithm : 'ES256'
|
|
298
|
+
const random = new Uint8Array(64)
|
|
299
|
+
globalThis.crypto.getRandomValues(random)
|
|
300
|
+
const answer = engine.call('lwsClientNewKey', [algorithm, base64OfBytes(random)])
|
|
301
|
+
return answer.key
|
|
302
|
+
}
|
|
303
|
+
|
|
262
304
|
/**
|
|
263
305
|
* An HTTP receiver for webhook notifications. For each request it asks the
|
|
264
306
|
* engine (`verifyNotification`) and answers with the status of the
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@factoidal/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "RDF parsing and writing (Turtle, TriG, N-Triples, N-Quads, RDF/XML, JSON-LD), SPARQL 1.1 and 1.2, RDFS and OWL reasoning, SHACL and ShEx validation, CSVW and RML mapping, RDFC-1.0, and SPARQL, LWS and Solid servers, for Node, Deno and browsers.",
|
|
3
|
+
"version": "0.19.0",
|
|
4
|
+
"description": "EXPERIMENTAL UNREVIEWED CODE: do not run public services on it without careful independent expert review. RDF parsing and writing (Turtle, TriG, N-Triples, N-Quads, RDF/XML, JSON-LD), SPARQL 1.1 and 1.2, RDFS and OWL reasoning, SHACL and ShEx validation, CSVW and RML mapping, RDFC-1.0, and SPARQL, LWS and Solid servers, for Node, Deno and browsers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sparql",
|
|
7
7
|
"rdf",
|
|
@@ -88,6 +88,9 @@
|
|
|
88
88
|
"./lws": {
|
|
89
89
|
"default": "./lws/server.mjs"
|
|
90
90
|
},
|
|
91
|
+
"./webdav": {
|
|
92
|
+
"default": "./webdav/server.mjs"
|
|
93
|
+
},
|
|
91
94
|
"./lws/client": {
|
|
92
95
|
"default": "./lws/client.mjs"
|
|
93
96
|
},
|
|
@@ -123,6 +126,7 @@
|
|
|
123
126
|
"lib/",
|
|
124
127
|
"bin/",
|
|
125
128
|
"lws/",
|
|
129
|
+
"webdav/",
|
|
126
130
|
"authz/",
|
|
127
131
|
"solid/",
|
|
128
132
|
"store-host/",
|
package/rdfjs.mjs
CHANGED
|
@@ -52,11 +52,20 @@ class BlankNode {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// RDF/JS Data Model, interface Literal: "language the language as lowercase
|
|
56
|
+
// [BCP47] string (examples: "en", "en-gb")". A BCP 47 tag is ASCII (RFC 5646
|
|
57
|
+
// section 2.1), and the engine's readers lowercase its ASCII letters (Lean
|
|
58
|
+
// String.toLower), so a factory term equals the same term read from text
|
|
59
|
+
// (https://github.com/danbri/factoidal/issues/1054).
|
|
60
|
+
function lowerAscii(s) {
|
|
61
|
+
return s.replace(/[A-Z]+/g, (m) => m.toLowerCase());
|
|
62
|
+
}
|
|
63
|
+
|
|
55
64
|
class Literal {
|
|
56
65
|
constructor(value, language, datatype, direction) {
|
|
57
66
|
this.termType = 'Literal';
|
|
58
67
|
this.value = value;
|
|
59
|
-
this.language = language
|
|
68
|
+
this.language = language ? lowerAscii(String(language)) : '';
|
|
60
69
|
// RDF/JS Data Model DataFactory.literal: no datatype argument means
|
|
61
70
|
// xsd:string, a language means rdf:langString, a language and a
|
|
62
71
|
// direction mean rdf:dirLangString.
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// A Node HTTP host for the Factoidal WebDAV server.
|
|
2
|
+
// https://github.com/danbri/factoidal/issues/1066
|
|
3
|
+
// Design record: docs/designissues/2026-10-27-webdav.md
|
|
4
|
+
//
|
|
5
|
+
// WHAT THIS FILE IS ALLOWED TO DO
|
|
6
|
+
// Carry sockets, files and fetches for the engine, and nothing else:
|
|
7
|
+
//
|
|
8
|
+
// * at start, list CONFIGDIR and give the names to `webdavOpen`; read the
|
|
9
|
+
// files and test the paths the engine names (`need`) and give them to
|
|
10
|
+
// `webdavLoad` until it answers `ready`; write the files it names
|
|
11
|
+
// (`write`, a key file it made); open each local storage directory it
|
|
12
|
+
// names with the ops `lws-serve` uses (`openLwsStorage`, the config passed
|
|
13
|
+
// unread);
|
|
14
|
+
// * per request, give the method, target, header fields and content to
|
|
15
|
+
// `webdavStep`, then carry what the engine answers: an `lws` request to
|
|
16
|
+
// the local storage it names (no socket: the storage handle's own step),
|
|
17
|
+
// a `fetch` to the URL it names, until it answers a `response`, which
|
|
18
|
+
// this file writes back.
|
|
19
|
+
//
|
|
20
|
+
// It makes NO protocol decision. Which files are mounts, what a mount file
|
|
21
|
+
// says, every status code, header field, XML body, lock, property and path
|
|
22
|
+
// mapping is decided in formal/lean4/L4Factoidal/WebDAV/ (iron rule 7 of
|
|
23
|
+
// CLAUDE.md). The one status code written here is 500, for a failure of
|
|
24
|
+
// this host itself. Requests are served one at a time (a queue), because
|
|
25
|
+
// the engine keeps one request in flight per handle.
|
|
26
|
+
//
|
|
27
|
+
// THE WASM CONTRACT (formal/lean4/Wasm/Ops/WebDAV.lean)
|
|
28
|
+
// webdavOpen [configJson] -> { ok, handle, need }
|
|
29
|
+
// webdavLoad [handle, filesJson] -> { ok, need } | { ok, ready }
|
|
30
|
+
// webdavStep [handle, requestJson] + body -> { ok, response | lws | fetch } + body
|
|
31
|
+
// webdavResume [handle, answerJson] + body -> { ok, response | lws | fetch } + body
|
|
32
|
+
// webdavClose [handle] -> { ok }
|
|
33
|
+
|
|
34
|
+
import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs'
|
|
35
|
+
import { resolve as resolvePath } from 'node:path'
|
|
36
|
+
import { loadEngine } from '../bin/engine.mjs'
|
|
37
|
+
import { openLwsStorage } from '../lws/server.mjs'
|
|
38
|
+
|
|
39
|
+
/** The ops this host needs from the engine. */
|
|
40
|
+
export const WEBDAV_OPS = ['webdavOpen', 'webdavLoad', 'webdavStep', 'webdavResume', 'webdavClose']
|
|
41
|
+
|
|
42
|
+
/** The most engine answers one request may take before this host gives up:
|
|
43
|
+
* a bound against a defective module, not a protocol rule. */
|
|
44
|
+
const MAX_HOPS = 100000
|
|
45
|
+
|
|
46
|
+
/** The most rounds of `need` at start: the engine names two. */
|
|
47
|
+
const MAX_LOADS = 8
|
|
48
|
+
|
|
49
|
+
/** An error of this host, as opposed to one the engine reported. */
|
|
50
|
+
export class WebdavHostError extends Error {
|
|
51
|
+
constructor (message, detail = {}) {
|
|
52
|
+
super(message)
|
|
53
|
+
this.name = 'WebdavHostError'
|
|
54
|
+
this.unknownOp = detail.unknownOp === true
|
|
55
|
+
this.envelope = detail.envelope
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isUnknownOp (error) {
|
|
60
|
+
const text = error && error.message ? String(error.message) : String(error)
|
|
61
|
+
return text.indexOf('unknown op') >= 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Random bytes as base64. Pure I/O. */
|
|
65
|
+
function randomBase64 (size) {
|
|
66
|
+
const bytes = new Uint8Array(size)
|
|
67
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
68
|
+
return Buffer.from(bytes).toString('base64')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function nowSeconds () {
|
|
72
|
+
return Math.floor(Date.now() / 1000)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One engine call; an `ok: false` envelope is thrown with the engine's words. */
|
|
76
|
+
function callEngine (engine, op, args) {
|
|
77
|
+
let envelope
|
|
78
|
+
try {
|
|
79
|
+
envelope = engine.call(op, args)
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (isUnknownOp(error)) {
|
|
82
|
+
throw new WebdavHostError(`the loaded l4factoidal WebAssembly module does not carry ${op}`, { unknownOp: true })
|
|
83
|
+
}
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
86
|
+
return envelope
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function blobCall (engine, op, args, bytes) {
|
|
90
|
+
const got = engine.callBlobIO(op, args, bytes)
|
|
91
|
+
const envelope = got.envelope
|
|
92
|
+
if (envelope === null || typeof envelope !== 'object') throw new WebdavHostError(`${op} answered with no envelope`)
|
|
93
|
+
if (envelope.ok === false) throw new WebdavHostError(`${op}: ${envelope.error}`, { envelope })
|
|
94
|
+
return { envelope, bytes: got.bytes instanceof Uint8Array ? got.bytes : new Uint8Array(0) }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The files and paths the engine named, read and tested. Pure I/O. */
|
|
98
|
+
function gather (configDir, need) {
|
|
99
|
+
const files = (need.read || []).map((name) => {
|
|
100
|
+
try {
|
|
101
|
+
return { name, text: readFileSync(resolvePath(configDir, name), 'utf8') }
|
|
102
|
+
} catch (_error) {
|
|
103
|
+
return { name, missing: true }
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
const exists = (need.exists || []).map((path) => ({ path, present: existsSync(resolvePath(configDir, path)) }))
|
|
107
|
+
return { files, exists }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Open a WebDAV server handle over the mount files of `configDir`.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} options
|
|
114
|
+
* @param {string} options.configDir the directory of mount files
|
|
115
|
+
* @param {object} [options.engine] an engine handle to reuse
|
|
116
|
+
* @returns {Promise<{handle: string, mounts: object[], step: Function, close: Function}>}
|
|
117
|
+
*/
|
|
118
|
+
export async function openWebdav (options = {}) {
|
|
119
|
+
if (typeof options.configDir !== 'string') throw new WebdavHostError('openWebdav needs configDir')
|
|
120
|
+
const configDir = options.configDir
|
|
121
|
+
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
122
|
+
const listing = readdirSync(configDir)
|
|
123
|
+
let envelope = callEngine(engine, 'webdavOpen', [JSON.stringify({ listing, entropy: randomBase64(32) })])
|
|
124
|
+
if (envelope.ok === false) throw new WebdavHostError(envelope.error, { envelope })
|
|
125
|
+
const handle = envelope.handle
|
|
126
|
+
let loads = 0
|
|
127
|
+
while (envelope.need !== undefined) {
|
|
128
|
+
loads += 1
|
|
129
|
+
if (loads > MAX_LOADS) throw new WebdavHostError('webdavLoad asked for files more often than a start may')
|
|
130
|
+
envelope = callEngine(engine, 'webdavLoad', [handle, JSON.stringify(gather(configDir, envelope.need))])
|
|
131
|
+
if (envelope.ok === false) throw new WebdavHostError(envelope.error, { envelope })
|
|
132
|
+
}
|
|
133
|
+
const ready = envelope.ready
|
|
134
|
+
// A file the engine made (a new key): written once, never over a file.
|
|
135
|
+
for (const w of ready.write || []) {
|
|
136
|
+
writeFileSync(resolvePath(configDir, w.path), w.text, { mode: parseInt(w.mode, 8), flag: 'wx' })
|
|
137
|
+
}
|
|
138
|
+
// Each local storage, opened as lws-serve opens it; the config is the
|
|
139
|
+
// engine's, passed unread.
|
|
140
|
+
const storages = new Map()
|
|
141
|
+
for (const l of ready.local || []) {
|
|
142
|
+
const storage = await openLwsStorage({ engine, dir: resolvePath(configDir, l.dir), ...l.config, realClock: true })
|
|
143
|
+
storages.set(l.name, storage)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Carry one `fetch` the engine named. Pure I/O. */
|
|
147
|
+
async function exchange (request, bytes) {
|
|
148
|
+
const init = { method: request.method, headers: request.headers || [], redirect: 'manual' }
|
|
149
|
+
if (bytes.length > 0) init.body = bytes
|
|
150
|
+
let reply
|
|
151
|
+
try {
|
|
152
|
+
reply = await fetch(request.url, init)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return { answer: { error: error && error.message ? String(error.message) : String(error) }, bytes: new Uint8Array(0) }
|
|
155
|
+
}
|
|
156
|
+
const headers = []
|
|
157
|
+
reply.headers.forEach((value, name) => { headers.push([name, value]) })
|
|
158
|
+
const body = new Uint8Array(await reply.arrayBuffer())
|
|
159
|
+
return { answer: { status: reply.status, headers, now: nowSeconds(), randomBase64: randomBase64(48) }, bytes: body }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let queue = Promise.resolve()
|
|
163
|
+
|
|
164
|
+
/** One request, start to end. */
|
|
165
|
+
async function serve (request) {
|
|
166
|
+
const record = {
|
|
167
|
+
method: request.method,
|
|
168
|
+
target: request.target,
|
|
169
|
+
headers: request.headers,
|
|
170
|
+
now: nowSeconds(),
|
|
171
|
+
randomBase64: randomBase64(16)
|
|
172
|
+
}
|
|
173
|
+
let got = blobCall(engine, 'webdavStep', [handle, JSON.stringify(record)], request.body || new Uint8Array(0))
|
|
174
|
+
for (let hop = 0; hop < MAX_HOPS; hop += 1) {
|
|
175
|
+
const env = got.envelope
|
|
176
|
+
if (env.response !== undefined) {
|
|
177
|
+
return { status: env.response.status, headers: env.response.headers || [], body: got.bytes }
|
|
178
|
+
}
|
|
179
|
+
let answer
|
|
180
|
+
let bytes
|
|
181
|
+
if (env.lws !== undefined) {
|
|
182
|
+
const storage = storages.get(env.lws.mount)
|
|
183
|
+
if (storage === undefined) throw new WebdavHostError(`the engine named a storage that is not open: ${env.lws.mount}`)
|
|
184
|
+
const req = env.lws.request
|
|
185
|
+
const a = await storage.step({ method: req.method, target: req.target, headers: req.headers || [], body: got.bytes })
|
|
186
|
+
answer = { status: a.status, headers: a.headers }
|
|
187
|
+
bytes = a.body
|
|
188
|
+
} else if (env.fetch !== undefined) {
|
|
189
|
+
const x = await exchange(env.fetch, got.bytes)
|
|
190
|
+
answer = x.answer
|
|
191
|
+
bytes = x.bytes
|
|
192
|
+
} else {
|
|
193
|
+
throw new WebdavHostError('the engine answered neither response, lws nor fetch')
|
|
194
|
+
}
|
|
195
|
+
got = blobCall(engine, 'webdavResume', [handle, JSON.stringify(answer)], bytes)
|
|
196
|
+
}
|
|
197
|
+
throw new WebdavHostError('a request took more engine answers than this host allows')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
handle,
|
|
202
|
+
engine,
|
|
203
|
+
mounts: ready.mounts || [],
|
|
204
|
+
/**
|
|
205
|
+
* One request, served after the requests before it.
|
|
206
|
+
* @param {{method: string, target: string, headers: Array<[string, string]>, body: Uint8Array}} request
|
|
207
|
+
*/
|
|
208
|
+
step (request) {
|
|
209
|
+
const run = queue.then(() => serve(request))
|
|
210
|
+
queue = run.catch(() => {})
|
|
211
|
+
return run
|
|
212
|
+
},
|
|
213
|
+
close () {
|
|
214
|
+
for (const s of storages.values()) s.close()
|
|
215
|
+
try { engine.call('webdavClose', [handle]) } catch (_error) { /* already gone */ }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readBody (request) {
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
const chunks = []
|
|
223
|
+
request.on('data', (chunk) => chunks.push(chunk))
|
|
224
|
+
request.on('error', reject)
|
|
225
|
+
request.on('end', () => resolve(new Uint8Array(Buffer.concat(chunks))))
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function headerPairs (raw) {
|
|
230
|
+
const pairs = []
|
|
231
|
+
for (let index = 0; index + 1 < raw.length; index += 2) pairs.push([raw[index], raw[index + 1]])
|
|
232
|
+
return pairs
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function writeHeaders (response, pairs) {
|
|
236
|
+
for (const pair of pairs) {
|
|
237
|
+
if (!Array.isArray(pair) || pair.length < 2) continue
|
|
238
|
+
const existing = response.getHeader(pair[0])
|
|
239
|
+
if (existing === undefined) response.setHeader(pair[0], String(pair[1]))
|
|
240
|
+
else if (Array.isArray(existing)) response.setHeader(pair[0], existing.concat([String(pair[1])]))
|
|
241
|
+
else response.setHeader(pair[0], [String(existing), String(pair[1])])
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Start the server: open the handle, then listen.
|
|
247
|
+
*
|
|
248
|
+
* @param {object} options `configDir`, `port` (default 8090), `host`
|
|
249
|
+
* (default 127.0.0.1), `engine`, `onError`
|
|
250
|
+
* @returns {Promise<{server: object, port: number, origin: string, mounts: object[], close: Function}>}
|
|
251
|
+
*/
|
|
252
|
+
export async function listen (options = {}) {
|
|
253
|
+
const http = await import('node:http')
|
|
254
|
+
const dav = await openWebdav(options)
|
|
255
|
+
const onError = typeof options.onError === 'function'
|
|
256
|
+
? options.onError
|
|
257
|
+
: (error) => { console.error(`webdav-serve: ${error.message}`) }
|
|
258
|
+
const server = http.createServer(async (request, response) => {
|
|
259
|
+
let answer
|
|
260
|
+
try {
|
|
261
|
+
const body = await readBody(request)
|
|
262
|
+
answer = await dav.step({ method: request.method, target: request.url, headers: headerPairs(request.rawHeaders), body })
|
|
263
|
+
} catch (error) {
|
|
264
|
+
onError(error)
|
|
265
|
+
response.writeHead(500, { 'content-type': 'text/plain' })
|
|
266
|
+
response.end(`webdav-serve: ${error.message}\n`)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
writeHeaders(response, answer.headers)
|
|
270
|
+
response.statusCode = answer.status
|
|
271
|
+
response.end(answer.body)
|
|
272
|
+
})
|
|
273
|
+
const port = typeof options.port === 'number' ? options.port : 8090
|
|
274
|
+
const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
|
|
275
|
+
await new Promise((resolve, reject) => {
|
|
276
|
+
server.once('error', reject)
|
|
277
|
+
server.listen(port, host, resolve)
|
|
278
|
+
})
|
|
279
|
+
const bound = server.address()
|
|
280
|
+
const origin = `http://${host.includes(':') ? `[${host}]` : host}:${bound.port}`
|
|
281
|
+
return {
|
|
282
|
+
server,
|
|
283
|
+
port: bound.port,
|
|
284
|
+
origin,
|
|
285
|
+
mounts: dav.mounts,
|
|
286
|
+
close () {
|
|
287
|
+
return new Promise((resolve) => {
|
|
288
|
+
server.close(() => { dav.close(); resolve() })
|
|
289
|
+
if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|