@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 CHANGED
@@ -1,6 +1,24 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
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 UNREVIEWED CODE - USE WITH CARE - DO NOT CREATE PUBLIC SERVICES USING THIS WITHOUT CAREFUL INDEPENDENT EXPERT REVIEW.**
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 UNREVIEWED CODE - USE WITH CARE - DO NOT CREATE PUBLIC SERVICES USING THIS WITHOUT CAREFUL INDEPENDENT EXPERT REVIEW.'
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) over TCP or direct TLS
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. The storage is kept in the engine
485
- handle and written to DIR/lws-state.json after every request that
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 snapshot): one snapshot file written whole after
581
- each change, or one file per body with a journal, so a
582
- write costs the same whatever the storage size
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, or 5223 with --cert and --key)
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, no STARTTLS
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: STARTTLS (use --cert/--key), server-to-server federation,
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
 
@@ -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 = "a020bab67e41";
40
+ const WASM_VERSION = "14599aeba24a";
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-72de36349ac0-web",
6
- "releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/41472062630",
7
- "gitSha": "72de36349ac003a402074cee9aa329828977f118",
8
- "builtAt": "2026-11-02T22:15:10Z",
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": "a020bab67e41d62992a520f4180a170cce0c4d419d687cd5eb227d2d14bba0b6",
15
- "wasmBytes": 31003213,
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
@@ -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.29.0';
5
+ export const version = '0.31.0';
@@ -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 engine's default, the snapshot).
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