@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/CHANGELOG.md +565 -0
- package/README.md +76 -4
- package/api.d.ts +2 -3
- package/api.mjs +2 -2
- package/authz/server.mjs +331 -0
- package/bin/factoidal.mjs +510 -8
- package/browser.d.ts +3 -2
- package/browser.js +1 -2
- package/fn.js +8 -12
- package/index.d.ts +100 -35
- package/index.mjs +1 -2
- 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 +96 -205
- package/lib/dataset.mjs +40 -45
- package/lib/delta-log-hosts.mjs +9 -8
- package/lib/errors.mjs +97 -0
- package/lib/lean-entry.mjs +15 -10
- package/lib/store.mjs +4 -5
- package/lib/version.mjs +1 -1
- package/lws/README.md +70 -3
- package/lws/client.mjs +319 -0
- package/lws/persist.mjs +253 -6
- package/lws/server.mjs +294 -10
- package/package.json +10 -1
- package/rdfjs.mjs +4 -2
- package/solid/server/index.mjs +7 -1
- package/xmpp/README.md +82 -4
- package/xmpp/server.mjs +549 -0
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
|
-
|
|
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
|
-
? { ...
|
|
234
|
-
:
|
|
235
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
}
|
package/solid/server/index.mjs
CHANGED
|
@@ -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
|
|
70
|
-
|
|
71
|
-
|
|
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
|