@factoidal/core 0.10.0 → 0.11.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.
@@ -22,12 +22,12 @@
22
22
  // None of them makes a semantic decision: which IRIs reach the host, what
23
23
  // an absent answer means, which artifact a query reads, are decided in Lean.
24
24
 
25
- const XSD = 'http://www.w3.org/2001/XMLSchema#';
26
25
 
27
26
  // The two npm entries and the engine each loads (issue 713 stage 4). The
28
27
  // core engine's `ops` reflection lists what only the full engine serves
29
28
  // (`fullOnlyOps`, `fullOnlyFormats`); a call to one of those names the
30
29
  // entry that has it.
30
+ import { appTermToJson, termToJson } from '../rdfjs.mjs';
31
31
  export const FULL_ENTRY = '@factoidal/core';
32
32
  // The core profile is built for the hub pages only; the package ships the
33
33
  // full engine (owner, 2026-10-20).
@@ -64,22 +64,22 @@ function failEnvelope(err) {
64
64
  return JSON.stringify({ ok: false, error: String((err && err.message) || err) });
65
65
  }
66
66
 
67
- // One SPARQL Results JSON term object per answer, the wire form
68
- // Wasm/ExtHost.lean reads; '' means "no value" (the section 17.6 error).
67
+ // One answer for Wasm/Ops/ExtFns.lean: the JavaScript value tagged with its
68
+ // JavaScript type ({js, v}; a number as its Number::toString text), or a term
69
+ // object. The engine chooses the datatype and the lexical form
70
+ // (L4Factoidal.XSD.JsValue.jsLiteral, issue 894 item 3). '' means "no
71
+ // value" (the section 17.6 error).
69
72
  function encodeExtAnswer(out) {
70
73
  if (out === null || out === undefined || out === EXT_PENDING_MARKER) return '';
71
- if (typeof out === 'boolean') {
72
- return JSON.stringify({ type: 'literal', value: out ? 'true' : 'false', datatype: XSD + 'boolean' });
74
+ switch (typeof out) {
75
+ case 'boolean': return JSON.stringify({ js: 'boolean', v: out });
76
+ case 'number': return JSON.stringify({ js: 'number', v: Object.is(out, -0) ? '-0' : String(out) });
77
+ case 'bigint': return JSON.stringify({ js: 'bigint', v: out.toString() });
78
+ case 'string': return JSON.stringify({ js: 'string', v: out });
79
+ case 'object':
80
+ try { return JSON.stringify(appTermToJson(out)); } catch (_error) { return ''; }
81
+ default: return '';
73
82
  }
74
- if (typeof out === 'number') {
75
- if (!Number.isFinite(out)) return '';
76
- return Number.isInteger(out)
77
- ? JSON.stringify({ type: 'literal', value: String(out), datatype: XSD + 'integer' })
78
- : JSON.stringify({ type: 'literal', value: String(out), datatype: XSD + 'double' });
79
- }
80
- if (typeof out === 'string') return JSON.stringify({ type: 'literal', value: out });
81
- if (typeof out === 'object' && typeof out.type === 'string') return JSON.stringify(out);
82
- return '';
83
83
  }
84
84
 
85
85
  // The Lean module asks its host through ONE global,
@@ -200,5 +200,14 @@ export function entryFromEngine(eng) {
200
200
  }
201
201
  };
202
202
  }
203
+ // datasetQuadsBin answers a byte region (formal/lean4/L4Factoidal/RDF/QuadsBin.lean):
204
+ // this method answers {envelope, bytes} and throws on a refusal.
205
+ const blobIoOps = Array.isArray(reflection.blobIoOps) ? reflection.blobIoOps : [];
206
+ if (typeof eng.callBlobIO === 'function' && blobIoOps.includes('datasetQuadsBin')) {
207
+ entry.datasetQuadsBin = (handle, optsJson) =>
208
+ eng.callBlobIO('datasetQuadsBin', [String(handle), String(optsJson ?? '')], new Uint8Array(0));
209
+ } else {
210
+ delete entry.datasetQuadsBin;
211
+ }
203
212
  return entry;
204
213
  }
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.10.0';
5
+ export const version = '0.11.0';
package/lws/README.md CHANGED
@@ -29,10 +29,13 @@ host or the engine failed rather than answered.
29
29
  | `lwsOpen` | `[configJson]` | `{ ok, handle, root }` |
30
30
  | `lwsStep` | `[handle, requestJson]` | `{ ok, response }` |
31
31
  | `lwsClose` | `[handle]` | `{ ok }` |
32
+ | `lwsSnapshot` | `[handle]` | `{ ok, snapshot }` |
33
+ | `lwsFetched` | `[handle, documentJson]` | `{ ok, fetch? , installed?, refused? }` |
32
34
 
33
35
  ```
34
- request { method, target, headers: [[name, value]], body }
35
- response { status, headers: [[name, value]], body }
36
+ request { method, target, headers: [[name, value]] } + the content as bytes
37
+ response { status, headers: [[name, value]], bodyBytes } + the content as bytes
38
+ (through callBlobIO; https://github.com/danbri/factoidal/issues/889)
36
39
  ```
37
40
 
38
41
  `headers` is a list of pairs, not an object: HTTP allows a field name to
@@ -43,7 +46,8 @@ unchanged and this host composes no IRI.
43
46
  `configJson` members, all optional: `baseIri` (the absolute IRI the
44
47
  storage root maps to), `now` (the engine clock in seconds, which advances
45
48
  by one second per mutation so a write sequence has increasing
46
- `Last-Modified` values with no real clock), `owner`, `agent`. A request
49
+ `Last-Modified` values with no real clock), `owner`, `agent`, `rootAcl`,
50
+ `auth` and `insecureNoAuth` (below). A request
47
51
  may carry `now` and `agent` of its own to override the handle's for one
48
52
  step; `listen({realClock: true})` is how a server with a real clock feeds
49
53
  one in, and a test leaves it off to keep the engine's deterministic
@@ -55,12 +59,41 @@ A WebAssembly module built before these ops landed answers `unknown op`.
55
59
  and every test in `tests/lws/` skips with the reason printed rather than
56
60
  passing on an engine that cannot answer.
57
61
 
62
+ ## Authorization
63
+
64
+ Authorization follows the draft's "Authorization" section
65
+ (https://github.com/danbri/factoidal/issues/886) and is decided by
66
+ `formal/lean4/L4Factoidal/LWS/Authorization.lean`. It is ON by default.
67
+
68
+ ```js
69
+ const running = await listen({
70
+ port: 3000,
71
+ owner: 'https://id.example/alice', // root ACL: Read, Write, Control
72
+ auth: { issuers: [{ issuer: 'https://as.example' }] },
73
+ realClock: true
74
+ })
75
+ ```
76
+
77
+ * Clients send `Authorization: Bearer <access token>` (RFC 6750). The
78
+ token is a JWT (RFC 9068) signed with ES256, RS256 or EdDSA by a trusted
79
+ issuer; `aud` must contain the storage IRI (`baseIri`, or `auth.audience`).
80
+ * Without a valid token the answer is 401 with
81
+ `WWW-Authenticate: Bearer resource_metadata="…"`; the RFC 9728 metadata
82
+ document is served at `/.well-known/oauth-protected-resource<path>`.
83
+ * The host fetches the issuer's `/.well-known/lws-configuration` and its
84
+ `jwks_uri` when the engine asks (`lwsStep` answers `{ fetch }`), passes
85
+ the bytes to `lwsFetched`, and steps again. `auth.issuers[].jwks` gives
86
+ the keys inline instead.
87
+ * `insecureNoAuth: true` serves every request with no authorization. It is
88
+ for development only, and must be asked for.
89
+
58
90
  ## Use
59
91
 
60
92
  ```js
61
93
  import { listen } from '@factoidal/core/lws'
62
94
 
63
- const running = await listen({ port: 3000, baseIri: 'https://storage.example/' })
95
+ const running = await listen({ port: 3000, baseIri: 'https://storage.example/',
96
+ insecureNoAuth: true }) // development only; see Authorization
64
97
  console.log(running.origin)
65
98
  await running.close()
66
99
  ```
@@ -68,9 +101,13 @@ await running.close()
68
101
  From the command:
69
102
 
70
103
  ```
71
- factoidal lws-serve /var/lib/lws --port 3000
104
+ factoidal lws-serve /var/lib/lws --port 3000 --issuer https://as.example --owner https://id.example/alice
105
+ factoidal lws-serve /tmp/dev --port 3000 --insecure-no-auth
72
106
  ```
73
107
 
108
+ One of `--issuer`, `--auth-config FILE` or `--insecure-no-auth` is
109
+ required.
110
+
74
111
  `--port 0` takes an ephemeral port and the command prints the one it got;
75
112
  `--base IRI` sets `baseIri`, which defaults to the bound origin. `DIR`
76
113
  names the storage directory, but the first slice keeps the resource tree
@@ -86,12 +123,15 @@ in the engine handle, so nothing is read from it or written to it yet
86
123
  | `openLwsStorage(options)` | open a handle and get `step` / `close` |
87
124
  | `createLwsServer(...).setBaseIri(iri)` | set `baseIri` before the first request |
88
125
  | `lwsOpsAvailable(engine)` | probe the loaded module for the ops |
89
- | `LWS_OPS` | the three op names |
126
+ | `LWS_OPS` | the op names |
90
127
  | `LwsHostError` | a host failure, with `unknownOp` set when the op is absent |
91
128
 
92
129
  ## Tests
93
130
 
94
131
  ```
95
132
  node tests/lws/server.mjs
133
+ node tests/lws/draft-binding.mjs
134
+ node tests/lws/binary-bodies.mjs
135
+ node tests/lws/authorization.mjs
96
136
  deno run --allow-read --allow-net --allow-write --allow-env tests/lws/server.mjs
97
137
  ```
@@ -0,0 +1,78 @@
1
+ // Durable storage for `factoidal lws-serve DIR` and `solid-serve DIR`.
2
+ // https://github.com/danbri/factoidal/issues/659
3
+ //
4
+ // WHAT THIS FILE IS ALLOWED TO DO
5
+ // Move bytes between one file and the engine. The engine decides what
6
+ // the bytes are (`L4Factoidal/LWS/Snapshot.lean`): the snapshot document,
7
+ // which paths it may hold (no `..`, no absolute or encoded traversal), and
8
+ // when the storage changed (the `changed` member of the step envelope).
9
+ // This file decides only where the one file lives and how it is written:
10
+ //
11
+ // DIR/lws-state.json the snapshot, replaced atomically
12
+ //
13
+ // No request target ever becomes a file name here, so a request cannot
14
+ // address a file outside DIR. The write is: a temporary file in DIR,
15
+ // fsync, rename over the state file, fsync of DIR. A crash leaves the old
16
+ // snapshot or the new one, never a torn one.
17
+
18
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync, existsSync } from 'node:fs'
19
+ import { join, resolve } from 'node:path'
20
+
21
+ export const STATE_FILE = 'lws-state.json'
22
+
23
+ /** The absolute path of the state file of a storage directory. */
24
+ export function stateFileOf (dir) {
25
+ return join(resolve(dir), STATE_FILE)
26
+ }
27
+
28
+ /**
29
+ * The parsed snapshot in DIR, or `null` when DIR holds none yet. The
30
+ * engine checks the document; a file that does not parse as JSON is an
31
+ * error of this host, reported as such.
32
+ */
33
+ export function readSnapshot (dir) {
34
+ const file = stateFileOf(dir)
35
+ if (!existsSync(file)) return null
36
+ const text = readFileSync(file, 'utf8')
37
+ try {
38
+ return JSON.parse(text)
39
+ } catch (error) {
40
+ throw new Error(`${file} is not JSON: ${error.message}`)
41
+ }
42
+ }
43
+
44
+ /** Write a snapshot document to DIR, atomically. */
45
+ export function writeSnapshot (dir, snapshot) {
46
+ const root = resolve(dir)
47
+ mkdirSync(root, { recursive: true })
48
+ const file = stateFileOf(root)
49
+ const temporary = join(root, `.${STATE_FILE}.${process.pid}.tmp`)
50
+ const fd = openSync(temporary, 'w', 0o600)
51
+ try {
52
+ writeSync(fd, JSON.stringify(snapshot))
53
+ fsyncSync(fd)
54
+ } finally {
55
+ closeSync(fd)
56
+ }
57
+ renameSync(temporary, file)
58
+ try {
59
+ const dirFd = openSync(root, 'r')
60
+ try { fsyncSync(dirFd) } finally { closeSync(dirFd) }
61
+ } catch (_error) {
62
+ // Some platforms cannot fsync a directory; the rename stands.
63
+ }
64
+ }
65
+
66
+ /**
67
+ * After one step: when the engine says the storage changed, ask it for
68
+ * the snapshot (`snapshotOp`) and write it.
69
+ */
70
+ export function persistIfChanged (engine, snapshotOp, handle, envelope, dir) {
71
+ if (typeof dir !== 'string' || envelope === null || typeof envelope !== 'object') return
72
+ if (envelope.changed !== true) return
73
+ const answer = engine.call(snapshotOp, [handle])
74
+ if (answer === null || typeof answer !== 'object' || answer.snapshot === undefined) {
75
+ throw new Error(`${snapshotOp} answered with no snapshot`)
76
+ }
77
+ writeSnapshot(dir, answer.snapshot)
78
+ }
package/lws/server.mjs CHANGED
@@ -21,12 +21,25 @@
21
21
  // lwsOpen [configJson] -> { ok, handle, root }
22
22
  // lwsStep [handle, requestJson] -> { ok, response }
23
23
  // lwsClose [handle] -> { ok }
24
+ // lwsFetched [handle, documentJson] -> { ok, fetch? | installed? | refused? }
24
25
  //
25
- // request { method, target, headers: [[name, value]], body }
26
- // response { status, headers: [[name, value]], body }
26
+ // `lwsStep` may answer `{ ok, fetch: { issuer, url } }` instead of a
27
+ // response: the engine needs an authorization server's metadata or key
28
+ // set before it can decide. This host fetches that URL, passes
29
+ // `{ issuer, url, status, body, now }` to `lwsFetched`, follows any
30
+ // further `fetch` it names, and steps the request again.
27
31
  //
28
- // The config members are `baseIri`, `now`, `owner` and `agent`, all
29
- // optional. `target` is a path within the storage, so this host passes
32
+ // request { method, target, headers: [[name, value]] } + content bytes
33
+ // response { status, headers: [[name, value]], bodyBytes } + content bytes
34
+ //
35
+ // Bodies are BYTES (https://github.com/danbri/factoidal/issues/889). The
36
+ // step goes through the engine's byte-region entry, `callBlobIO`: the
37
+ // request content is the IN region and the response content is the OUT
38
+ // region, with no encoding and no decoding here. This host never reads a
39
+ // body as text.
40
+ //
41
+ // The config members are `baseIri`, `now`, `owner`, `agent`, `rootAcl`,
42
+ // `auth` and `insecureNoAuth`, all optional. `target` is a path within the storage, so this host passes
30
43
  // Node's `request.url` through unchanged and never composes an absolute
31
44
  // IRI: `baseIri` tells the engine what the root maps to.
32
45
  //
@@ -48,9 +61,30 @@
48
61
  // produced and this code keeps working unchanged.
49
62
 
50
63
  import { loadEngine } from '../bin/engine.mjs'
64
+ import { readSnapshot, persistIfChanged } from './persist.mjs'
51
65
 
52
66
  /** The ops this host needs from the engine. */
53
- export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose']
67
+ export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched']
68
+
69
+ /** The most documents one request may make this host fetch before the
70
+ * engine has answered it. The engine names at most two per issuer (its
71
+ * metadata, then its key set); this bound only stops a defective module
72
+ * from looping the host. */
73
+ const MAX_FETCHES = 8
74
+
75
+ /**
76
+ * Fetch one document the engine named, for `lwsFetched`. Pure I/O: the
77
+ * engine decides what the bytes mean. A network failure is reported as
78
+ * status 0, which the engine refuses like any other failed fetch.
79
+ */
80
+ async function fetchDocument (url) {
81
+ try {
82
+ const answer = await fetch(url, { redirect: 'error' })
83
+ return { status: answer.status, body: await answer.text() }
84
+ } catch (error) {
85
+ return { status: 0, body: String(error && error.message ? error.message : error) }
86
+ }
87
+ }
54
88
 
55
89
  /** An error raised by the LWS host, as opposed to one the engine
56
90
  * reported. `unknownOp` is set when the loaded WebAssembly module does
@@ -108,7 +142,7 @@ function handleOf (envelope) {
108
142
  return null
109
143
  }
110
144
 
111
- function responseOf (envelope) {
145
+ function responseOf (envelope, bytes) {
112
146
  if (envelope === null || typeof envelope !== 'object') {
113
147
  throw new LwsHostError('lwsStep answered with no envelope')
114
148
  }
@@ -119,10 +153,18 @@ function responseOf (envelope) {
119
153
  throw new LwsHostError('lwsStep answered with no status in its response record')
120
154
  }
121
155
  const headers = Array.isArray(record.headers) ? record.headers : []
122
- const body = typeof record.body === 'string' ? record.body : ''
156
+ const body = bytes instanceof Uint8Array ? bytes : new Uint8Array(0)
123
157
  return { status: record.status, headers, body }
124
158
  }
125
159
 
160
+ /** The request content as bytes: a byte array is passed as it is, and a
161
+ * string (a JavaScript caller's convenience) as its UTF-8 bytes. */
162
+ function contentBytes (request) {
163
+ if (request.body instanceof Uint8Array) return request.body
164
+ if (typeof request.body === 'string') return new TextEncoder().encode(request.body)
165
+ return new Uint8Array(0)
166
+ }
167
+
126
168
  /**
127
169
  * Open one LWS storage on the engine and return the handle plus the two
128
170
  * calls a host makes against it.
@@ -146,6 +188,20 @@ export async function openLwsStorage (options = {}) {
146
188
  if (typeof options.now === 'number') config.now = options.now
147
189
  if (typeof options.owner === 'string') config.owner = options.owner
148
190
  if (typeof options.agent === 'string') config.agent = options.agent
191
+ // Authorization (L4Factoidal/LWS/Authorization.lean). `auth` is passed
192
+ // through unread; `insecureNoAuth` is the explicit development mode.
193
+ // With neither, the engine enforces authorization with no trusted
194
+ // issuer.
195
+ if (options.auth !== undefined && options.auth !== null) config.auth = options.auth
196
+ if (options.insecureNoAuth === true) config.insecureNoAuth = true
197
+ if (typeof options.rootAcl === 'string') config.rootAcl = options.rootAcl
198
+ // `dir` names the storage directory. The snapshot found there, if any,
199
+ // is the storage to continue from; the engine checks it.
200
+ const dir = typeof options.dir === 'string' ? options.dir : null
201
+ if (dir !== null) {
202
+ const snapshot = readSnapshot(dir)
203
+ if (snapshot !== null) config.snapshot = snapshot
204
+ }
149
205
  let opened
150
206
  try {
151
207
  opened = engine.call('lwsOpen', [JSON.stringify(config)])
@@ -167,14 +223,43 @@ export async function openLwsStorage (options = {}) {
167
223
  /**
168
224
  * One protocol step.
169
225
  * @param {{method: string, target: string,
170
- * headers: Array<[string, string]>, body: string}} request
171
- * @returns {{status: number, headers: Array<[string, string]>, body: string}}
226
+ * headers: Array<[string, string]>, body: Uint8Array|string}} request
227
+ * @returns {{status: number, headers: Array<[string, string]>, body: Uint8Array}}
172
228
  */
173
- step (request) {
229
+ async step (request) {
230
+ const content = contentBytes(request)
231
+ const { body: _body, ...fields } = request
174
232
  const record = options.realClock === true
175
- ? { ...request, now: Math.floor(Date.now() / 1000) }
176
- : request
177
- return responseOf(engine.call('lwsStep', [handle, JSON.stringify(record)]))
233
+ ? { ...fields, now: Math.floor(Date.now() / 1000) }
234
+ : fields
235
+ const callStep = () => engine.callBlobIO('lwsStep', [handle, JSON.stringify(record)], content)
236
+ let answered = callStep()
237
+ let envelope = answered.envelope
238
+ // The engine may answer "fetch this document first" (an issuer's
239
+ // metadata or key set). The host fetches exactly what it is told,
240
+ // hands the bytes back, and steps the request again.
241
+ let fetches = 0
242
+ while (envelope !== null && typeof envelope === 'object' &&
243
+ envelope.fetch !== undefined && envelope.fetch !== null) {
244
+ let wanted = envelope.fetch
245
+ while (wanted !== undefined && wanted !== null) {
246
+ fetches += 1
247
+ if (fetches > MAX_FETCHES) {
248
+ throw new LwsHostError('the engine asked for more documents than one request may fetch')
249
+ }
250
+ const got = await fetchDocument(wanted.url)
251
+ const told = engine.call('lwsFetched', [handle, JSON.stringify({
252
+ issuer: wanted.issuer, url: wanted.url, status: got.status, body: got.body,
253
+ ...(typeof record.now === 'number' ? { now: record.now } : {})
254
+ })])
255
+ wanted = told !== null && typeof told === 'object' ? told.fetch : null
256
+ }
257
+ answered = callStep()
258
+ envelope = answered.envelope
259
+ }
260
+ const answer = responseOf(envelope, answered.bytes)
261
+ if (dir !== null) persistIfChanged(engine, 'lwsSnapshot', handle, envelope, dir)
262
+ return answer
178
263
  },
179
264
  close () {
180
265
  try { engine.call('lwsClose', [handle]) } catch (_error) { /* already gone */ }
@@ -182,13 +267,13 @@ export async function openLwsStorage (options = {}) {
182
267
  }
183
268
  }
184
269
 
185
- /** Read a Node request body as one UTF-8 string. */
270
+ /** Read a Node request body as its bytes. */
186
271
  function readBody (request) {
187
272
  return new Promise((resolve, reject) => {
188
273
  const chunks = []
189
274
  request.on('data', (chunk) => chunks.push(chunk))
190
275
  request.on('error', reject)
191
- request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
276
+ request.on('end', () => resolve(new Uint8Array(Buffer.concat(chunks))))
192
277
  })
193
278
  }
194
279
 
@@ -250,7 +335,7 @@ export async function createLwsServer (options = {}) {
250
335
  }
251
336
  let answer
252
337
  try {
253
- answer = (await storage()).step(record)
338
+ answer = await (await storage()).step(record)
254
339
  } catch (error) {
255
340
  // A failure of this host or of the engine, not a protocol answer.
256
341
  // The engine's own words are kept; nothing is invented.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",