@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 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 `&#xD;` (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). SAML
365
- 2.0 is not accepted (https://github.com/danbri/factoidal/issues/926).
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 and --allow-private-fetch as there). The
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, manifestHex: hexOfBytes(opened.manifest), activated: true }
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
- function regionOf (store, keys) {
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 { blob, artifacts } = regionOf(store, keys)
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 = engine.callBlobIO('storeOpen',
381
- [store.manifestHex, JSON.stringify(artifacts)], blob).envelope
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
 
@@ -37,7 +37,7 @@
37
37
  // bytes change.
38
38
 
39
39
  // Stamped by formal/lean4/Wasm/build-wasm.sh step 9 -- do not hand-edit.
40
- const WASM_VERSION = "ebac38a482d0";
40
+ const WASM_VERSION = "6dbf38660cb9";
41
41
 
42
42
  import createModule from './l4factoidal.mjs';
43
43
 
Binary file
@@ -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-fa72ba5bc828-web",
6
- "releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/40439252690",
7
- "gitSha": "fa72ba5bc828c1eb75572c3436e9d739f76fdd57",
8
- "builtAt": "2026-10-27T03:13:16Z",
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": "ebac38a482d03d4a6ce1cf4a59164288628fed6957483dd55180743e7a3daf40",
15
- "wasmBytes": 17071472,
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, manifestHex, fromNQuads, withExtensionRounds) {
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.storeManifestInspect(this._manifestHex), 'Store.inspect');
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, format?: string, baseIRI?: string}} [options]
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
- const manifestHex = hexOfBytes(gen.manifest);
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(e.storeQueryPlan(manifestHex, opts.sparql), 'openStore(plan)');
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(e.storeManifestInspect(manifestHex), 'openStore(inspect)'));
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(e.storeOpen(manifestHex, JSON.stringify(artifacts), blob), 'openStore');
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, manifestHex, (nq) => c.fromNQuads(nq, {}), withExtensionRounds);
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;
@@ -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
@@ -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.17.0';
5
+ export const version = '0.19.0';
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', 'readLinkset',
47
- 'updateLinkset', 'move', 'list', 'typeIndex', 'search', 'subscribe', 'unsubscribe',
48
- 'requestAccess', 'grantAccess', 'getAccess', 'revokeAccess', 'verifyNotification'
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 key
142
- * to sign self-issued credentials with, instead of a subject token
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.17.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
+ }