@factoidal/core 0.11.0 → 0.13.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/lws/server.mjs CHANGED
@@ -22,6 +22,29 @@
22
22
  // lwsStep [handle, requestJson] -> { ok, response }
23
23
  // lwsClose [handle] -> { ok }
24
24
  // lwsFetched [handle, documentJson] -> { ok, fetch? | installed? | refused? }
25
+ // lwsDelivered [handle, reportJson] -> { ok, changed, retry? }
26
+ // lwsFlush [handle, { now? }] -> { ok, changed, deliveries?, wake? }
27
+ //
28
+ // RATE LIMIT AND INBOX CHECK (https://github.com/danbri/factoidal/issues/915)
29
+ // An answer may carry `wake: SECONDS`: deliveries wait for a rate-limit
30
+ // window. This host calls `lwsFlush` after that many seconds and makes the
31
+ // deliveries it names. `lwsStep` may answer `{ ok, resolve: { host } }`:
32
+ // this host resolves the name (`dns.lookup`, every address) and steps the
33
+ // request again with `resolved: [{ host, addresses }]`. The listen address,
34
+ // port and the machine's interface addresses go to `lwsOpen` as `listen`.
35
+ // Whether an inbox is this server, and how many deliveries go when, are
36
+ // decided by `L4Factoidal/LWS/InboxGuard.lean` and `Notifications.lean`.
37
+ //
38
+ // NOTIFICATIONS (https://github.com/danbri/factoidal/issues/890)
39
+ // `lwsStep` may carry `deliveries: [{ id, url, headers, body }]`: HTTP
40
+ // requests the engine decided to make (LWS notifications, webhook suite).
41
+ // This host POSTs each one exactly as given, after the response to the
42
+ // request that caused it, and reports the status it got (0 when no answer
43
+ // came) to `lwsDelivered`. When the engine answers `retry`, this host
44
+ // waits the number of seconds it names and makes that request. Which
45
+ // changes are announced, to whom, with which body and signature, whether
46
+ // to retry and when a subscription ends are all decided by
47
+ // `formal/lean4/L4Factoidal/LWS/Notifications.lean`.
25
48
  //
26
49
  // `lwsStep` may answer `{ ok, fetch: { issuer, url } }` instead of a
27
50
  // response: the engine needs an authorization server's metadata or key
@@ -38,7 +61,7 @@
38
61
  // region, with no encoding and no decoding here. This host never reads a
39
62
  // body as text.
40
63
  //
41
- // The config members are `baseIri`, `now`, `owner`, `agent`, `rootAcl`,
64
+ // The config members are `baseIri`, `now`, `owner`, `agent`, `pageSize`, `rootAcl`,
42
65
  // `auth` and `insecureNoAuth`, all optional. `target` is a path within the storage, so this host passes
43
66
  // Node's `request.url` through unchanged and never composes an absolute
44
67
  // IRI: `baseIri` tells the engine what the root maps to.
@@ -61,10 +84,27 @@
61
84
  // produced and this code keeps working unchanged.
62
85
 
63
86
  import { loadEngine } from '../bin/engine.mjs'
64
- import { readSnapshot, persistIfChanged } from './persist.mjs'
87
+ import { readSnapshot, persistIfChanged, storageKeyText, storageModeText, loadFiles, persistFilesIfChanged, readStepBodies } from './persist.mjs'
65
88
 
66
89
  /** The ops this host needs from the engine. */
67
- export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched']
90
+ export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched', 'lwsDelivered',
91
+ 'lwsLoad', 'lwsPersist', 'lwsRead']
92
+
93
+ /** How long this host waits for the answer to one delivery request before
94
+ * it reports "no answer" (status 0) to the engine. */
95
+ const DELIVERY_TIMEOUT_MS = 10000
96
+
97
+ /** Random bytes for the engine, base64: `entropy` at open (32 bytes, the
98
+ * seed of the signing key of a new storage and of the identifiers of one
99
+ * run) and `random` with each request (16 bytes, the name of a search
100
+ * results resource when the engine makes one). Pure I/O. */
101
+ function entropyBase64 (size = 32) {
102
+ const bytes = new Uint8Array(size)
103
+ globalThis.crypto.getRandomValues(bytes)
104
+ let text = ''
105
+ for (const b of bytes) text += String.fromCharCode(b)
106
+ return btoa(text)
107
+ }
68
108
 
69
109
  /** The most documents one request may make this host fetch before the
70
110
  * engine has answered it. The engine names at most two per issuer (its
@@ -72,6 +112,32 @@ export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFet
72
112
  * from looping the host. */
73
113
  const MAX_FETCHES = 8
74
114
 
115
+ /** The most host names one request may make this host resolve. The engine
116
+ * names at most one (the inbox of a subscription request). */
117
+ const MAX_RESOLVES = 4
118
+
119
+ /** Resolve one host name the engine named. Pure I/O: every address
120
+ * `dns.lookup` gives, or none when it fails; the engine decides. */
121
+ async function resolveHost (host) {
122
+ try {
123
+ const dns = await import('node:dns')
124
+ const found = await dns.promises.lookup(host, { all: true, verbatim: true })
125
+ return found.map((a) => a.address)
126
+ } catch (_error) {
127
+ return []
128
+ }
129
+ }
130
+
131
+ /** The addresses of this machine's network interfaces. Pure I/O. */
132
+ async function interfaceAddresses () {
133
+ try {
134
+ const os = await import('node:os')
135
+ return Object.values(os.networkInterfaces()).flat().filter(Boolean).map((i) => i.address)
136
+ } catch (_error) {
137
+ return []
138
+ }
139
+ }
140
+
75
141
  /**
76
142
  * Fetch one document the engine named, for `lwsFetched`. Pure I/O: the
77
143
  * engine decides what the bytes mean. A network failure is reported as
@@ -169,7 +235,7 @@ function contentBytes (request) {
169
235
  * Open one LWS storage on the engine and return the handle plus the two
170
236
  * calls a host makes against it.
171
237
  *
172
- * The config members are the ABI's own: `baseIri`, `now`, `owner`,
238
+ * The config members are the ABI's own: `baseIri`, `now`, `owner`, `pageSize`,
173
239
  * `agent`. Nothing else is sent.
174
240
  *
175
241
  * @param {object} options
@@ -177,7 +243,17 @@ function contentBytes (request) {
177
243
  * @param {number} [options.now] the engine clock, seconds since the epoch
178
244
  * @param {string} [options.owner] the WebID the storage advertises
179
245
  * @param {string} [options.agent] the WebID of the requesting agent
246
+ * @param {number} [options.pageSize] the container pagination threshold and page size (default 100; 0 turns pagination off)
247
+ * @param {boolean} [options.paginateAlways] present every container listing as paginated (default false)
248
+ * @param {boolean|string} [options.moves] which resources move: true or "all", "data", false or "off" (default all)
249
+ * @param {boolean} [options.linksetPut] does a linkset accept PUT (default true)
180
250
  * @param {boolean} [options.realClock] stamp each request with this host's clock
251
+ * @param {string|null} [options.storageKey] the storage key text (64 hex digits or base64url);
252
+ * default the environment variable FACTOIDAL_LWS_KEY; `null` reads no key
253
+ * @param {string} [options.storage] where DIR keeps the storage: "snapshot" (default, one
254
+ * file) or "files" (one file per body and a journal, issue 918); passed unread
255
+ * @param {boolean|string[]} [options.cors] CORS (issue 923): false for none, a list of
256
+ * origins to answer; default: echo any valid request Origin. Passed unread
181
257
  * @param {object} [options.engine] an engine handle to reuse
182
258
  * @returns {Promise<{step: Function, close: Function, handle: string, engine: object}>}
183
259
  */
@@ -188,6 +264,9 @@ export async function openLwsStorage (options = {}) {
188
264
  if (typeof options.now === 'number') config.now = options.now
189
265
  if (typeof options.owner === 'string') config.owner = options.owner
190
266
  if (typeof options.agent === 'string') config.agent = options.agent
267
+ // The pagination threshold and page size of container listings; the
268
+ // engine decides what it means (L4Factoidal/LWS/Pagination.lean).
269
+ if (typeof options.pageSize === 'number') config.pageSize = options.pageSize
191
270
  // Authorization (L4Factoidal/LWS/Authorization.lean). `auth` is passed
192
271
  // through unread; `insecureNoAuth` is the explicit development mode.
193
272
  // With neither, the engine enforces authorization with no trusted
@@ -195,10 +274,70 @@ export async function openLwsStorage (options = {}) {
195
274
  if (options.auth !== undefined && options.auth !== null) config.auth = options.auth
196
275
  if (options.insecureNoAuth === true) config.insecureNoAuth = true
197
276
  if (typeof options.rootAcl === 'string') config.rootAcl = options.rootAcl
277
+ // Notifications are on unless the caller turns them off; the engine
278
+ // decides everything else about them.
279
+ if (options.notifications === false) config.notifications = false
280
+ // The Type Index and Type Search services are on unless the caller turns
281
+ // them off (L4Factoidal/LWS/Search.lean).
282
+ if (options.typeIndex === false) config.typeIndex = false
283
+ // Moves (a change of the up link, L4Factoidal/LWS/Move.lean) are on
284
+ // unless the caller turns them off.
285
+ if (options.moves === false) config.moves = false
286
+ // `moves` may also be "all", "data" or "off", passed unread; the engine
287
+ // decides what each means and refuses another value (Wasm/Ops/Lws.lean).
288
+ if (typeof options.moves === 'string') config.moves = options.moves
289
+ // A linkset resource accepts PUT unless the caller turns it off
290
+ // (L4Factoidal/LWS/Operations.lean, linksetStep).
291
+ if (options.linksetPut === false) config.linksetPut = false
292
+ // Every container listing paginated, also one that fits on one page
293
+ // (L4Factoidal/LWS/Pagination.lean, paginatedWith). Off unless turned on.
294
+ if (options.paginateAlways === true) config.paginateAlways = true
295
+ // Access requests and grants are on unless the caller turns them off
296
+ // (L4Factoidal/LWS/AccessGrants.lean).
297
+ if (options.accessGrants === false) config.accessGrants = false
298
+ // ANTICIPATORY AND UNSTABLE (https://github.com/danbri/factoidal/issues/922):
299
+ // a QUERY answer of the type search names a results resource with
300
+ // Content-Location. Off unless the caller turns it on
301
+ // (L4Factoidal/LWS/SearchResults.lean decides everything about it).
302
+ if (options.searchResults === true) config.searchResults = true
303
+ if (typeof options.searchResultsTtl === 'number') config.searchResultsTtl = options.searchResultsTtl
304
+ if (typeof options.searchResultsMax === 'number') config.searchResultsMax = options.searchResultsMax
305
+ // Merkle hashes (https://github.com/danbri/factoidal/issues/919): off
306
+ // unless the caller turns them on (L4Factoidal/LWS/MerkleHttp.lean decides
307
+ // every field and member they add).
308
+ if (options.merkle === true) config.merkle = true
309
+ // Webhook delivery rate (issue 915), the redirect table bound (issue 914)
310
+ // and the storage controller's inbox (issue 920): passed unread; the
311
+ // engine checks and decides.
312
+ if (typeof options.deliveryLimit === 'number') config.deliveryLimit = options.deliveryLimit
313
+ if (typeof options.deliveryWindow === 'number') config.deliveryWindow = options.deliveryWindow
314
+ if (typeof options.redirectsMax === 'number') config.redirectsMax = options.redirectsMax
315
+ if (typeof options.controllerInbox === 'string') config.controllerInbox = options.controllerInbox
316
+ // CORS (https://github.com/danbri/factoidal/issues/923): `cors` is false or
317
+ // a list of origins, passed unread; absent, the engine echoes the request
318
+ // Origin. L4Factoidal/LWS/Cors.lean decides every CORS field.
319
+ if (options.cors !== undefined) config.cors = options.cors
320
+ // What this host bound: data for the engine's inbox check (issue 915).
321
+ if (options.listen !== undefined && options.listen !== null) config.listen = options.listen
322
+ config.entropy = entropyBase64()
323
+ // Encryption at rest (issue 921): the key text from `storageKey` or the
324
+ // environment variable FACTOIDAL_LWS_KEY, passed unread. The engine checks
325
+ // it, refuses a file that does not match it, and seals every snapshot.
326
+ const storageKey = storageKeyText(options.storageKey)
327
+ if (storageKey !== undefined) config.storageKey = storageKey
328
+ // Where the storage is kept (https://github.com/danbri/factoidal/issues/918):
329
+ // `storage` is passed unread; the engine refuses a value it does not know.
330
+ // With "files" the engine names the files to read (`load`) and gives the
331
+ // plan of every write (`lwsPersist`), and this host reads no snapshot.
332
+ // With a DIR and no `storage` option, the environment variable
333
+ // FACTOIDAL_LWS_STORAGE is the value; without a DIR there is nothing to
334
+ // keep, and only an explicit option is passed.
335
+ const dir = typeof options.dir === 'string' ? options.dir : null
336
+ const storage = dir !== null ? storageModeText(options.storage) : options.storage
337
+ if (typeof storage === 'string') config.storage = storage
198
338
  // `dir` names the storage directory. The snapshot found there, if any,
199
339
  // is the storage to continue from; the engine checks it.
200
- const dir = typeof options.dir === 'string' ? options.dir : null
201
- if (dir !== null) {
340
+ if (dir !== null && storage !== 'files') {
202
341
  const snapshot = readSnapshot(dir)
203
342
  if (snapshot !== null) config.snapshot = snapshot
204
343
  }
@@ -217,6 +356,95 @@ export async function openLwsStorage (options = {}) {
217
356
  if (handle === null) {
218
357
  throw new LwsHostError('lwsOpen answered with no handle')
219
358
  }
359
+ // A storage kept in files: read the files the engine names until it is
360
+ // ready, and perform the plan of the start. Without a DIR, there are no
361
+ // files: the engine is told none is present.
362
+ const filesMode = opened !== null && typeof opened === 'object' &&
363
+ opened.load !== undefined && opened.load !== null
364
+ if (filesMode) {
365
+ if (dir === null) throw new LwsHostError('a storage kept in files needs a storage directory (dir)')
366
+ loadFiles(engine, handle, opened, dir)
367
+ }
368
+ /** After a call whose envelope may say `changed`: write what the engine
369
+ * gives (the snapshot, or the plan of a storage kept in files). */
370
+ const persist = (envelope) => {
371
+ if (dir === null) return
372
+ if (filesMode) persistFilesIfChanged(engine, handle, envelope, dir)
373
+ else persistIfChanged(engine, 'lwsSnapshot', handle, envelope, dir)
374
+ }
375
+
376
+ // Deliveries in flight, so a caller (a test, a shutdown) can wait.
377
+ const inFlight = new Set()
378
+ let closed = false
379
+ // The one timer that calls `lwsFlush` when the engine named a `wake`.
380
+ let flushTimer = null
381
+ let flushAt = Infinity
382
+ const clockNow = () => (options.realClock === true ? { now: Math.floor(Date.now() / 1000) } : {})
383
+
384
+ /** Make one delivery request the engine named, report the status, and
385
+ * follow the engine's answer. */
386
+ function deliver (delivery) {
387
+ const job = (async () => {
388
+ let status = 0
389
+ try {
390
+ const answer = await fetch(delivery.url, {
391
+ method: 'POST',
392
+ headers: Array.isArray(delivery.headers) ? delivery.headers : [],
393
+ body: delivery.body,
394
+ redirect: 'manual',
395
+ signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS)
396
+ })
397
+ status = answer.status
398
+ await answer.arrayBuffer()
399
+ } catch (_error) {
400
+ status = 0
401
+ }
402
+ if (closed) return
403
+ const told = engine.call('lwsDelivered', [handle, JSON.stringify({
404
+ id: delivery.id, status, ...clockNow()
405
+ })])
406
+ persist(told)
407
+ const retry = told !== null && typeof told === 'object' ? told.retry : null
408
+ if (retry !== undefined && retry !== null && typeof retry.after === 'number') {
409
+ await new Promise((resolve) => {
410
+ const timer = setTimeout(resolve, retry.after * 1000)
411
+ if (timer && typeof timer.unref === 'function') timer.unref()
412
+ })
413
+ if (!closed) deliver(retry.delivery)
414
+ }
415
+ })()
416
+ inFlight.add(job)
417
+ // A failure here is a failure of this host or of the engine call, not a
418
+ // protocol answer; it is reported and the delivery is dropped.
419
+ job.catch((error) => { console.error(`lws-serve: delivery: ${error && error.message ? error.message : error}`) })
420
+ .finally(() => inFlight.delete(job))
421
+ }
422
+
423
+ /** Call `lwsFlush` after `seconds`, unless an earlier call is due. */
424
+ function scheduleFlush (seconds) {
425
+ if (closed || typeof seconds !== 'number') return
426
+ const at = Date.now() + seconds * 1000
427
+ if (flushTimer !== null && flushAt <= at) return
428
+ if (flushTimer !== null) clearTimeout(flushTimer)
429
+ flushAt = at
430
+ flushTimer = setTimeout(() => {
431
+ flushTimer = null
432
+ flushAt = Infinity
433
+ if (closed) return
434
+ try {
435
+ const told = engine.call('lwsFlush', [handle, JSON.stringify(clockNow())])
436
+ persist(told)
437
+ if (told !== null && typeof told === 'object') {
438
+ if (Array.isArray(told.deliveries)) for (const delivery of told.deliveries) deliver(delivery)
439
+ if (typeof told.wake === 'number') scheduleFlush(told.wake)
440
+ }
441
+ } catch (error) {
442
+ console.error(`lws-serve: flush: ${error && error.message ? error.message : error}`)
443
+ }
444
+ }, seconds * 1000)
445
+ if (flushTimer && typeof flushTimer.unref === 'function') flushTimer.unref()
446
+ }
447
+
220
448
  return {
221
449
  engine,
222
450
  handle,
@@ -229,12 +457,46 @@ export async function openLwsStorage (options = {}) {
229
457
  async step (request) {
230
458
  const content = contentBytes(request)
231
459
  const { body: _body, ...fields } = request
460
+ // Random bytes with every request; the engine uses them only to name
461
+ // a search results resource (issue 922), and only when that is on.
462
+ const stamped = { ...fields, random: entropyBase64(16) }
232
463
  const record = options.realClock === true
233
- ? { ...fields, now: Math.floor(Date.now() / 1000) }
234
- : fields
235
- const callStep = () => engine.callBlobIO('lwsStep', [handle, JSON.stringify(record)], content)
464
+ ? { ...stamped, now: Math.floor(Date.now() / 1000) }
465
+ : stamped
466
+ // The engine may ask for the addresses of a host name first
467
+ // (`resolve`); this host resolves it and steps the request again.
468
+ let resolves = 0
469
+ // A storage kept in files (https://github.com/danbri/factoidal/issues/918,
470
+ // stage 2) may answer `read: [NAME, ...]` first: the body files the step
471
+ // needs. This host reads exactly those files, passes them to `lwsRead`
472
+ // (the engine checks them), and steps the same request again.
473
+ const callStep = () => {
474
+ let got = engine.callBlobIO('lwsStep', [handle, JSON.stringify(record)], content)
475
+ let reads = 0
476
+ while (got.envelope !== null && typeof got.envelope === 'object' && Array.isArray(got.envelope.read)) {
477
+ reads += 1
478
+ if (reads > 2 || dir === null) {
479
+ throw new LwsHostError('the engine asked for body files this host cannot read')
480
+ }
481
+ readStepBodies(engine, handle, got.envelope.read, dir)
482
+ got = engine.callBlobIO('lwsStep', [handle, JSON.stringify(record)], content)
483
+ }
484
+ return got
485
+ }
236
486
  let answered = callStep()
237
487
  let envelope = answered.envelope
488
+ while (envelope !== null && typeof envelope === 'object' &&
489
+ envelope.resolve !== undefined && envelope.resolve !== null) {
490
+ resolves += 1
491
+ if (resolves > MAX_RESOLVES) {
492
+ throw new LwsHostError('the engine asked for more host names than one request may resolve')
493
+ }
494
+ const host = String(envelope.resolve.host)
495
+ const addresses = await resolveHost(host)
496
+ record.resolved = [...(Array.isArray(record.resolved) ? record.resolved : []), { host, addresses }]
497
+ answered = callStep()
498
+ envelope = answered.envelope
499
+ }
238
500
  // The engine may answer "fetch this document first" (an issuer's
239
501
  // metadata or key set). The host fetches exactly what it is told,
240
502
  // hands the bytes back, and steps the request again.
@@ -258,10 +520,23 @@ export async function openLwsStorage (options = {}) {
258
520
  envelope = answered.envelope
259
521
  }
260
522
  const answer = responseOf(envelope, answered.bytes)
261
- if (dir !== null) persistIfChanged(engine, 'lwsSnapshot', handle, envelope, dir)
523
+ persist(envelope)
524
+ if (Array.isArray(envelope.deliveries)) {
525
+ // After this step's answer is ready; the engine's state already
526
+ // records them as pending.
527
+ for (const delivery of envelope.deliveries) setTimeout(() => deliver(delivery), 0)
528
+ }
529
+ if (typeof envelope.wake === 'number') scheduleFlush(envelope.wake)
262
530
  return answer
263
531
  },
532
+ /** Resolves when no delivery request is in flight. */
533
+ async settled () {
534
+ await new Promise((resolve) => setTimeout(resolve, 0))
535
+ while (inFlight.size > 0) await Promise.allSettled([...inFlight])
536
+ },
264
537
  close () {
538
+ closed = true
539
+ if (flushTimer !== null) clearTimeout(flushTimer)
265
540
  try { engine.call('lwsClose', [handle]) } catch (_error) { /* already gone */ }
266
541
  }
267
542
  }
@@ -359,6 +634,14 @@ export async function createLwsServer (options = {}) {
359
634
  }
360
635
  settings = { ...settings, baseIri: value }
361
636
  },
637
+ /** Pass what the socket bound (address, port, interface addresses),
638
+ * before the first request. Data for the engine's inbox check. */
639
+ setListen (value) {
640
+ if (opening !== null) {
641
+ throw new LwsHostError('the storage is already open; set listen first')
642
+ }
643
+ settings = { ...settings, listen: value }
644
+ },
362
645
  close () {
363
646
  return new Promise((resolve) => {
364
647
  server.close(() => {
@@ -406,6 +689,7 @@ export async function listen (options = {}) {
406
689
  const bound = created.server.address()
407
690
  const origin = `http://${host}:${bound.port}`
408
691
  if (typeof options.baseIri !== 'string') created.setBaseIri(`${origin}/`)
692
+ created.setListen({ address: bound.address, port: bound.port, addresses: await interfaceAddresses() })
409
693
  try {
410
694
  await created.storage()
411
695
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.11.0",
3
+ "version": "0.13.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",
@@ -69,6 +69,7 @@
69
69
  "./store-host": "./store-host/index.mjs",
70
70
  "./store-host/paths": "./store-host/paths.mjs",
71
71
  "./xmpp": "./xmpp/index.mjs",
72
+ "./xmpp/server": "./xmpp/server.mjs",
72
73
  "./store": {
73
74
  "default": "./bin/store.mjs"
74
75
  },
@@ -87,6 +88,12 @@
87
88
  "./lws": {
88
89
  "default": "./lws/server.mjs"
89
90
  },
91
+ "./lws/client": {
92
+ "default": "./lws/client.mjs"
93
+ },
94
+ "./authz": {
95
+ "default": "./authz/server.mjs"
96
+ },
90
97
  "./solid/server": {
91
98
  "default": "./solid/server/index.mjs"
92
99
  },
@@ -116,6 +123,7 @@
116
123
  "lib/",
117
124
  "bin/",
118
125
  "lws/",
126
+ "authz/",
119
127
  "solid/",
120
128
  "store-host/",
121
129
  "xmpp/",
@@ -135,6 +143,7 @@
135
143
  "build": "bash ../../formal/lean4/Wasm/build-wasm.sh",
136
144
  "test": "node --test 'test/*.test.js' 'test/*.test.mjs'",
137
145
  "test:speed": "node ../../tests/perf/npm_speed_gate.mjs",
146
+ "test:costs": "node ../../tests/perf/npm_costs.mjs",
138
147
  "prepublishOnly": "npm test"
139
148
  },
140
149
  "devDependencies": {
package/rdfjs.mjs CHANGED
@@ -15,6 +15,8 @@
15
15
  // ES module (0.9.0): the same file serves Node (index.mjs, and rdfjs.js for
16
16
  // require()) and the browser (browser.js), with no build step.
17
17
 
18
+ import { FactoidalError } from './lib/errors.mjs';
19
+
18
20
  // RDF/JS Data Model defaults of DataFactory.literal.
19
21
  const XSD_STRING = 'http://www.w3.org/2001/XMLSchema#string'; // purity-lint: allow RDF/JS DataFactory default
20
22
  const RDF_LANGSTRING =
@@ -330,10 +332,10 @@ function _setTextEngine(engine) { textEngine = engine; }
330
332
 
331
333
  function needEngine(who) {
332
334
  if (!textEngine) {
333
- throw new Error(
335
+ throw new FactoidalError(
334
336
  `${who}: N-Quads text is read and written by the engine, which is not ` +
335
337
  'loaded yet. Await any @factoidal/core call first (for example ' +
336
- '`await ready()`), or use parse() and Dataset.toString().');
338
+ '`await ready()`), or use parse() and Dataset.toString().', { code: 'op.unknown' });
337
339
  }
338
340
  return textEngine;
339
341
  }
@@ -56,7 +56,7 @@
56
56
  // stay distinct.
57
57
 
58
58
  import { loadEngine } from '../../bin/engine.mjs'
59
- import { readSnapshot, persistIfChanged } from '../../lws/persist.mjs'
59
+ import { readSnapshot, persistIfChanged, storageKeyText } from '../../lws/persist.mjs'
60
60
 
61
61
  /** The ops this host needs from the engine. */
62
62
  export const SOLID_SERVER_OPS = ['solidOpen', 'solidStep', 'solidClose', 'solidSnapshot',
@@ -169,6 +169,8 @@ function contentBytes (request) {
169
169
  * @param {string} [options.owner] the WebID the storage advertises
170
170
  * @param {string} [options.agent] the WebID of the requesting agent
171
171
  * @param {boolean} [options.realClock] stamp each request with this host's clock
172
+ * @param {string|null} [options.storageKey] the storage key text (64 hex digits or base64url);
173
+ * default the environment variable FACTOIDAL_LWS_KEY; `null` reads no key
172
174
  * @param {object} [options.engine] an engine handle to reuse
173
175
  * @returns {Promise<{step: Function, close: Function, handle: string,
174
176
  * engine: object}>}
@@ -186,6 +188,10 @@ export async function openSolidStorage (options = {}) {
186
188
  if (options.insecureNoAuth === true) config.insecureNoAuth = true
187
189
  if (typeof options.enforceWac === 'boolean') config.enforceWac = options.enforceWac
188
190
  if (typeof options.rootAcl === 'string') config.rootAcl = options.rootAcl
191
+ // Encryption at rest (issue 921), as the LWS host: the key text from
192
+ // `storageKey` or FACTOIDAL_LWS_KEY, passed unread; the engine decides.
193
+ const storageKey = storageKeyText(options.storageKey)
194
+ if (storageKey !== undefined) config.storageKey = storageKey
189
195
  // `dir` names the storage directory. The snapshot found there, if any,
190
196
  // is the storage to continue from; the engine checks it.
191
197
  const dir = typeof options.dir === 'string' ? options.dir : null
package/xmpp/README.md CHANGED
@@ -59,6 +59,85 @@ carries these ops at all, so a caller (or a test) can skip gracefully
59
59
  against an older committed bundle instead of getting a confusing
60
60
  "unknown op" error.
61
61
 
62
+ ## The server: `@factoidal/core/xmpp/server` and `factoidal xmpp-serve`
63
+
64
+ ```sh
65
+ mkdir -p state && printf 'juliet:r0m30\nromeo:juli3t\n' > state/accounts
66
+ npx factoidal xmpp-serve state --domain localhost # plaintext on 5222
67
+ npx factoidal xmpp-serve state --cert fullchain.pem --key privkey.pem # XEP-0368 direct TLS on 5223
68
+ ```
69
+
70
+ ```js
71
+ import { listen } from '@factoidal/core/xmpp/server'
72
+ const running = await listen({ dir: 'state', domain: 'localhost', port: 0 })
73
+ console.log(running.origin) // xmpp://127.0.0.1:<port>
74
+ await running.close()
75
+ ```
76
+
77
+ `server.mjs` is a socket, a UTF-8 decoder and a routing table. It runs
78
+ one engine session per connection and answers every byte through the
79
+ engine's `xmppSessionFeed` operation; `L4Factoidal.XMPP.Server.step`
80
+ (Lean, `formal/lean4/L4Factoidal/XMPP/Server.lean`) makes every
81
+ protocol decision and the host performs what comes back. It is the same
82
+ library the native carrier (`deploy/fly/xmpp/`, one `l4xmpp-serve`
83
+ process per connection behind socat) serves; the wasm session ops are
84
+ `formal/lean4/Wasm/Ops/XmppSession.lean`.
85
+
86
+ | op | arguments | answer |
87
+ |---|---|---|
88
+ | `xmppOpen` | `[configJson]` | `{ ok, handle, domain, accounts }` |
89
+ | `xmppSessionOpen` | `[server, optionsJson]` | `{ ok, handle }` — `streamId` and `nonce` are required and come from the host's random source |
90
+ | `xmppSessionFeed` | `[session, text]` | `{ ok, send: [...], route: [{to, text}], presence: [...], rostersSaved: [...], close, stage, bare, full, bufferedBytes }` |
91
+ | `xmppSessionDeliver` | `[session, text]` | `{ ok, delivered, send }` — `delivered:false` for an unbound session; nothing is lost |
92
+ | `xmppSessionClose` | `[session]` | `{ ok, send }` |
93
+ | `xmppRosterGet` / `xmppRosterSet` | `[server, bare]` / `[server, bare, encoded]` | the roster in the engine's own on-disk encoding |
94
+ | `xmppClose` | `[server]` | `{ ok, sessions }` |
95
+
96
+ The state directory is laid out as the native carrier's, so the two
97
+ hosts can share one: `DIR/accounts` (one `user:password` per line),
98
+ `DIR/roster/<bare jid>.tsv` (persisted on every `rostersSaved`, loaded
99
+ on start), and `DIR/spool/<bare jid>/<file>` — a stanza written there
100
+ by any process is delivered to that account's bound session and
101
+ unlinked. That last one is how a program that is not an XMPP client (a
102
+ web application, say) hands a user a message: write a file.
103
+
104
+ Roster snapshots are written in order through temporary-file replacement.
105
+ `await running.close()` waits for queued writes and rejects if persistence
106
+ failed. This protects against overlapping writes within one host; it does
107
+ not coordinate concurrent processes or promise recovery after a power loss.
108
+ The spool file is removed when the engine accepts delivery, before any
109
+ remote acknowledgement. Stanzas queued for offline accounts in this Node
110
+ host are held in memory and disappear on process exit.
111
+
112
+ `maxBuffered` (default 1 MiB) limits the engine's residual UTF-8 input after
113
+ each feed, reported by `bufferedBytes`. A decoder can separately hold up to
114
+ three bytes of an incomplete UTF-8 sequence. This is not a bound on complete
115
+ stanza size, output queues, offline queues, connection count or idle time.
116
+ The host requires this field; an older engine without it closes the affected
117
+ connection with an error. Rebuild the host and engine together.
118
+
119
+ What it speaks: RFC 6120 stream negotiation, stream errors, SASL PLAIN
120
+ and SCRAM-SHA-256, resource binding; RFC 6121 roster, presence
121
+ subscription and broadcast, message delivery with the server-stamped
122
+ `from`; XEP-0030 `disco#info`; XEP-0368 direct TLS with `--cert`/`--key`.
123
+ Not: STARTTLS, server-to-server federation, MUC/MIX/GC3 rooms, PRECIS.
124
+ Known limitation shared with the native harness: one SCRAM salt per
125
+ server (`saltB64` in the config), not one per account.
126
+
127
+ The shared engine currently reduces routed destinations to bare JIDs and
128
+ the Node host delivers to every bound resource. It does not implement full
129
+ resource-selection/priority semantics. Binding alone permits delivery; this
130
+ is not a complete presence-availability policy. The plaintext test listener
131
+ also offers SASL PLAIN and has no transport confidentiality. Supplying only
132
+ one of `cert`/`key` is rejected; supplying both selects direct TLS.
133
+
134
+ Tests: `node --test test/xmpp-serve.test.mjs` drives the host over real
135
+ TCP sockets, including an independent SCRAM-SHA-256 computed with
136
+ `node:crypto`; `tools/xmpp-interop.sh wasm` drives it with
137
+ `@xmpp/client`, a third-party implementation.
138
+ `node --test test/xmpp-host.test.mjs` separately checks IO cleanup and
139
+ persistence with a mock engine; passing it makes no protocol-conformance claim.
140
+
62
141
  ## What this does not cover yet, on purpose
63
142
 
64
143
  - **Stanza construction from JSON.** `parseStanza` has no inverse: building
@@ -66,10 +145,9 @@ against an older committed bundle instead of getting a confusing
66
145
  sibling-node XML constructor the Lean side doesn't have yet.
67
146
  - **GC3 itself.** Still an unfinished, early-stage upstream XSF spec —
68
147
  see the design record.
69
- - **SASL/SCRAM computation.** The message *format* exists on the Lean
70
- side (`L4Factoidal.XMPP.Sasl`); the actual hash/HMAC computation
71
- doesn't yet (no HACL* HMAC-SHA-256 binding — see the design record's
72
- crypto section).
148
+ - **SASL/SCRAM computation as a pure function.** The server ops above
149
+ do SCRAM-SHA-256 (via `L4Factoidal.XMPP.Scram` and the HACL* SHA-256
150
+ binding); there is no standalone pure op for it in this module.
73
151
  - **MUC/MIX interop modules.**
74
152
 
75
153
  ## Upstream limit found via this module's tests (2026-09-07), repaired 2026-10-10