@factoidal/core 0.29.0 → 0.31.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 +19 -1
- package/README.md +1 -1
- package/bin/factoidal.mjs +113 -23
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/version.mjs +1 -1
- package/lws/dataset.mjs +160 -0
- package/lws/inpage.mjs +135 -0
- package/lws/persist.mjs +1 -1
- package/lws/server.mjs +55 -9
- package/package.json +9 -2
- package/xmpp/README.md +9 -1
- package/xmpp/inpage.mjs +197 -0
- package/xmpp/router.mjs +96 -0
- package/xmpp/server.mjs +280 -58
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.31.0 (2026-11-05)
|
|
4
|
+
|
|
5
|
+
- Stack use on phones. The engine failed on iPhone Safari with "Maximum call stack size exceeded". Two faults are fixed:
|
|
6
|
+
- The ordered regex matcher (`cmatchE`) nested stack frames for each character of a star. `REPLACE`, `fn:replace` and other capture-aware operations overflowed on long text: in 0.30.0, `REPLACE` with a star failed at 40,000 characters under Node's default stack. A star over one character class is now a loop, with the same order of outcomes. A star over a group still recurses.
|
|
7
|
+
- Engine start decoded a test fixture (`Codec/Archive.lean`, `realTar`) with one stack frame for each byte. In WebKit, start needed about 1 MB of stack. The decoder is now a loop.
|
|
8
|
+
- `test/stack-use.test.mjs` gates both faults in the package suite. All of its tests fail against 0.30.0.
|
|
9
|
+
- SPARQL `SERVICE`: variable correlation, and scoped query provider grants (https://github.com/danbri/factoidal-core/pull/283).
|
|
10
|
+
- XPath: `parse-QName` function values capture static namespaces (https://github.com/danbri/factoidal-core/pull/278).
|
|
11
|
+
- VC-JOSE-COSE: draft plan and checklist (https://github.com/danbri/factoidal-core/pull/232).
|
|
12
|
+
- Proof that checked normalization is preserved into the actual RDFS closure (https://github.com/danbri/factoidal-core/pull/288).
|
|
13
|
+
|
|
14
|
+
## 0.30.0 (2026-11-04)
|
|
15
|
+
|
|
16
|
+
- The Lean XMPP server now has an in-page host (`@factoidal/core/xmpp/inpage`): `openInPageXmppServer` exposes ordered message connections and `inPageWebSocket` adapts them for RFC 7395 clients. Protocol decisions remain in Lean, with shared routing for direct messages, MUC rooms, MIX channels and experimental GC3 groups. This provides an app-to-app transport; it does not include a chat UI or WebMCP bridge.
|
|
17
|
+
- The Node XMPP host adds STARTTLS and WebSocket transport, IQ routing and sealed LWS-backed state for restart persistence. LWS writes enforce the configured content limit with a 413 response; component requests carry their principal through authorization.
|
|
18
|
+
|
|
19
|
+
- `openInPageLwsStorage({ engine, baseIri, principal, rootAcl?, storageKey?, snapshot?, onSnapshot? })` (`@factoidal/core/lws/inpage`): one Lean LWS storage with no network, in a page, a worker or Node, reached through a `fetch`-shaped function that answers real `Response`s. Every request is the component principal's (issue 1238) and Web Access Control decides it; notifications are off. The storage is kept as one snapshot, sealed when a `storageKey` is given: after each change it is handed to `onSnapshot`, and the caller passes it back as `snapshot` to reopen (the engine refuses one sealed under another key). No Node API. Test: `test/lws-inpage.test.mjs`.
|
|
20
|
+
- `openLwsDataset(storageRootUrl, { fetch, auth })` (`@factoidal/core/lws/dataset`): an LWS storage as an RDF dataset (https://github.com/danbri/factoidal/issues/1011, version 2 of the view). The default graph is empty. The first named graph is the table of contents, named by the storage description IRI (the target of the root's `https://www.w3.org/ns/lws#storage` link; the result member `toc`): the containment as `lws:items`, each listing member's `rdf:type` (`lws:Container` or `lws:DataResource`) with its `dcterms:format`, `schema:size` and `dcterms:modified`, and `<resource> <http://www.iana.org/assignments/relation/linkset> <linkset>` for each linkset read. Then one named graph per RDF resource (named by its IRI) and one per linkset (named by the linkset IRI, one triple per link; relation types become IRIs from the LWS vocabulary, else `http://www.iana.org/assignments/relation/<name>`). `query()` answers `{result, evidence}`; the evidence is each document read with its ETag and each linkset read with its linkset IRI and ETag. A full crawl on every open (one HEAD and one GET per resource for its linkset), no search index, no caching. The definition is Lean (`LWS/DatasetView.lean`, theorems `view_names_nodup`, `view_toc_first`, `view_replace`); the source theorem `lwsSource_query_sound` rests on the named assumption `lwsReadsAtSnapshot`. Needs a wasm build with version 2 of the ops `lwsViewPlan` (with `metadata`) and `lwsDatasetView`.
|
|
21
|
+
- The experimental code notice now says "EXPERIMENTAL UNDER-REVIEWED CODE" (was "UNREVIEWED"), in the README, the package description, every help text, the warning on standard error when a server, client or key command starts, and the hub pages. Owner decision of 2026-10-28, confirmed 2026-11-03.
|
|
4
22
|
|
|
5
23
|
## 0.29.0 (2026-11-02)
|
|
6
24
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @factoidal/core
|
|
2
2
|
|
|
3
|
-
> ⚠️ **EXPERIMENTAL
|
|
3
|
+
> ⚠️ **EXPERIMENTAL UNDER-REVIEWED CODE - USE WITH CARE - DO NOT CREATE PUBLIC SERVICES USING THIS WITHOUT CAREFUL INDEPENDENT EXPERT REVIEW.**
|
|
4
4
|
>
|
|
5
5
|
> This covers the whole core library and every command line tool, and in
|
|
6
6
|
> particular all protocol, authentication and security code (the LWS, Solid,
|
package/bin/factoidal.mjs
CHANGED
|
@@ -94,7 +94,7 @@ function err (line) { console.error(line) }
|
|
|
94
94
|
// Owner, 2026-10-27: every protocol, authentication and security component,
|
|
95
95
|
// the CLIs and the core library carry this notice. It is printed with every
|
|
96
96
|
// help text and on standard error when a server, client or key command starts.
|
|
97
|
-
const REVIEW_WARNING = 'EXPERIMENTAL
|
|
97
|
+
const REVIEW_WARNING = 'EXPERIMENTAL UNDER-REVIEWED CODE - USE WITH CARE - DO NOT CREATE PUBLIC SERVICES USING THIS WITHOUT CAREFUL INDEPENDENT EXPERT REVIEW.'
|
|
98
98
|
|
|
99
99
|
/** The commands that serve, speak a protocol, or handle keys and tokens. */
|
|
100
100
|
const WARNED_COMMANDS = new Set(['serve', 'lws-serve', 'solid-serve', 'solid-client', 'lws-client',
|
|
@@ -128,7 +128,7 @@ commands:
|
|
|
128
128
|
authz-serve DIR serve an OAuth 2.0 authorization server for LWS
|
|
129
129
|
(RFC 8414 metadata, JWK Set, RFC 8693 token exchange)
|
|
130
130
|
authz-keys DIR print the public JWK Set of an authorization server
|
|
131
|
-
xmpp-serve DIR serve XMPP (RFC 6120/6121)
|
|
131
|
+
xmpp-serve DIR serve XMPP (RFC 6120/6121): TCP, STARTTLS or direct TLS
|
|
132
132
|
webdav-serve CONFIGDIR serve local and remote LWS storages to file
|
|
133
133
|
browsers over WebDAV (RFC 4918)
|
|
134
134
|
webdav-user FILE NAME add or replace a user in a webdav-serve users
|
|
@@ -481,14 +481,8 @@ usage: factoidal lws-serve DIR (--authz DIR | --issuer IRI | --auth-config FILE
|
|
|
481
481
|
Starts a Node HTTP server that answers every request through the engine's
|
|
482
482
|
lwsStep operation.
|
|
483
483
|
|
|
484
|
-
DIR names the storage directory.
|
|
485
|
-
|
|
486
|
-
changed it (temporary file, fsync, rename), and read back on the next
|
|
487
|
-
start. The engine decides the snapshot format and refuses a snapshot
|
|
488
|
-
path with a dot segment or other traversal form
|
|
489
|
-
(formal/lean4/L4Factoidal/LWS/Snapshot.lean).
|
|
490
|
-
|
|
491
|
-
With --storage files (off by default) a write costs the same whatever the
|
|
484
|
+
DIR names the storage directory. By default (--storage files, since
|
|
485
|
+
2026-11-03) a write costs the same whatever the
|
|
492
486
|
size of the storage: DIR holds one file per resource body (DIR/bodies/),
|
|
493
487
|
a journal of metadata changes (DIR/lws-journal) and a metadata file
|
|
494
488
|
(DIR/lws-files.json) that the journal is folded into from time to time.
|
|
@@ -500,6 +494,12 @@ byte (formal/lean4/L4Factoidal/LWS/FileLayout.lean); the storage is still
|
|
|
500
494
|
held in memory while the server runs
|
|
501
495
|
(docs/designissues/2026-10-22-lws-storage-on-disk.md).
|
|
502
496
|
|
|
497
|
+
With --storage snapshot the storage is written whole to DIR/lws-state.json
|
|
498
|
+
after every request that changed it (temporary file, fsync, rename), and
|
|
499
|
+
read back on the next start. The engine decides the snapshot format and
|
|
500
|
+
refuses a snapshot path with a dot segment or other traversal form
|
|
501
|
+
(formal/lean4/L4Factoidal/LWS/Snapshot.lean).
|
|
502
|
+
|
|
503
503
|
Encryption at rest (off by default): when the environment variable
|
|
504
504
|
FACTOIDAL_LWS_KEY holds a 32-byte key (64 hex digits, or 43 characters of
|
|
505
505
|
base64url), the snapshot is sealed with ChaCha20-Poly1305 (HACL*) under a
|
|
@@ -577,9 +577,9 @@ The engine decides the file, its contents and every refusal
|
|
|
577
577
|
--storage snapshot|files
|
|
578
578
|
how DIR keeps the storage (config member storage;
|
|
579
579
|
default the environment variable FACTOIDAL_LWS_STORAGE,
|
|
580
|
-
else
|
|
581
|
-
|
|
582
|
-
|
|
580
|
+
else files): one file per body with a journal, so a
|
|
581
|
+
write costs the same whatever the storage size, or one
|
|
582
|
+
snapshot file written whole after each change
|
|
583
583
|
https://github.com/danbri/factoidal/issues/918
|
|
584
584
|
--cors-origin ORIGIN
|
|
585
585
|
answer CORS (Fetch Standard) only for this origin, written
|
|
@@ -744,10 +744,13 @@ those operations landed makes this command exit 3 and say so.
|
|
|
744
744
|
|
|
745
745
|
Specification: https://solidproject.org/TR/protocol`,
|
|
746
746
|
|
|
747
|
-
'xmpp-serve': `factoidal xmpp-serve - serve XMPP (RFC 6120 and RFC 6121) over TCP or direct TLS
|
|
747
|
+
'xmpp-serve': `factoidal xmpp-serve - serve XMPP (RFC 6120 and RFC 6121) over TCP, STARTTLS or direct TLS
|
|
748
748
|
|
|
749
749
|
usage: factoidal xmpp-serve DIR [--domain NAME] [--port N] [--host ADDRESS]
|
|
750
|
-
[--accounts FILE] [--cert FILE --key FILE]
|
|
750
|
+
[--accounts FILE] [--cert FILE --key FILE [--starttls]]
|
|
751
|
+
[--ws-port N [--ws-path PATH] [--ws-origin ORIGIN]...]
|
|
752
|
+
[--muc DOMAIN] [--mix DOMAIN] [--gc3 DOMAIN]
|
|
753
|
+
[--state-key FILE]
|
|
751
754
|
|
|
752
755
|
Starts a Node TCP server that runs one engine session per connection and
|
|
753
756
|
answers every byte through the engine's xmppSessionFeed operation.
|
|
@@ -761,11 +764,41 @@ DIR/spool/<bare jid>/ is delivered to that account's bound session.
|
|
|
761
764
|
--domain NAME the domain the server is authoritative for (default localhost);
|
|
762
765
|
a stream for any other domain is answered host-unknown
|
|
763
766
|
--port N the port to bind; 0 takes an ephemeral one
|
|
764
|
-
(default 5222
|
|
765
|
-
--host ADDRESS the address to bind (default 127.0.0.1)
|
|
767
|
+
(default 5222; 5223 with --cert and --key and no --starttls)
|
|
768
|
+
--host ADDRESS the address to bind (default 127.0.0.1); without
|
|
769
|
+
--cert and --key only a loopback address is accepted
|
|
766
770
|
--accounts FILE the accounts file (default DIR/accounts)
|
|
767
|
-
--cert FILE PEM certificate chain: XEP-0368 direct TLS,
|
|
771
|
+
--cert FILE PEM certificate chain: XEP-0368 direct TLS, or STARTTLS with --starttls
|
|
768
772
|
--key FILE PEM private key for --cert
|
|
773
|
+
--starttls listen in clear and REQUIRE STARTTLS (RFC 6120 section 5)
|
|
774
|
+
before anything else; no SASL mechanism is offered and no
|
|
775
|
+
credential is accepted until the TLS handshake finishes
|
|
776
|
+
--ws-port N also serve XMPP over WebSocket (RFC 7395, the "xmpp"
|
|
777
|
+
subprotocol of RFC 6455) for browser clients such as
|
|
778
|
+
Strophe.js; wss: with --cert and --key, else loopback only
|
|
779
|
+
--ws-path PATH the WebSocket endpoint path (default /xmpp-websocket)
|
|
780
|
+
--ws-origin ORIGIN a browser Origin allowed to connect, e.g.
|
|
781
|
+
http://localhost:5173 (repeatable); a request from any
|
|
782
|
+
other Origin is refused 403, and one with no Origin
|
|
783
|
+
(not a browser) is accepted only when no --ws-origin is given
|
|
784
|
+
--muc DOMAIN also serve XEP-0045 multi-user chat rooms at DOMAIN, e.g.
|
|
785
|
+
conference.example.com: temporary rooms, made by the first
|
|
786
|
+
join and gone with the last occupant; no history, no
|
|
787
|
+
configuration, no persistence (L4Factoidal/XMPP/Muc.lean)
|
|
788
|
+
--mix DOMAIN also serve XEP-0369 MIX channels at DOMAIN, e.g. mix.example.com,
|
|
789
|
+
joined through this server (XEP-0405 MIX-PAM); channels are
|
|
790
|
+
made by an IQ <create/>, kept in memory only, JID-hidden; no
|
|
791
|
+
PubSub nodes, no archive (L4Factoidal/XMPP/Mix.lean)
|
|
792
|
+
--state-key FILE keep the server's state across restarts: rosters, messages held
|
|
793
|
+
for offline accounts, MIX channels and GC3 groups, in an LWS
|
|
794
|
+
storage in DIR/state, sealed (ChaCha20-Poly1305) under the key
|
|
795
|
+
in FILE (64 hex digits; keep FILE outside DIR). A wrong key
|
|
796
|
+
stops the start. Plaintext roster files are migrated, then
|
|
797
|
+
deleted. Without it, that state is lost at a restart.
|
|
798
|
+
--gc3 DOMAIN also serve PROTOTYPE "Groupchat 3.0" groups at DOMAIN: persistent
|
|
799
|
+
membership, stable participant ids, optional events. There is
|
|
800
|
+
no published GC3 spec; every wire format is invented and listed
|
|
801
|
+
in L4Factoidal/XMPP/Gc3.lean and danbri/factoidal issue 1236
|
|
769
802
|
|
|
770
803
|
The server makes no protocol decision. Stream negotiation, SASL PLAIN and
|
|
771
804
|
SCRAM-SHA-256, resource binding, the roster, presence subscription and
|
|
@@ -774,7 +807,7 @@ disco#info are answered by formal/lean4/L4Factoidal/XMPP/. A WebAssembly
|
|
|
774
807
|
module built before the session operations landed makes this command
|
|
775
808
|
exit 3 and say so.
|
|
776
809
|
|
|
777
|
-
Not implemented:
|
|
810
|
+
Not implemented: SCRAM channel binding, server-to-server federation,
|
|
778
811
|
MUC/MIX/GC3 rooms.
|
|
779
812
|
|
|
780
813
|
Specifications: https://www.rfc-editor.org/rfc/rfc6120 and
|
|
@@ -1150,7 +1183,7 @@ function parseArguments (args, valueOptions) {
|
|
|
1150
1183
|
class UsageError extends Error {}
|
|
1151
1184
|
|
|
1152
1185
|
/** Options that may be given more than once; their value is the list. */
|
|
1153
|
-
const REPEATABLE_OPTIONS = new Set(['cors-origin', 'saml-idp', 'saml-decrypt-key', 'allow-host', 'allow-origin'])
|
|
1186
|
+
const REPEATABLE_OPTIONS = new Set(['cors-origin', 'saml-idp', 'saml-decrypt-key', 'allow-host', 'allow-origin', 'ws-origin'])
|
|
1154
1187
|
|
|
1155
1188
|
const VALUE_OPTIONS = {
|
|
1156
1189
|
xpath4: new Set(['expression', 'file', 'input', 'format']),
|
|
@@ -1178,7 +1211,7 @@ const VALUE_OPTIONS = {
|
|
|
1178
1211
|
'authz-serve': new Set(['port', 'host', 'issuer', 'resource', 'alg', 'token-lifetime', 'rotate-after',
|
|
1179
1212
|
'cors-origin', 'saml-idp', 'saml-decrypt-key']),
|
|
1180
1213
|
'authz-keys': new Set([]),
|
|
1181
|
-
'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key']),
|
|
1214
|
+
'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key', 'ws-port', 'ws-path', 'ws-origin', 'muc', 'mix', 'gc3', 'state-key']),
|
|
1182
1215
|
'webdav-serve': new Set(['port', 'host', 'users', 'guest', 'cert', 'key', 'login-limit', 'login-window', 'xmpp', 'xmpp-password-file',
|
|
1183
1216
|
'xmpp-host', 'xmpp-port', 'xmpp-allow', 'xmpp-resource', 'xmpp-ca', 'allow-host', 'allow-origin']),
|
|
1184
1217
|
'webdav-user': new Set(['iterations']),
|
|
@@ -2825,9 +2858,11 @@ async function commandXmppServe (positional, options) {
|
|
|
2825
2858
|
if (useTls && (typeof options.cert !== 'string' || typeof options.key !== 'string')) {
|
|
2826
2859
|
throw new UsageError('--cert and --key go together')
|
|
2827
2860
|
}
|
|
2861
|
+
const startTls = options.starttls === true
|
|
2862
|
+
if (startTls && !useTls) throw new UsageError('--starttls needs --cert and --key')
|
|
2828
2863
|
const settings = {
|
|
2829
2864
|
dir: positional[0],
|
|
2830
|
-
port: servePort(options, useTls ? 5223 : 5222),
|
|
2865
|
+
port: servePort(options, useTls && !startTls ? 5223 : 5222),
|
|
2831
2866
|
host: serveHost(options),
|
|
2832
2867
|
onError: (error) => { if (!quiet) err(`xmpp-serve: ${error.message}`) }
|
|
2833
2868
|
}
|
|
@@ -2837,6 +2872,51 @@ async function commandXmppServe (positional, options) {
|
|
|
2837
2872
|
settings.cert = await fs.readFile(options.cert)
|
|
2838
2873
|
settings.key = await fs.readFile(options.key)
|
|
2839
2874
|
}
|
|
2875
|
+
if (startTls) settings.startTls = true
|
|
2876
|
+
if (options['state-key'] !== undefined) {
|
|
2877
|
+
if (typeof options['state-key'] !== 'string') throw new UsageError('--state-key needs a FILE')
|
|
2878
|
+
// 64 hex digits or base64url of 32 bytes; the engine checks it.
|
|
2879
|
+
settings.stateKey = (await fs.readFile(options['state-key'], 'utf8')).trim()
|
|
2880
|
+
}
|
|
2881
|
+
if (options.muc !== undefined) {
|
|
2882
|
+
if (typeof options.muc !== 'string' || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(options.muc)) {
|
|
2883
|
+
throw new UsageError('--muc needs a domain name, e.g. conference.example.com')
|
|
2884
|
+
}
|
|
2885
|
+
settings.mucDomain = options.muc
|
|
2886
|
+
}
|
|
2887
|
+
if (options.mix !== undefined) {
|
|
2888
|
+
if (typeof options.mix !== 'string' || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(options.mix)) {
|
|
2889
|
+
throw new UsageError('--mix needs a domain name, e.g. mix.example.com')
|
|
2890
|
+
}
|
|
2891
|
+
if (options.mix === options.muc) throw new UsageError('--mix and --muc need different domains')
|
|
2892
|
+
settings.mixDomain = options.mix
|
|
2893
|
+
}
|
|
2894
|
+
if (options.gc3 !== undefined) {
|
|
2895
|
+
if (typeof options.gc3 !== 'string' || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(options.gc3)) {
|
|
2896
|
+
throw new UsageError('--gc3 needs a domain name, e.g. gc3.example.com')
|
|
2897
|
+
}
|
|
2898
|
+
if (options.gc3 === options.muc || options.gc3 === options.mix) throw new UsageError('--gc3 needs a domain of its own')
|
|
2899
|
+
settings.gc3Domain = options.gc3
|
|
2900
|
+
}
|
|
2901
|
+
if (options['ws-port'] !== undefined) {
|
|
2902
|
+
const wsPort = Number(options['ws-port'])
|
|
2903
|
+
if (!Number.isInteger(wsPort) || wsPort < 0 || wsPort > 65535) throw new UsageError('--ws-port needs a port number')
|
|
2904
|
+
const origins = options['ws-origin'] === undefined ? []
|
|
2905
|
+
: Array.isArray(options['ws-origin']) ? options['ws-origin'] : [options['ws-origin']]
|
|
2906
|
+
settings.websocket = {
|
|
2907
|
+
port: wsPort,
|
|
2908
|
+
path: typeof options['ws-path'] === 'string' ? options['ws-path'] : '/xmpp-websocket',
|
|
2909
|
+
allowedOrigins: origins,
|
|
2910
|
+
allowNoOrigin: origins.length === 0
|
|
2911
|
+
}
|
|
2912
|
+
if (useTls) { settings.websocket.cert = settings.cert; settings.websocket.key = settings.key }
|
|
2913
|
+
}
|
|
2914
|
+
// Invariant I4 (docs/designissues/2026-10-28-feature-combination-safety.md,
|
|
2915
|
+
// C4): with no TLS this listener offers PLAIN, which sends the password in
|
|
2916
|
+
// clear, so it binds to loopback only.
|
|
2917
|
+
if (!useTls && !/^(127\.\d+\.\d+\.\d+|::1|localhost)$/.test(settings.host)) {
|
|
2918
|
+
throw new UsageError(`xmpp-serve: no TLS on ${settings.host}: give --cert and --key (with --starttls for port 5222), or bind to loopback`)
|
|
2919
|
+
}
|
|
2840
2920
|
let running
|
|
2841
2921
|
try {
|
|
2842
2922
|
running = await listen(settings)
|
|
@@ -2849,9 +2929,19 @@ async function commandXmppServe (positional, options) {
|
|
|
2849
2929
|
if (!quiet) {
|
|
2850
2930
|
err(`xmpp-serve: listening on ${running.origin} for domain ${running.xmpp.domain}` +
|
|
2851
2931
|
` (${running.xmpp.accounts} accounts, state ${positional[0]}` +
|
|
2852
|
-
(useTls ? ', direct TLS)' : ', PLAINTEXT: no TLS on this listener)'))
|
|
2932
|
+
(startTls ? ', STARTTLS required)' : useTls ? ', direct TLS)' : ', PLAINTEXT: no TLS on this listener)'))
|
|
2933
|
+
if (settings.mucDomain) err(`xmpp-serve: multi-user chat (XEP-0045) at ${settings.mucDomain}`)
|
|
2934
|
+
if (settings.mixDomain) err(`xmpp-serve: MIX channels (XEP-0369, joined by XEP-0405) at ${settings.mixDomain}`)
|
|
2935
|
+
if (settings.gc3Domain) err(`xmpp-serve: GC3 PROTOTYPE groups at ${settings.gc3Domain} (no published spec; invented wire format)`)
|
|
2936
|
+
if (settings.stateKey) err(`xmpp-serve: state kept across restarts in ${positional[0]}/state, sealed under the state key`)
|
|
2937
|
+
else err('xmpp-serve: WARNING: no --state-key: held messages, MIX channels and GC3 groups are lost at a restart, and rosters are written in clear')
|
|
2938
|
+
}
|
|
2939
|
+
if (running.wsOrigin && !quiet) {
|
|
2940
|
+
err(`xmpp-serve: XMPP over WebSocket on ${running.wsOrigin}` +
|
|
2941
|
+
(settings.websocket.allowedOrigins.length ? ` for Origin ${settings.websocket.allowedOrigins.join(', ')}` : ' (no browser Origin allowed)'))
|
|
2853
2942
|
}
|
|
2854
2943
|
out(running.origin)
|
|
2944
|
+
if (running.wsOrigin) out(running.wsOrigin)
|
|
2855
2945
|
return untilInterrupted(running, quiet)
|
|
2856
2946
|
}
|
|
2857
2947
|
|
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-11-
|
|
5
|
+
"release": "danbri/factoidal-builds build-ace53d982f83-web",
|
|
6
|
+
"releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/42050112010",
|
|
7
|
+
"gitSha": "ace53d982f83e6bf0a1a749a5e800481cc092b9f",
|
|
8
|
+
"builtAt": "2026-11-05T16:35:31Z",
|
|
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": "14599aeba24a46bf05e1c7ce8aa885df536fe1398a797034eff107e04fa4ebc3",
|
|
15
|
+
"wasmBytes": 31371405,
|
|
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/version.mjs
CHANGED
package/lws/dataset.mjs
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// An LWS storage as an RDF dataset (https://github.com/danbri/factoidal/issues/1011,
|
|
2
|
+
// version 2): a table-of-contents graph named by the storage description IRI
|
|
3
|
+
// (the containment as lws:items, the listing members, the link from each
|
|
4
|
+
// resource to its linkset), one named graph per RDF document, one named graph
|
|
5
|
+
// per linkset, and the entity tag of every document and linkset read.
|
|
6
|
+
// Design record: docs/designissues/2026-10-25-lws-dataset-view.md
|
|
7
|
+
//
|
|
8
|
+
// WHAT THIS FILE IS ALLOWED TO DO
|
|
9
|
+
// Loop. Start at the storage root; ask the LWS client (lws/client.mjs) to
|
|
10
|
+
// list a container; give the engine the listing and the container IRI (op
|
|
11
|
+
// lwsViewPlan), which answers which members to list, which to read and whose
|
|
12
|
+
// linksets to read; read those with the client (read, readLinkset); give the
|
|
13
|
+
// engine every listing, read and linkset read (op lwsDatasetView), which
|
|
14
|
+
// answers the dataset as N-Quads with the evidence and the name of the
|
|
15
|
+
// table-of-contents graph; open that text as a Dataset. Every decision is
|
|
16
|
+
// Lean: which members are containers, which media types have an RDF reader
|
|
17
|
+
// and which resources have a linkset to read
|
|
18
|
+
// (L4Factoidal/LWS/DatasetViewRead.lean), the entity tag and the graph of
|
|
19
|
+
// each read, the links of each linkset, the storage description IRI, and the
|
|
20
|
+
// view itself (L4Factoidal/LWS/DatasetView.lean). This file reads no header,
|
|
21
|
+
// no link and chooses no media type.
|
|
22
|
+
//
|
|
23
|
+
// Limits (stated in API.md): a full crawl on every open; one HEAD and one GET
|
|
24
|
+
// per resource for its linkset; no search index; no caching; the snapshot is
|
|
25
|
+
// a sequence of GETs, not one read of the storage.
|
|
26
|
+
|
|
27
|
+
import { loadEngine } from '../bin/engine.mjs'
|
|
28
|
+
import { parse } from '../index.mjs'
|
|
29
|
+
import { createLwsClient, LwsClientError } from './client.mjs'
|
|
30
|
+
import { FactoidalError } from '../lib/errors.mjs'
|
|
31
|
+
|
|
32
|
+
/** The engine ops this host needs, beyond the client's. */
|
|
33
|
+
export const LWS_DATASET_OPS = ['lwsViewPlan', 'lwsDatasetView']
|
|
34
|
+
|
|
35
|
+
function isUnknownOp (error) {
|
|
36
|
+
const text = error && error.message ? String(error.message) : String(error)
|
|
37
|
+
return text.indexOf('unknown op') >= 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether the loaded engine carries the dataset-view ops.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} engine the handle from `loadEngine()`
|
|
44
|
+
* @returns {Promise<{available: boolean, reason: string}>}
|
|
45
|
+
*/
|
|
46
|
+
export async function lwsDatasetOpsAvailable (engine) {
|
|
47
|
+
try {
|
|
48
|
+
const plan = engine.call('lwsViewPlan', ['{}'])
|
|
49
|
+
if (!plan || !Array.isArray(plan.metadata)) {
|
|
50
|
+
return {
|
|
51
|
+
available: false,
|
|
52
|
+
reason: 'the loaded l4factoidal WebAssembly module carries version 1 of lwsViewPlan ' +
|
|
53
|
+
'(no metadata member; rebuild formal/lean4/Wasm)'
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { available: true, reason: 'lwsViewPlan answered' }
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (isUnknownOp(error)) {
|
|
59
|
+
return {
|
|
60
|
+
available: false,
|
|
61
|
+
reason: 'the loaded l4factoidal WebAssembly module does not carry lwsViewPlan ' +
|
|
62
|
+
'(rebuild formal/lean4/Wasm)'
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { available: true, reason: 'lwsViewPlan answered with an engine error' }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Crawl an LWS storage and open it as a Dataset.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} storageRootUrl the URL of the storage root container
|
|
73
|
+
* @param {object} [options]
|
|
74
|
+
* @param {Function} [options.fetch] the fetch to use (default `globalThis.fetch`)
|
|
75
|
+
* @param {object} [options.auth] passed to `createLwsClient`:
|
|
76
|
+
* `{ subjectToken, subjectTokenType }`, `{ selfIssued }` or `{ session }`
|
|
77
|
+
* @param {object} [options.engine] an engine handle to reuse
|
|
78
|
+
* @param {number} [options.maxContainers] stop with an error after listing
|
|
79
|
+
* this many containers (default 10000)
|
|
80
|
+
* @returns {Promise<object>} `{ dataset, toc, evidence, skipped, graphs,
|
|
81
|
+
* query(sparql, options), dispose() }`
|
|
82
|
+
*/
|
|
83
|
+
export async function openLwsDataset (storageRootUrl, options = {}) {
|
|
84
|
+
if (typeof storageRootUrl !== 'string') {
|
|
85
|
+
throw new TypeError('openLwsDataset: storageRootUrl must be a string')
|
|
86
|
+
}
|
|
87
|
+
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
88
|
+
const probe = await lwsDatasetOpsAvailable(engine)
|
|
89
|
+
if (!probe.available) {
|
|
90
|
+
throw new FactoidalError(`openLwsDataset: ${probe.reason}`, { code: 'op.unknown' })
|
|
91
|
+
}
|
|
92
|
+
const auth = options.auth !== undefined && options.auth !== null ? options.auth : {}
|
|
93
|
+
const client = await createLwsClient({ ...auth, engine, fetch: options.fetch })
|
|
94
|
+
const maxContainers = typeof options.maxContainers === 'number' ? options.maxContainers : 10000
|
|
95
|
+
|
|
96
|
+
const queue = [storageRootUrl]
|
|
97
|
+
const listed = new Set()
|
|
98
|
+
const listings = []
|
|
99
|
+
const reads = []
|
|
100
|
+
const linksets = []
|
|
101
|
+
const skipped = []
|
|
102
|
+
while (queue.length > 0) {
|
|
103
|
+
const container = queue.shift()
|
|
104
|
+
if (listed.has(container)) continue
|
|
105
|
+
if (listed.size >= maxContainers) {
|
|
106
|
+
throw new FactoidalError(`openLwsDataset: more than ${maxContainers} containers`,
|
|
107
|
+
{ code: 'lws.dataset.limit' })
|
|
108
|
+
}
|
|
109
|
+
listed.add(container)
|
|
110
|
+
const done = await client.list(container)
|
|
111
|
+
listings.push({ container, done })
|
|
112
|
+
const plan = engine.call('lwsViewPlan', [JSON.stringify(done), container])
|
|
113
|
+
for (const c of plan.containers) queue.push(c)
|
|
114
|
+
for (const s of plan.skipped) skipped.push(s)
|
|
115
|
+
for (const resource of plan.resources) {
|
|
116
|
+
try {
|
|
117
|
+
reads.push({ resource, done: await client.read(resource) })
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (!(error instanceof LwsClientError)) throw error
|
|
120
|
+
skipped.push({ resource, reason: `read: ${error.reason}` })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const resource of plan.metadata) {
|
|
124
|
+
try {
|
|
125
|
+
linksets.push({ resource, done: await client.readLinkset(resource) })
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (!(error instanceof LwsClientError)) throw error
|
|
128
|
+
skipped.push({ resource, reason: `linkset: ${error.reason}` })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const view = engine.call('lwsDatasetView', [JSON.stringify({ listings, reads, linksets })])
|
|
134
|
+
for (const s of view.skipped) skipped.push(s)
|
|
135
|
+
const dataset = await parse(view.nquads, { format: 'nquads' })
|
|
136
|
+
const evidence = view.evidence.map((e) => ({ ...e }))
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
dataset,
|
|
140
|
+
/** The name of the table-of-contents graph: the storage description IRI. */
|
|
141
|
+
toc: view.toc,
|
|
142
|
+
/** Each RDF document read, `{ resource, etag, lastModified }`, then each
|
|
143
|
+
* linkset read, `{ resource, linkset, etag }`. */
|
|
144
|
+
evidence,
|
|
145
|
+
/** Each member or read left out of the dataset: `{ resource, reason }`. */
|
|
146
|
+
skipped,
|
|
147
|
+
/** The number of named graphs (the table of contents, the documents and
|
|
148
|
+
* the linksets). */
|
|
149
|
+
graphs: view.graphs,
|
|
150
|
+
/**
|
|
151
|
+
* Run a SPARQL query over the dataset.
|
|
152
|
+
* @returns {Promise<{result: *, evidence: Array<object>}>} `result` as
|
|
153
|
+
* `Dataset.query` answers; `evidence` the documents and linksets read
|
|
154
|
+
*/
|
|
155
|
+
async query (sparql, queryOptions) {
|
|
156
|
+
return { result: await dataset.query(sparql, queryOptions), evidence: evidence.slice() }
|
|
157
|
+
},
|
|
158
|
+
dispose () { dataset.dispose() }
|
|
159
|
+
}
|
|
160
|
+
}
|
package/lws/inpage.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// factoidal/lws/inpage.mjs — one Lean LWS storage with no network: it runs
|
|
2
|
+
// in the page (or a worker, or Node) beside the code that uses it, and is
|
|
3
|
+
// reached through a `fetch`-shaped function.
|
|
4
|
+
//
|
|
5
|
+
// import { loadEngine } from '@factoidal/core/browser'
|
|
6
|
+
// import { openInPageLwsStorage } from '@factoidal/core/lws/inpage'
|
|
7
|
+
// const pod = await openInPageLwsStorage({ engine: await loadEngine(),
|
|
8
|
+
// baseIri: 'https://pod.foafos.local/', principal: 'urn:foafos:shell',
|
|
9
|
+
// rootAcl, storageKey, snapshot: saved,
|
|
10
|
+
// onSnapshot: (s) => save(s) })
|
|
11
|
+
// const r = await pod.fetch('https://pod.foafos.local/notes/a.txt', { method: 'PUT', body: 'hi' })
|
|
12
|
+
//
|
|
13
|
+
// The storage has a component principal (issue 1238): every request is that
|
|
14
|
+
// agent's, no token is read, and Web Access Control still decides it
|
|
15
|
+
// (L4Factoidal/LWS/Authorization.lean). Whoever holds the object can use it,
|
|
16
|
+
// so the host page decides which code gets it, as with xmpp/inpage.mjs.
|
|
17
|
+
//
|
|
18
|
+
// It is kept as one snapshot (`lwsSnapshot`), which the engine seals when a
|
|
19
|
+
// `storageKey` is given. After each step that changed the storage this file
|
|
20
|
+
// asks the engine for the snapshot and gives it to `onSnapshot`; the caller
|
|
21
|
+
// keeps it (IndexedDB, a file) and passes it back as `snapshot` to reopen.
|
|
22
|
+
// The engine checks that snapshot and refuses one sealed under another key.
|
|
23
|
+
//
|
|
24
|
+
// The engine makes every protocol decision (status, headers, bodies). This
|
|
25
|
+
// file turns a fetch call into the request record of the wasm ABI
|
|
26
|
+
// (docs/lws-solid-conformance.md, "wasm ABI") and the response record into a
|
|
27
|
+
// Response. Notifications are off: there is no network to deliver them on.
|
|
28
|
+
// No Node API; `crypto.getRandomValues`, `Request` and `Response` only.
|
|
29
|
+
|
|
30
|
+
/** Random bytes for the engine, base64 (see lws/server.mjs, entropyBase64). */
|
|
31
|
+
function entropyBase64 (size = 32) {
|
|
32
|
+
const bytes = new Uint8Array(size)
|
|
33
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
34
|
+
let text = ''
|
|
35
|
+
for (const b of bytes) text += String.fromCharCode(b)
|
|
36
|
+
return btoa(text)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function handleOf (envelope) {
|
|
40
|
+
if (envelope === null || typeof envelope !== 'object') return null
|
|
41
|
+
for (const name of ['handle', 'id']) {
|
|
42
|
+
const value = envelope[name]
|
|
43
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
44
|
+
if (typeof value === 'number') return String(value)
|
|
45
|
+
}
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {object} options
|
|
51
|
+
* @param {object} options.engine a loaded engine handle (`loadEngine()`), with callBlobIO
|
|
52
|
+
* @param {string} options.baseIri the absolute IRI the storage root maps to; fetch
|
|
53
|
+
* reaches only IRIs under it
|
|
54
|
+
* @param {string} options.principal the component principal (an IRI); every request is its
|
|
55
|
+
* @param {string} [options.rootAcl] the root ACL (Turtle) of a new storage
|
|
56
|
+
* @param {string} [options.owner] the WebID the storage advertises
|
|
57
|
+
* @param {string} [options.storageKey] key text (64 hex digits or base64url); seals the snapshot
|
|
58
|
+
* @param {object} [options.snapshot] a snapshot to continue from (from onSnapshot)
|
|
59
|
+
* @param {function} [options.onSnapshot] (snapshot) => void, after each change
|
|
60
|
+
* @param {number} [options.maxBody] the largest content of one write (the engine's default otherwise)
|
|
61
|
+
* @returns {Promise<{fetch: Function, snapshot: Function, close: Function, baseIri: string}>}
|
|
62
|
+
*/
|
|
63
|
+
export async function openInPageLwsStorage (options = {}) {
|
|
64
|
+
const { engine } = options
|
|
65
|
+
if (!engine || typeof engine.callBlobIO !== 'function') throw new TypeError('openInPageLwsStorage: an engine with callBlobIO is required')
|
|
66
|
+
if (typeof options.baseIri !== 'string') throw new TypeError('openInPageLwsStorage: baseIri is required')
|
|
67
|
+
if (typeof options.principal !== 'string') throw new TypeError('openInPageLwsStorage: principal is required')
|
|
68
|
+
const base = new URL(options.baseIri)
|
|
69
|
+
const config = { baseIri: base.href, principal: options.principal, notifications: false, entropy: entropyBase64() }
|
|
70
|
+
for (const name of ['rootAcl', 'owner', 'storageKey']) {
|
|
71
|
+
if (typeof options[name] === 'string') config[name] = options[name]
|
|
72
|
+
}
|
|
73
|
+
if (typeof options.maxBody === 'number') config.maxBody = options.maxBody
|
|
74
|
+
if (options.snapshot !== undefined && options.snapshot !== null) config.snapshot = options.snapshot
|
|
75
|
+
const opened = engine.call('lwsOpen', [JSON.stringify(config)])
|
|
76
|
+
const handle = handleOf(opened)
|
|
77
|
+
if (handle === null) throw new Error('lwsOpen answered with no handle')
|
|
78
|
+
const onSnapshot = typeof options.onSnapshot === 'function' ? options.onSnapshot : null
|
|
79
|
+
let closed = false
|
|
80
|
+
|
|
81
|
+
const snapshot = () => {
|
|
82
|
+
const answer = engine.call('lwsSnapshot', [handle, JSON.stringify({ nonce: entropyBase64(12) })])
|
|
83
|
+
if (answer === null || typeof answer !== 'object' || answer.snapshot === undefined) {
|
|
84
|
+
throw new Error('lwsSnapshot answered with no snapshot')
|
|
85
|
+
}
|
|
86
|
+
return answer.snapshot
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A fetch to this storage: the same arguments as `fetch`, a real Response back. */
|
|
90
|
+
async function podFetch (input, init) {
|
|
91
|
+
if (closed) throw new TypeError('this storage is closed')
|
|
92
|
+
const request = new Request(input, init)
|
|
93
|
+
const url = new URL(request.url)
|
|
94
|
+
if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) {
|
|
95
|
+
throw new TypeError('the IRI is not in this storage')
|
|
96
|
+
}
|
|
97
|
+
const target = '/' + url.pathname.slice(base.pathname.length) + url.search
|
|
98
|
+
const content = request.method === 'GET' || request.method === 'HEAD'
|
|
99
|
+
? new Uint8Array(0)
|
|
100
|
+
: new Uint8Array(await request.arrayBuffer())
|
|
101
|
+
const record = {
|
|
102
|
+
method: request.method,
|
|
103
|
+
target,
|
|
104
|
+
headers: [...request.headers],
|
|
105
|
+
now: Math.floor(Date.now() / 1000),
|
|
106
|
+
random: entropyBase64(16)
|
|
107
|
+
}
|
|
108
|
+
const got = engine.callBlobIO('lwsStep', [handle, JSON.stringify(record)], content)
|
|
109
|
+
const envelope = got.envelope
|
|
110
|
+
if (envelope === null || typeof envelope !== 'object') throw new Error('lwsStep answered with no envelope')
|
|
111
|
+
// A storage with a component principal and no files has nothing to
|
|
112
|
+
// fetch, resolve or read; an engine that asks is not one this host runs.
|
|
113
|
+
for (const ask of ['fetch', 'resolve', 'read']) {
|
|
114
|
+
if (envelope[ask] !== undefined && envelope[ask] !== null) throw new Error(`lwsStep asked this host to ${ask}, which an in-page storage cannot`)
|
|
115
|
+
}
|
|
116
|
+
const answer = (envelope.response !== null && typeof envelope.response === 'object') ? envelope.response : envelope
|
|
117
|
+
if (typeof answer.status !== 'number') throw new Error('lwsStep answered with no status in its response record')
|
|
118
|
+
if (envelope.changed === true && onSnapshot !== null) onSnapshot(snapshot())
|
|
119
|
+
const headers = new Headers()
|
|
120
|
+
for (const [name, value] of Array.isArray(answer.headers) ? answer.headers : []) headers.append(name, value)
|
|
121
|
+
// No content is a null body: a Response refuses an empty one with 204 or 304.
|
|
122
|
+
const body = got.bytes instanceof Uint8Array && got.bytes.length > 0 ? got.bytes : null
|
|
123
|
+
return new Response(body, { status: answer.status, headers })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
baseIri: base.href,
|
|
128
|
+
fetch: podFetch,
|
|
129
|
+
snapshot,
|
|
130
|
+
close () {
|
|
131
|
+
closed = true
|
|
132
|
+
try { engine.call('lwsClose', [handle]) } catch (_error) { /* already gone */ }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
package/lws/persist.mjs
CHANGED
|
@@ -64,7 +64,7 @@ export const STORAGE_MODE_VARIABLE = 'FACTOIDAL_LWS_STORAGE'
|
|
|
64
64
|
/**
|
|
65
65
|
* The text of the storage mode: `explicit` when the caller gave a string,
|
|
66
66
|
* and otherwise the environment variable, passed unread; the engine checks
|
|
67
|
-
* it. An empty value is none (the
|
|
67
|
+
* it. An empty value is none (the host's default with a DIR, "files").
|
|
68
68
|
*/
|
|
69
69
|
export function storageModeText (explicit) {
|
|
70
70
|
if (typeof explicit === 'string') return explicit
|