@factoidal/core 0.17.0 → 0.18.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 +10 -0
- package/bin/factoidal.mjs +75 -2
- package/bin/store.mjs +67 -11
- package/index.d.ts +9 -0
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/api.mjs +26 -11
- package/lib/dataset.mjs +2 -1
- package/lib/lean-entry.mjs +40 -1
- package/lib/version.mjs +1 -1
- package/lws/client.mjs +50 -8
- package/package.json +5 -1
- package/webdav/server.mjs +293 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.18.0 (2026-10-27)
|
|
6
|
+
|
|
7
|
+
- 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).
|
|
8
|
+
- 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).
|
|
9
|
+
- 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).
|
|
10
|
+
- 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).
|
|
11
|
+
- 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`.
|
|
12
|
+
- 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`.
|
|
13
|
+
- 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).
|
|
14
|
+
|
|
5
15
|
## 0.17.0 (2026-10-27)
|
|
6
16
|
|
|
7
17
|
- **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/bin/factoidal.mjs
CHANGED
|
@@ -113,6 +113,8 @@ commands:
|
|
|
113
113
|
(RFC 8414 metadata, JWK Set, RFC 8693 token exchange)
|
|
114
114
|
authz-keys DIR print the public JWK Set of an authorization server
|
|
115
115
|
xmpp-serve DIR serve XMPP (RFC 6120/6121) over TCP or direct TLS
|
|
116
|
+
webdav-serve CONFIGDIR serve local and remote LWS storages to file
|
|
117
|
+
browsers over WebDAV (RFC 4918)
|
|
116
118
|
|
|
117
119
|
global options:
|
|
118
120
|
-h, --help print this text, or a command's own help
|
|
@@ -644,6 +646,47 @@ MUC/MIX/GC3 rooms.
|
|
|
644
646
|
Specifications: https://www.rfc-editor.org/rfc/rfc6120 and
|
|
645
647
|
https://www.rfc-editor.org/rfc/rfc6121`,
|
|
646
648
|
|
|
649
|
+
'webdav-serve': `factoidal webdav-serve - serve LWS storages to file browsers over WebDAV
|
|
650
|
+
|
|
651
|
+
usage: factoidal webdav-serve CONFIGDIR [--port N] [--host ADDRESS]
|
|
652
|
+
|
|
653
|
+
Starts a WebDAV server (RFC 4918, classes 1 and 2) that macOS Finder,
|
|
654
|
+
Windows Explorer, GNOME Files and KDE Dolphin can connect to. Its root
|
|
655
|
+
collection holds one folder per mount; a mount is one Markdown file
|
|
656
|
+
CONFIGDIR/NAME.md (README.md is not a mount):
|
|
657
|
+
|
|
658
|
+
# Display name
|
|
659
|
+
- kind: local | lws
|
|
660
|
+
- path: ./alice-storage (local: an lws-serve storage directory)
|
|
661
|
+
- url: https://storage.example/alice/ (lws: the storage root)
|
|
662
|
+
- auth: none | did-key | bearer
|
|
663
|
+
- key-file: ./alice.jwk (did-key: a P-256 private JWK; made when absent)
|
|
664
|
+
- token-file: ./alice.token (bearer: an access token)
|
|
665
|
+
- writable: true | false (default true for local, false for lws)
|
|
666
|
+
- storage: snapshot | files (local: default what the directory holds)
|
|
667
|
+
- base: IRI (local: the storage root's IRI, default http://localhost/)
|
|
668
|
+
|
|
669
|
+
Only the first "# " heading and the first "- key: value" list are read; the
|
|
670
|
+
words in brackets above are notes, not part of a value. A malformed file
|
|
671
|
+
stops the start with its name and line number.
|
|
672
|
+
|
|
673
|
+
A local mount opens the directory with the operations lws-serve uses, as
|
|
674
|
+
the storage's owner (do not serve the same directory with lws-serve at the
|
|
675
|
+
same time). An lws mount reaches the storage through the LWS client.
|
|
676
|
+
|
|
677
|
+
--port N the port to bind (default 8090; 0 takes an ephemeral one)
|
|
678
|
+
--host ADDRESS the address to bind (default 127.0.0.1)
|
|
679
|
+
|
|
680
|
+
Connect: macOS Finder, Go > Connect to Server, http://127.0.0.1:8090/ ;
|
|
681
|
+
Windows, Map network drive, http://127.0.0.1:8090/ ; GNOME Files,
|
|
682
|
+
dav://127.0.0.1:8090/ .
|
|
683
|
+
|
|
684
|
+
The server makes no protocol decision: every method, status, property, lock
|
|
685
|
+
and mapping is decided by formal/lean4/L4Factoidal/WebDAV/. A WebAssembly
|
|
686
|
+
module built before the WebDAV operations landed makes this command exit 3
|
|
687
|
+
and say so. Design: docs/designissues/2026-10-27-webdav.md
|
|
688
|
+
(https://github.com/danbri/factoidal/issues/1066).`,
|
|
689
|
+
|
|
647
690
|
'solid-client': `factoidal solid-client - run one Solid client operation
|
|
648
691
|
|
|
649
692
|
usage: factoidal solid-client <get|put|post|delete|discover> URL [options]
|
|
@@ -788,7 +831,8 @@ const VALUE_OPTIONS = {
|
|
|
788
831
|
'authz-serve': new Set(['port', 'host', 'issuer', 'resource', 'alg', 'token-lifetime', 'rotate-after',
|
|
789
832
|
'cors-origin']),
|
|
790
833
|
'authz-keys': new Set([]),
|
|
791
|
-
'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key'])
|
|
834
|
+
'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key']),
|
|
835
|
+
'webdav-serve': new Set(['port', 'host'])
|
|
792
836
|
}
|
|
793
837
|
|
|
794
838
|
// ------------------------------------------------------------- commands
|
|
@@ -2027,6 +2071,34 @@ async function commandSolidServe (positional, options) {
|
|
|
2027
2071
|
return untilInterrupted(running, quiet)
|
|
2028
2072
|
}
|
|
2029
2073
|
|
|
2074
|
+
// webdav-serve CONFIGDIR (https://github.com/danbri/factoidal/issues/1066):
|
|
2075
|
+
// the host carries sockets, files and fetches; the engine reads the mount
|
|
2076
|
+
// files and decides every answer (L4Factoidal/WebDAV/).
|
|
2077
|
+
async function commandWebdavServe (positional, options) {
|
|
2078
|
+
if (positional.length !== 1) throw new UsageError('webdav-serve needs one CONFIGDIR')
|
|
2079
|
+
const quiet = options.quiet === true
|
|
2080
|
+
const { listen, WebdavHostError } = await import('../webdav/server.mjs')
|
|
2081
|
+
let running
|
|
2082
|
+
try {
|
|
2083
|
+
running = await listen({ configDir: positional[0], port: servePort(options, 8090), host: serveHost(options) })
|
|
2084
|
+
} catch (error) {
|
|
2085
|
+
if (error instanceof WebdavHostError && error.unknownOp) return notWired('webdav-serve', error.message)
|
|
2086
|
+
if (error instanceof WebdavHostError || (error !== null && typeof error === 'object' && error.code === 'ENOENT')) {
|
|
2087
|
+
err(`factoidal webdav-serve: ${error.message}`)
|
|
2088
|
+
return EXIT_FAILURE
|
|
2089
|
+
}
|
|
2090
|
+
throw error
|
|
2091
|
+
}
|
|
2092
|
+
if (!quiet) {
|
|
2093
|
+
err(`webdav-serve: listening on ${running.origin} for ${positional[0]}`)
|
|
2094
|
+
for (const m of running.mounts) {
|
|
2095
|
+
err(`webdav-serve: ${running.origin}/${encodeURIComponent(m.name)}/ - ${m.title} (${m.kind}${m.writable ? '' : ', read-only'}${m.agent ? ', as ' + m.agent : ''})`)
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
out(running.origin)
|
|
2099
|
+
return untilInterrupted(running, quiet)
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2030
2102
|
async function commandXmppServe (positional, options) {
|
|
2031
2103
|
if (positional.length !== 1) throw new UsageError('xmpp-serve needs one DIR')
|
|
2032
2104
|
const quiet = options.quiet === true
|
|
@@ -2207,7 +2279,8 @@ const COMMANDS = {
|
|
|
2207
2279
|
'lws-client': commandLwsClient,
|
|
2208
2280
|
'authz-serve': commandAuthzServe,
|
|
2209
2281
|
'authz-keys': commandAuthzKeys,
|
|
2210
|
-
'xmpp-serve': commandXmppServe
|
|
2282
|
+
'xmpp-serve': commandXmppServe,
|
|
2283
|
+
'webdav-serve': commandWebdavServe
|
|
2211
2284
|
}
|
|
2212
2285
|
|
|
2213
2286
|
// ----------------------------------------------------------------- main
|
package/bin/store.mjs
CHANGED
|
@@ -123,7 +123,7 @@ export const STACK_REMEDY = {
|
|
|
123
123
|
export function openStore (root, generationName = null) {
|
|
124
124
|
if (typeof generationName !== 'string') {
|
|
125
125
|
const opened = openCollection(root)
|
|
126
|
-
return { ...opened,
|
|
126
|
+
return withManifestHex({ ...opened, activated: true })
|
|
127
127
|
}
|
|
128
128
|
const generationDir = joinPath(root, generationName)
|
|
129
129
|
for (const manifestName of MANIFEST_NAMES) {
|
|
@@ -133,23 +133,67 @@ export function openStore (root, generationName = null) {
|
|
|
133
133
|
} catch (_error) {
|
|
134
134
|
continue
|
|
135
135
|
}
|
|
136
|
-
return {
|
|
136
|
+
return withManifestHex({
|
|
137
137
|
root,
|
|
138
138
|
generation: generationName,
|
|
139
139
|
generationDir,
|
|
140
140
|
manifestName,
|
|
141
141
|
manifest,
|
|
142
|
-
manifestHex: hexOfBytes(manifest),
|
|
143
142
|
activated: false
|
|
144
|
-
}
|
|
143
|
+
})
|
|
145
144
|
}
|
|
146
145
|
throw new StoreOperationError(
|
|
147
146
|
`${generationDir} has none of ${MANIFEST_NAMES.join(', ')}`)
|
|
148
147
|
}
|
|
149
148
|
|
|
149
|
+
/**
|
|
150
|
+
* `store.manifestHex`, the hexadecimal the older manifest ops take, computed
|
|
151
|
+
* on first read. The byte ops (`hasManifestBytesOps`) do not read it, so a
|
|
152
|
+
* store opened for them never builds the 1.5 million digits of a large
|
|
153
|
+
* manifest (https://github.com/danbri/factoidal/issues/1039).
|
|
154
|
+
*/
|
|
155
|
+
function withManifestHex (store) {
|
|
156
|
+
let hex = null
|
|
157
|
+
return Object.defineProperty(store, 'manifestHex', {
|
|
158
|
+
enumerable: true,
|
|
159
|
+
get () {
|
|
160
|
+
if (hex === null) hex = hexOfBytes(store.manifest)
|
|
161
|
+
return hex
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The byte forms of the manifest ops (formal/lean4/Wasm/Ops/StoreHandles.lean). */
|
|
167
|
+
const MANIFEST_BYTES_OPS = ['storeManifestInspectBytes', 'storeQueryPlanBytes', 'storeOpenBytes']
|
|
168
|
+
const manifestBytesSupport = new WeakMap()
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Whether the engine serves the byte forms of the manifest ops. They take
|
|
172
|
+
* the manifest as the byte region, not as hexadecimal inside the JSON
|
|
173
|
+
* arguments, and the engine decodes it once for a plan and the open that
|
|
174
|
+
* follows. An engine built before them has only the hexadecimal ops, and
|
|
175
|
+
* this host then uses those, as before.
|
|
176
|
+
*/
|
|
177
|
+
function hasManifestBytesOps (engine) {
|
|
178
|
+
let known = manifestBytesSupport.get(engine)
|
|
179
|
+
if (known === undefined) {
|
|
180
|
+
known = false
|
|
181
|
+
if (typeof engine.callBlobIO === 'function') {
|
|
182
|
+
const reflection = engine.call('ops', [])
|
|
183
|
+
const blobIoOps = Array.isArray(reflection && reflection.blobIoOps) ? reflection.blobIoOps : []
|
|
184
|
+
known = MANIFEST_BYTES_OPS.every((op) => blobIoOps.includes(op))
|
|
185
|
+
}
|
|
186
|
+
manifestBytesSupport.set(engine, known)
|
|
187
|
+
}
|
|
188
|
+
return known
|
|
189
|
+
}
|
|
190
|
+
|
|
150
191
|
/** `storeManifestInspect` — decode one manifest. */
|
|
151
192
|
export function inspectManifest (engine, store) {
|
|
152
193
|
try {
|
|
194
|
+
if (hasManifestBytesOps(engine)) {
|
|
195
|
+
return engine.callBlobIO('storeManifestInspectBytes', [], store.manifest).envelope
|
|
196
|
+
}
|
|
153
197
|
return engine.call('storeManifestInspect', [store.manifestHex])
|
|
154
198
|
} catch (error) {
|
|
155
199
|
throw asStoreError(error)
|
|
@@ -159,6 +203,9 @@ export function inspectManifest (engine, store) {
|
|
|
159
203
|
/** `storeQueryPlan` — the artifact keys this query needs, and the mode. */
|
|
160
204
|
export function planQuery (engine, store, sparql) {
|
|
161
205
|
try {
|
|
206
|
+
if (hasManifestBytesOps(engine)) {
|
|
207
|
+
return engine.callBlobIO('storeQueryPlanBytes', [sparql], store.manifest).envelope
|
|
208
|
+
}
|
|
162
209
|
return engine.call('storeQueryPlan', [store.manifestHex, sparql])
|
|
163
210
|
} catch (error) {
|
|
164
211
|
throw asStoreError(error)
|
|
@@ -194,16 +241,20 @@ function capDecision (engine, store, sparql) {
|
|
|
194
241
|
}
|
|
195
242
|
}
|
|
196
243
|
|
|
197
|
-
/** Read the named artifacts and concatenate them into one region.
|
|
198
|
-
|
|
244
|
+
/** Read the named artifacts and concatenate them into one region. With a
|
|
245
|
+
* `prefix` (the manifest, for `storeOpenBytes`) the region starts with it,
|
|
246
|
+
* and the windows still count from the first artifact byte. */
|
|
247
|
+
function regionOf (store, keys, prefix = null) {
|
|
199
248
|
const chunks = keys.map((key) => readWhole(joinPath(store.generationDir, key)))
|
|
249
|
+
const start = prefix ? prefix.length : 0
|
|
200
250
|
let total = 0
|
|
201
251
|
for (const chunk of chunks) total += chunk.length
|
|
202
|
-
const blob = new Uint8Array(total)
|
|
252
|
+
const blob = new Uint8Array(start + total)
|
|
253
|
+
if (prefix) blob.set(prefix, 0)
|
|
203
254
|
const artifacts = []
|
|
204
255
|
let offset = 0
|
|
205
256
|
for (let index = 0; index < chunks.length; index += 1) {
|
|
206
|
-
blob.set(chunks[index], offset)
|
|
257
|
+
blob.set(chunks[index], start + offset)
|
|
207
258
|
artifacts.push({ key: keys[index], offset, len: chunks[index].length })
|
|
208
259
|
offset += chunks[index].length
|
|
209
260
|
}
|
|
@@ -374,11 +425,16 @@ export function openStoreHandle (engine, store, options = {}) {
|
|
|
374
425
|
} else {
|
|
375
426
|
keys = manifestKeys(engine, store)
|
|
376
427
|
}
|
|
377
|
-
const
|
|
428
|
+
const bytesOps = hasManifestBytesOps(engine)
|
|
429
|
+
// The byte op takes the manifest as the first bytes of the region.
|
|
430
|
+
const { blob, artifacts } = regionOf(store, keys, bytesOps ? store.manifest : null)
|
|
378
431
|
let envelope
|
|
379
432
|
try {
|
|
380
|
-
envelope =
|
|
381
|
-
|
|
433
|
+
envelope = bytesOps
|
|
434
|
+
? engine.callBlobIO('storeOpenBytes',
|
|
435
|
+
[String(store.manifest.length), JSON.stringify(artifacts)], blob).envelope
|
|
436
|
+
: engine.callBlobIO('storeOpen',
|
|
437
|
+
[store.manifestHex, JSON.stringify(artifacts)], blob).envelope
|
|
382
438
|
} catch (error) {
|
|
383
439
|
throw asStoreError(error)
|
|
384
440
|
}
|
package/index.d.ts
CHANGED
|
@@ -212,6 +212,9 @@ export interface WriteOptions {
|
|
|
212
212
|
useRdfType?: boolean;
|
|
213
213
|
/** 'jsonld' only. */
|
|
214
214
|
rdfDirection?: string;
|
|
215
|
+
/** 'jsonld' only: write RDF 1.2 triple terms as JSON-LD-star (`@triple`,
|
|
216
|
+
* `@reifies`, `@annotation`). Without it a triple term is refused. */
|
|
217
|
+
rdfstar?: boolean;
|
|
215
218
|
}
|
|
216
219
|
|
|
217
220
|
export type WriteFormat =
|
|
@@ -1011,6 +1014,9 @@ export interface JsonLdOptions {
|
|
|
1011
1014
|
rdfDirection?: string;
|
|
1012
1015
|
expandContext?: string;
|
|
1013
1016
|
processingMode?: string;
|
|
1017
|
+
/** JSON-LD-star: read `@triple`, `@reifies`, `@annotation` as RDF 1.2
|
|
1018
|
+
* triple terms and reifiers. */
|
|
1019
|
+
rdfstar?: boolean;
|
|
1014
1020
|
}
|
|
1015
1021
|
|
|
1016
1022
|
/**
|
|
@@ -1027,6 +1033,9 @@ export function jsonldToRdf(
|
|
|
1027
1033
|
export interface JsonLdFromRdfOptions {
|
|
1028
1034
|
useNativeTypes?: boolean;
|
|
1029
1035
|
useRdfType?: boolean;
|
|
1036
|
+
/** JSON-LD-star: write RDF 1.2 triple terms (`@triple`, `@reifies`,
|
|
1037
|
+
* `@annotation`). Without it a triple term is refused. */
|
|
1038
|
+
rdfstar?: boolean;
|
|
1030
1039
|
format?: DataFormat;
|
|
1031
1040
|
}
|
|
1032
1041
|
|
package/l4-assets/l4factoidal.js
CHANGED
|
Binary file
|
package/l4-assets/version.json
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
"engine": "lean4",
|
|
3
3
|
"note": "The Lean 4 (L4Factoidal) engine build provenance and claims: the only engine of @factoidal/core since 0.9.0 (issue 713). This committed file is a placeholder. tools/fetch-builds.sh --set web (through tools/stamp-l4-version.mjs) writes the provenance members from the danbri/factoidal-builds release that built the wasm; formal/lean4/Wasm/build-wasm.sh step 9 writes them for a local build. Do not commit a stamped copy.",
|
|
4
4
|
"stamped": true,
|
|
5
|
-
"release": "danbri/factoidal-builds build-
|
|
6
|
-
"releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/
|
|
7
|
-
"gitSha": "
|
|
8
|
-
"builtAt": "2026-10-
|
|
5
|
+
"release": "danbri/factoidal-builds build-788ee43aec79-web",
|
|
6
|
+
"releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/40457275423",
|
|
7
|
+
"gitSha": "788ee43aec79c4d29592c5e82cc7ab350f0d05dd",
|
|
8
|
+
"builtAt": "2026-10-27T06:36:59Z",
|
|
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": "925f357453dbb71d087bef70fd428b054999756d7c967a806403d3d8ee875a57",
|
|
15
|
+
"wasmBytes": 17565361,
|
|
16
16
|
"claims": {
|
|
17
17
|
"source": "formal/lean4 (L4Factoidal): no sorry, no user axioms, no native_decide; W3C behaviour pinned by build-time #guard",
|
|
18
18
|
"suitesAtBuildSha": "see docs/test-results and formal/lean4/PORT_NOTES.md at gitSha"
|
package/lib/api.mjs
CHANGED
|
@@ -182,11 +182,10 @@ function utf16Diagnostics(text, diagnostics) {
|
|
|
182
182
|
// is never one of a partial generation.
|
|
183
183
|
// ---------------------------------------------------------------------
|
|
184
184
|
class Store {
|
|
185
|
-
constructor(e, env, gen,
|
|
185
|
+
constructor(e, env, gen, fromNQuads, withExtensionRounds) {
|
|
186
186
|
this._e = e;
|
|
187
187
|
this._env = env;
|
|
188
188
|
this._gen = gen;
|
|
189
|
-
this._manifestHex = manifestHex;
|
|
190
189
|
this._closed = false;
|
|
191
190
|
this._fromNQuads = fromNQuads;
|
|
192
191
|
this._withExtensionRounds = withExtensionRounds;
|
|
@@ -236,7 +235,9 @@ class Store {
|
|
|
236
235
|
|
|
237
236
|
/** The manifest, decoded by the engine (op storeManifestInspect). */
|
|
238
237
|
async inspect() {
|
|
239
|
-
const r = entryResult(this._e.
|
|
238
|
+
const r = entryResult(typeof this._e.storeManifestInspectBytes === 'function'
|
|
239
|
+
? this._e.storeManifestInspectBytes(this._gen.manifest)
|
|
240
|
+
: this._e.storeManifestInspect(hexOfBytes(this._gen.manifest)), 'Store.inspect');
|
|
240
241
|
const { ok: _ok, ...rest } = r;
|
|
241
242
|
return rest;
|
|
242
243
|
}
|
|
@@ -841,7 +842,7 @@ function buildApi(driver) {
|
|
|
841
842
|
* @param {Dataset|string|Array} data
|
|
842
843
|
* @param {{format?: string, version?: '1.1'|'1.2', prefixes?: object,
|
|
843
844
|
* literalShorthand?: boolean, useNativeTypes?: boolean,
|
|
844
|
-
* useRdfType?: boolean, rdfDirection?: string,
|
|
845
|
+
* useRdfType?: boolean, rdfDirection?: string, rdfstar?: boolean,
|
|
845
846
|
* inputFormat?: string, baseIRI?: string}} [options]
|
|
846
847
|
* format: nquads (default), ntriples, turtle, trig, rdfxml, jsonld,
|
|
847
848
|
* rdfc. inputFormat and baseIRI read text `data`.
|
|
@@ -1210,7 +1211,9 @@ function buildApi(driver) {
|
|
|
1210
1211
|
* A remote `@context` is not fetched.
|
|
1211
1212
|
* @param {string} jsonldText
|
|
1212
1213
|
* @param {{base?: string, rdfDirection?: string, expandContext?: string,
|
|
1213
|
-
* processingMode?: string}} [options]
|
|
1214
|
+
* processingMode?: string, rdfstar?: boolean}} [options]
|
|
1215
|
+
* rdfstar: JSON-LD-star (`@triple`, `@reifies`, `@annotation` as RDF 1.2
|
|
1216
|
+
* triple terms and reifiers).
|
|
1214
1217
|
* @returns {Promise<Dataset>}
|
|
1215
1218
|
*/
|
|
1216
1219
|
async function jsonldToRdf(jsonldText, options) {
|
|
@@ -1223,6 +1226,7 @@ function buildApi(driver) {
|
|
|
1223
1226
|
...(opts.rdfDirection ? { rdfDirection: opts.rdfDirection } : {}),
|
|
1224
1227
|
...(opts.expandContext ? { expandContext: opts.expandContext } : {}),
|
|
1225
1228
|
...(opts.processingMode ? { processingMode: opts.processingMode } : {}),
|
|
1229
|
+
...(opts.rdfstar ? { rdfstar: true } : {}),
|
|
1226
1230
|
});
|
|
1227
1231
|
return c.wrap(entryResult(c.e.datasetFromJsonLd(jsonldText, optionsJson), 'jsonldToRdf'), {},
|
|
1228
1232
|
{ format: 'jsonld', formatInferred: false });
|
|
@@ -1250,7 +1254,8 @@ function buildApi(driver) {
|
|
|
1250
1254
|
* The text form is `serialize(data, {format: 'jsonld'})`.
|
|
1251
1255
|
* @param {Dataset|string|Array} data
|
|
1252
1256
|
* @param {{useNativeTypes?: boolean, useRdfType?: boolean,
|
|
1253
|
-
* rdfDirection?: string,
|
|
1257
|
+
* rdfDirection?: string, rdfstar?: boolean, format?: string,
|
|
1258
|
+
* baseIRI?: string}} [options]
|
|
1254
1259
|
* @returns {Promise<any>}
|
|
1255
1260
|
*/
|
|
1256
1261
|
async function jsonldFromRdf(data, options) {
|
|
@@ -1907,20 +1912,30 @@ function buildApi(driver) {
|
|
|
1907
1912
|
const e = await entry();
|
|
1908
1913
|
requireEntryFn(e, 'storeOpen', 'openStore (storeOpen)');
|
|
1909
1914
|
const gen = await openGeneration(reader, opts.generation);
|
|
1910
|
-
|
|
1915
|
+
// The byte ops when the engine has them (issue 1039): the manifest
|
|
1916
|
+
// crosses as bytes and is decoded once for the plan and the open. An
|
|
1917
|
+
// older engine gets the hexadecimal ops, as before.
|
|
1918
|
+
const bytesOps = typeof e.storeOpenBytes === 'function';
|
|
1919
|
+
const manifestHex = bytesOps ? null : hexOfBytes(gen.manifest);
|
|
1911
1920
|
let keys;
|
|
1912
1921
|
if (Array.isArray(opts.keys)) {
|
|
1913
1922
|
keys = opts.keys.map(String);
|
|
1914
1923
|
} else if (typeof opts.sparql === 'string') {
|
|
1915
|
-
const plan = entryResult(
|
|
1924
|
+
const plan = entryResult(bytesOps
|
|
1925
|
+
? e.storeQueryPlanBytes(gen.manifest, opts.sparql)
|
|
1926
|
+
: e.storeQueryPlan(manifestHex, opts.sparql), 'openStore(plan)');
|
|
1916
1927
|
keys = plan.keys.concat(plan.sidecarKeys || [], plan.blobKeys || []);
|
|
1917
1928
|
} else {
|
|
1918
|
-
keys = manifestKeys(entryResult(
|
|
1929
|
+
keys = manifestKeys(entryResult(bytesOps
|
|
1930
|
+
? e.storeManifestInspectBytes(gen.manifest)
|
|
1931
|
+
: e.storeManifestInspect(manifestHex), 'openStore(inspect)'));
|
|
1919
1932
|
}
|
|
1920
1933
|
const { blob, artifacts } = await readRegion(reader, gen.generation, keys);
|
|
1921
|
-
const env = entryResult(
|
|
1934
|
+
const env = entryResult(bytesOps
|
|
1935
|
+
? e.storeOpenBytes(gen.manifest, JSON.stringify(artifacts), blob)
|
|
1936
|
+
: e.storeOpen(manifestHex, JSON.stringify(artifacts), blob), 'openStore');
|
|
1922
1937
|
const c = await ctx();
|
|
1923
|
-
return new Store(e, env, gen,
|
|
1938
|
+
return new Store(e, env, gen, (nq) => c.fromNQuads(nq, {}), withExtensionRounds);
|
|
1924
1939
|
}
|
|
1925
1940
|
|
|
1926
1941
|
/**
|
package/lib/dataset.mjs
CHANGED
|
@@ -279,7 +279,7 @@ class Dataset {
|
|
|
279
279
|
* The dataset as a document.
|
|
280
280
|
* @param {{format?: string, version?: '1.1'|'1.2', prefixes?: object,
|
|
281
281
|
* literalShorthand?: boolean, useNativeTypes?: boolean,
|
|
282
|
-
* useRdfType?: boolean, rdfDirection?: string}} [options]
|
|
282
|
+
* useRdfType?: boolean, rdfDirection?: string, rdfstar?: boolean}} [options]
|
|
283
283
|
* format: nquads (default), ntriples, turtle, trig, rdfxml, jsonld,
|
|
284
284
|
* rdfc (RDFC-1.0 canonical N-Quads).
|
|
285
285
|
* @returns {Promise<string>}
|
|
@@ -315,6 +315,7 @@ class Dataset {
|
|
|
315
315
|
...(opts.useNativeTypes ? { useNativeTypes: true } : {}),
|
|
316
316
|
...(opts.useRdfType ? { useRdfType: true } : {}),
|
|
317
317
|
...(opts.rdfDirection ? { rdfDirection: String(opts.rdfDirection) } : {}),
|
|
318
|
+
...(opts.rdfstar ? { rdfstar: true } : {}),
|
|
318
319
|
...(suffix ? { version: String(opts.version) } : {}),
|
|
319
320
|
})), 'serialize');
|
|
320
321
|
return r.jsonld;
|
package/lib/lean-entry.mjs
CHANGED
|
@@ -125,6 +125,9 @@ function installExtensionBridge(eng, entry) {
|
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/** The byte forms of the manifest ops (Wasm/Ops/StoreHandles.lean). */
|
|
129
|
+
const MANIFEST_BYTES_OPS = ['storeManifestInspectBytes', 'storeQueryPlanBytes', 'storeOpenBytes'];
|
|
130
|
+
|
|
128
131
|
/**
|
|
129
132
|
* Build the entry object lib/api.mjs drives, from a loaded engine handle.
|
|
130
133
|
*
|
|
@@ -199,9 +202,45 @@ export function entryFromEngine(eng) {
|
|
|
199
202
|
}
|
|
200
203
|
};
|
|
201
204
|
}
|
|
205
|
+
const blobIoOps = Array.isArray(reflection.blobIoOps) ? reflection.blobIoOps : [];
|
|
206
|
+
// The byte forms of the manifest ops (https://github.com/danbri/factoidal/issues/1039):
|
|
207
|
+
// the manifest crosses as the byte region, not as hexadecimal inside the
|
|
208
|
+
// JSON arguments, and the engine decodes it once for a plan and the open
|
|
209
|
+
// that follows. An engine without them has only the hexadecimal ops; the
|
|
210
|
+
// methods are then absent and lib/api.mjs uses the hexadecimal ops.
|
|
211
|
+
if (typeof eng.callBlobIO === 'function' &&
|
|
212
|
+
MANIFEST_BYTES_OPS.every((op) => blobIoOps.includes(op))) {
|
|
213
|
+
entry.storeManifestInspectBytes = (manifestBytes) => {
|
|
214
|
+
try {
|
|
215
|
+
return JSON.stringify(eng.callBlobIO('storeManifestInspectBytes', [], manifestBytes).envelope);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
return failEnvelope(err);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
entry.storeQueryPlanBytes = (manifestBytes, sparql) => {
|
|
221
|
+
try {
|
|
222
|
+
return JSON.stringify(
|
|
223
|
+
eng.callBlobIO('storeQueryPlanBytes', [String(sparql)], manifestBytes).envelope);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return failEnvelope(err);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
entry.storeOpenBytes = (manifestBytes, artifactsJson, bytes) => {
|
|
229
|
+
try {
|
|
230
|
+
const region = new Uint8Array(manifestBytes.length + bytes.length);
|
|
231
|
+
region.set(manifestBytes, 0);
|
|
232
|
+
region.set(bytes, manifestBytes.length);
|
|
233
|
+
return JSON.stringify(eng.callBlobIO('storeOpenBytes',
|
|
234
|
+
[String(manifestBytes.length), String(artifactsJson)], region).envelope);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return failEnvelope(err);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
} else {
|
|
240
|
+
for (const op of MANIFEST_BYTES_OPS) delete entry[op];
|
|
241
|
+
}
|
|
202
242
|
// datasetQuadsBin answers a byte region (formal/lean4/L4Factoidal/RDF/QuadsBin.lean):
|
|
203
243
|
// this method answers {envelope, bytes} and throws on a refusal.
|
|
204
|
-
const blobIoOps = Array.isArray(reflection.blobIoOps) ? reflection.blobIoOps : [];
|
|
205
244
|
if (typeof eng.callBlobIO === 'function' && blobIoOps.includes('datasetQuadsBin')) {
|
|
206
245
|
entry.datasetQuadsBin = (handle, optsJson) => {
|
|
207
246
|
try {
|
package/lib/version.mjs
CHANGED
package/lws/client.mjs
CHANGED
|
@@ -38,14 +38,15 @@ import { loadEngine } from '../bin/engine.mjs'
|
|
|
38
38
|
import { FactoidalError } from '../lib/errors.mjs'
|
|
39
39
|
|
|
40
40
|
/** The ops this host needs from the engine. */
|
|
41
|
-
export const LWS_CLIENT_OPS = ['lwsClientStart', 'lwsClientStep']
|
|
41
|
+
export const LWS_CLIENT_OPS = ['lwsClientStart', 'lwsClientStep', 'lwsClientNewKey']
|
|
42
42
|
|
|
43
43
|
/** The operation kinds of the ABI. Listed so a caller can be checked
|
|
44
44
|
* against the contract; this host attaches no meaning to any of them. */
|
|
45
45
|
export const LWS_CLIENT_KINDS = [
|
|
46
|
-
'discover', 'read', 'head', 'create', 'update', 'patch', 'delete',
|
|
47
|
-
'
|
|
48
|
-
'
|
|
46
|
+
'discover', 'describe', 'read', 'readPage', 'head', 'create', 'update', 'patch', 'delete',
|
|
47
|
+
'findLinkset', 'readLinkset', 'updateLinkset', 'putLinkset', 'move', 'list', 'typeIndex',
|
|
48
|
+
'search', 'queryFormats', 'subscribe', 'getSubscription', 'unsubscribe', 'requestAccess',
|
|
49
|
+
'grantAccess', 'getAccess', 'revokeAccess', 'verifyNotification'
|
|
49
50
|
]
|
|
50
51
|
|
|
51
52
|
/** The error an operation ends with. `reason` is the engine's code
|
|
@@ -58,6 +59,9 @@ export class LwsClientError extends FactoidalError {
|
|
|
58
59
|
this.name = 'LwsClientError'
|
|
59
60
|
this.reason = reason
|
|
60
61
|
this.detail = fail
|
|
62
|
+
// The engine's name for the failure in the lws-driver/1 adapter
|
|
63
|
+
// protocol ({ kind, message, ... }), as the engine wrote it.
|
|
64
|
+
this.report = fail && typeof fail.report === 'object' ? fail.report : undefined
|
|
61
65
|
this.unknownOp = extra.unknownOp === true
|
|
62
66
|
}
|
|
63
67
|
}
|
|
@@ -138,8 +142,12 @@ export async function lwsClientOpsAvailable (engine) {
|
|
|
138
142
|
* @param {string} [options.subjectToken] the authentication credential to
|
|
139
143
|
* exchange for access tokens (RFC 8693 subject_token)
|
|
140
144
|
* @param {string} [options.subjectTokenType] its token type URI
|
|
141
|
-
* @param {object} [options.selfIssued] `{ subject, keyId, jwk }`: a P-256
|
|
142
|
-
* to sign self-issued credentials with, instead of a subject
|
|
145
|
+
* @param {object} [options.selfIssued] `{ subject, keyId, jwk }`: a P-256 or
|
|
146
|
+
* Ed25519 key to sign self-issued credentials with, instead of a subject
|
|
147
|
+
* token (`createDidKey` makes one)
|
|
148
|
+
* @param {string} [options.idToken] an OpenID Connect ID Token to exchange
|
|
149
|
+
* @param {object} [options.bearer] `{ token, realm? }`: an access token the
|
|
150
|
+
* caller already has; with a realm it is sent only under it
|
|
143
151
|
* @param {object} [options.session] a session from an earlier client
|
|
144
152
|
* @param {number} [options.maxSteps] the loop cap of one operation
|
|
145
153
|
* @returns {Promise<object>} the client
|
|
@@ -157,6 +165,9 @@ export async function createLwsClient (options = {}) {
|
|
|
157
165
|
// `{ subject, keyId, jwk }`: a P-256 key the engine signs self-issued
|
|
158
166
|
// credentials with (lws10-authn-ssi-cid), passed through unread.
|
|
159
167
|
if (options.selfIssued !== undefined) session.selfIssued = options.selfIssued
|
|
168
|
+
// Read by the engine's session reader (`Machine.sessionOfJson`), unread here.
|
|
169
|
+
if (options.idToken !== undefined) session.idToken = options.idToken
|
|
170
|
+
if (options.bearer !== undefined) session.bearer = options.bearer
|
|
160
171
|
|
|
161
172
|
function call (op, args) {
|
|
162
173
|
try {
|
|
@@ -230,7 +241,10 @@ export async function createLwsClient (options = {}) {
|
|
|
230
241
|
/** The session (access tokens and subject token), for a later client. */
|
|
231
242
|
session: () => ({ ...session }),
|
|
232
243
|
discover: with_('discover'),
|
|
244
|
+
describe: with_('describe'),
|
|
245
|
+
/** `read(url, { accept, rangeStart, rangeEnd, ifNoneMatch, prefer })` */
|
|
233
246
|
read: with_('read'),
|
|
247
|
+
readPage: with_('readPage'),
|
|
234
248
|
head: with_('head'),
|
|
235
249
|
/** `create(containerUrl, { body, mediaType, slug, container, types, links })` */
|
|
236
250
|
create: with_('create'),
|
|
@@ -239,26 +253,54 @@ export async function createLwsClient (options = {}) {
|
|
|
239
253
|
patch: with_('patch'),
|
|
240
254
|
/** `delete(url, { recursive, precondition, tag })` */
|
|
241
255
|
delete: with_('delete'),
|
|
256
|
+
findLinkset: with_('findLinkset'),
|
|
242
257
|
readLinkset: with_('readLinkset'),
|
|
258
|
+
/** `updateLinkset(resourceUrl, { body })`, or with `{ linksetUrl, body }`
|
|
259
|
+
* when the caller has the linkset resource. */
|
|
243
260
|
updateLinkset: with_('updateLinkset'),
|
|
261
|
+
putLinkset: with_('putLinkset'),
|
|
244
262
|
/** `move(url, { destination })` */
|
|
245
263
|
move: with_('move'),
|
|
246
264
|
list: with_('list'),
|
|
247
265
|
typeIndex: with_('typeIndex'),
|
|
248
|
-
/** `search(anyUrlInTheStorage, { query })`
|
|
266
|
+
/** `search(anyUrlInTheStorage, { query })`, or `{ endpoint, query }` with
|
|
267
|
+
* the service endpoint, `{ onePage: true }` for one page */
|
|
249
268
|
search: with_('search'),
|
|
269
|
+
queryFormats: with_('queryFormats'),
|
|
270
|
+
/** `subscribe(url, { topics, inbox, expires })` */
|
|
250
271
|
subscribe: with_('subscribe'),
|
|
272
|
+
getSubscription: with_('getSubscription'),
|
|
251
273
|
unsubscribe: with_('unsubscribe'),
|
|
252
274
|
requestAccess: with_('requestAccess'),
|
|
253
275
|
grantAccess: with_('grantAccess'),
|
|
254
276
|
getAccess: with_('getAccess'),
|
|
255
277
|
revokeAccess: with_('revokeAccess'),
|
|
256
|
-
/** `verifyNotification(baseUrl, { requestTarget, method, headers, body })`:
|
|
278
|
+
/** `verifyNotification(baseUrl, { requestTarget, method, headers, body, trustedStorages })`:
|
|
257
279
|
* the engine's verdict `{ accept, status, reason? }`. */
|
|
258
280
|
verifyNotification: with_('verifyNotification')
|
|
259
281
|
}
|
|
260
282
|
}
|
|
261
283
|
|
|
284
|
+
/**
|
|
285
|
+
* A new `did:key` agent: the engine makes the key pair and the identifier from
|
|
286
|
+
* 64 random bytes of this host's random source. The host reads none of it.
|
|
287
|
+
*
|
|
288
|
+
* @param {object} [options]
|
|
289
|
+
* @param {object} [options.engine] an engine handle to reuse
|
|
290
|
+
* @param {string} [options.algorithm] the engine's name of the algorithm
|
|
291
|
+
* (default `ES256`, a P-256 key; `EdDSA` makes an Ed25519 key)
|
|
292
|
+
* @returns {Promise<{agent: string, kid: string, selfIssued: object}>}
|
|
293
|
+
* `selfIssued` is the `createLwsClient` option of that name
|
|
294
|
+
*/
|
|
295
|
+
export async function createDidKey (options = {}) {
|
|
296
|
+
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
297
|
+
const algorithm = typeof options.algorithm === 'string' ? options.algorithm : 'ES256'
|
|
298
|
+
const random = new Uint8Array(64)
|
|
299
|
+
globalThis.crypto.getRandomValues(random)
|
|
300
|
+
const answer = engine.call('lwsClientNewKey', [algorithm, base64OfBytes(random)])
|
|
301
|
+
return answer.key
|
|
302
|
+
}
|
|
303
|
+
|
|
262
304
|
/**
|
|
263
305
|
* An HTTP receiver for webhook notifications. For each request it asks the
|
|
264
306
|
* engine (`verifyNotification`) and answers with the status of the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@factoidal/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sparql",
|
|
@@ -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/",
|
|
@@ -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
|
+
}
|