@factoidal/core 0.12.0 → 0.14.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 +198 -0
- package/README.md +60 -2
- package/authz/server.mjs +331 -0
- package/bin/factoidal.mjs +378 -12
- package/browser.d.ts +7 -0
- package/browser.js +8 -0
- package/index.d.ts +221 -8
- package/index.mjs +8 -0
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/api.mjs +57 -5
- package/lib/dataset.mjs +8 -0
- package/lib/evidence.mjs +84 -0
- package/lib/nodes.mjs +215 -0
- package/lib/sbm11-node.mjs +61 -0
- package/lib/sbm11.mjs +148 -0
- package/lib/version.mjs +1 -1
- package/lws/client.mjs +319 -0
- package/lws/persist.mjs +17 -1
- package/lws/server.mjs +25 -3
- package/package.json +9 -1
- package/sample-store.mjs +1 -1
- package/xmpp/README.md +82 -4
- package/xmpp/server.mjs +549 -0
package/lws/client.mjs
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// A `fetch` host for the Linked Web Storage 1.0 client.
|
|
2
|
+
// https://github.com/danbri/factoidal/issues/917
|
|
3
|
+
// Design record: docs/designissues/2026-10-22-lws-client.md
|
|
4
|
+
// Drafts: https://w3c.github.io/lws-protocol/lws10-core/ (and lws10-index,
|
|
5
|
+
// lws10-notifications-webhook)
|
|
6
|
+
//
|
|
7
|
+
// WHAT THIS FILE IS ALLOWED TO DO
|
|
8
|
+
// Call `lwsClientStart` with the caller's operation, then loop: when the
|
|
9
|
+
// engine's action is `fetch`, send exactly that request with the `fetch`
|
|
10
|
+
// it was given and pass the answer to `lwsClientStep`; when it is `done` or
|
|
11
|
+
// `fail`, return or throw. It is a socket and nothing else.
|
|
12
|
+
//
|
|
13
|
+
// Every decision is in formal/lean4/L4Factoidal/LWS/Client/ (iron rule 7
|
|
14
|
+
// of CLAUDE.md, anti-pattern 36): which request comes next, every header
|
|
15
|
+
// field, every URL, every media type, the authorization flow and its
|
|
16
|
+
// discovery checks, preconditions, retries, paging, and whether a
|
|
17
|
+
// notification's signature verifies. This file reads no header, composes no
|
|
18
|
+
// URL and chooses no media type. A reviewer who finds a header name, a
|
|
19
|
+
// status code, a media type or a link relation here has found a rule
|
|
20
|
+
// violation.
|
|
21
|
+
//
|
|
22
|
+
// THE WASM CONTRACT (docs/lws-solid-conformance.md, section "wasm ABI")
|
|
23
|
+
// lwsClientStart [opJson, sessionJson] -> { ok, state, action }
|
|
24
|
+
// lwsClientStep [stateJson, eventJson] -> { ok, state, action }
|
|
25
|
+
//
|
|
26
|
+
// action { fetch: { method, url, headers: [[name, value]], bodyBase64? } }
|
|
27
|
+
// | { done: RESULT, session } | { fail: { code, ... }, session }
|
|
28
|
+
// event { status, headers: [[name, value]], bodyBase64 } | { error }
|
|
29
|
+
//
|
|
30
|
+
// `state` is passed back unread. `session` (access tokens, the subject
|
|
31
|
+
// token) is kept here and given to the next operation's start, unread.
|
|
32
|
+
//
|
|
33
|
+
// `fetch` is any function with the WHATWG fetch signature: Node's, a
|
|
34
|
+
// browser's, or one that carries each request as a message over another
|
|
35
|
+
// transport (XMPP, a worker port). The client needs no global fetch.
|
|
36
|
+
|
|
37
|
+
import { loadEngine } from '../bin/engine.mjs'
|
|
38
|
+
import { FactoidalError } from '../lib/errors.mjs'
|
|
39
|
+
|
|
40
|
+
/** The ops this host needs from the engine. */
|
|
41
|
+
export const LWS_CLIENT_OPS = ['lwsClientStart', 'lwsClientStep']
|
|
42
|
+
|
|
43
|
+
/** The operation kinds of the ABI. Listed so a caller can be checked
|
|
44
|
+
* against the contract; this host attaches no meaning to any of them. */
|
|
45
|
+
export const LWS_CLIENT_KINDS = [
|
|
46
|
+
'discover', 'read', 'head', 'create', 'update', 'patch', 'delete', 'readLinkset',
|
|
47
|
+
'updateLinkset', 'move', 'list', 'typeIndex', 'search', 'subscribe', 'unsubscribe',
|
|
48
|
+
'requestAccess', 'grantAccess', 'getAccess', 'revokeAccess', 'verifyNotification'
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
/** The error an operation ends with. `reason` is the engine's code
|
|
52
|
+
* (`discoveryValidation`, `preconditionFailed`, ...); `detail` is the
|
|
53
|
+
* engine's whole `fail` document. */
|
|
54
|
+
export class LwsClientError extends FactoidalError {
|
|
55
|
+
constructor (fail, extra = {}) {
|
|
56
|
+
const reason = fail && typeof fail.code === 'string' ? fail.code : 'unknown'
|
|
57
|
+
super(`lws client: ${reason}`, { code: 'lws.client' })
|
|
58
|
+
this.name = 'LwsClientError'
|
|
59
|
+
this.reason = reason
|
|
60
|
+
this.detail = fail
|
|
61
|
+
this.unknownOp = extra.unknownOp === true
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isUnknownOp (error) {
|
|
66
|
+
const text = error && error.message ? String(error.message) : String(error)
|
|
67
|
+
return text.indexOf('unknown op') >= 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Bytes as base64 (RFC 4648 section 4): the transport encoding of the
|
|
71
|
+
* string ABI, not a reading of the bytes. */
|
|
72
|
+
function base64OfBytes (bytes) {
|
|
73
|
+
if (typeof Buffer !== 'undefined') {
|
|
74
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64')
|
|
75
|
+
}
|
|
76
|
+
let text = ''
|
|
77
|
+
for (let index = 0; index < bytes.length; index += 1) text += String.fromCharCode(bytes[index])
|
|
78
|
+
return btoa(text)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function bytesOfBase64 (text) {
|
|
82
|
+
if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(text, 'base64'))
|
|
83
|
+
const raw = atob(text)
|
|
84
|
+
const out = new Uint8Array(raw.length)
|
|
85
|
+
for (let index = 0; index < raw.length; index += 1) out[index] = raw.charCodeAt(index)
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A caller's body (string, Uint8Array, ArrayBuffer) as bytes. */
|
|
90
|
+
function bytesOf (body) {
|
|
91
|
+
if (body === undefined || body === null) return null
|
|
92
|
+
if (typeof body === 'string') return new TextEncoder().encode(body)
|
|
93
|
+
if (body instanceof Uint8Array) return body
|
|
94
|
+
if (body instanceof ArrayBuffer) return new Uint8Array(body)
|
|
95
|
+
throw new TypeError('a body is a string, a Uint8Array or an ArrayBuffer')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** An operation document: the caller's members, with a body carried as
|
|
99
|
+
* base64. */
|
|
100
|
+
function opDocument (op) {
|
|
101
|
+
const doc = { ...op }
|
|
102
|
+
if (doc.body !== undefined) {
|
|
103
|
+
const bytes = bytesOf(doc.body)
|
|
104
|
+
delete doc.body
|
|
105
|
+
if (bytes !== null) doc.bodyBase64 = base64OfBytes(bytes)
|
|
106
|
+
}
|
|
107
|
+
return doc
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether the loaded engine carries the client operations.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} engine the handle from `loadEngine()`
|
|
114
|
+
* @returns {Promise<{available: boolean, reason: string}>}
|
|
115
|
+
*/
|
|
116
|
+
export async function lwsClientOpsAvailable (engine) {
|
|
117
|
+
try {
|
|
118
|
+
engine.call('lwsClientStart', [JSON.stringify({ kind: 'head', url: 'http://localhost/' }), ''])
|
|
119
|
+
return { available: true, reason: 'lwsClientStart answered' }
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (isUnknownOp(error)) {
|
|
122
|
+
return {
|
|
123
|
+
available: false,
|
|
124
|
+
reason: 'the loaded l4factoidal WebAssembly module does not carry the LWS client ops ' +
|
|
125
|
+
'(rebuild formal/lean4/Wasm)'
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { available: true, reason: 'lwsClientStart answered with an engine error' }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* An LWS client bound to one engine and one `fetch`.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} [options]
|
|
136
|
+
* @param {object} [options.engine] an engine handle to reuse
|
|
137
|
+
* @param {Function} [options.fetch] the fetch to use (default `globalThis.fetch`)
|
|
138
|
+
* @param {string} [options.subjectToken] the authentication credential to
|
|
139
|
+
* exchange for access tokens (RFC 8693 subject_token)
|
|
140
|
+
* @param {string} [options.subjectTokenType] its token type URI
|
|
141
|
+
* @param {object} [options.selfIssued] `{ subject, keyId, jwk }`: a P-256 key
|
|
142
|
+
* to sign self-issued credentials with, instead of a subject token
|
|
143
|
+
* @param {object} [options.session] a session from an earlier client
|
|
144
|
+
* @param {number} [options.maxSteps] the loop cap of one operation
|
|
145
|
+
* @returns {Promise<object>} the client
|
|
146
|
+
*/
|
|
147
|
+
export async function createLwsClient (options = {}) {
|
|
148
|
+
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
149
|
+
const wire = typeof options.fetch === 'function' ? options.fetch : globalThis.fetch
|
|
150
|
+
if (typeof wire !== 'function') throw new TypeError('no fetch is available in this runtime')
|
|
151
|
+
const maxSteps = typeof options.maxSteps === 'number' ? options.maxSteps : 1024
|
|
152
|
+
let session = options.session !== undefined && options.session !== null
|
|
153
|
+
? { ...options.session }
|
|
154
|
+
: {}
|
|
155
|
+
if (typeof options.subjectToken === 'string') session.subjectToken = options.subjectToken
|
|
156
|
+
if (typeof options.subjectTokenType === 'string') session.subjectTokenType = options.subjectTokenType
|
|
157
|
+
// `{ subject, keyId, jwk }`: a P-256 key the engine signs self-issued
|
|
158
|
+
// credentials with (lws10-authn-ssi-cid), passed through unread.
|
|
159
|
+
if (options.selfIssued !== undefined) session.selfIssued = options.selfIssued
|
|
160
|
+
|
|
161
|
+
function call (op, args) {
|
|
162
|
+
try {
|
|
163
|
+
return engine.call(op, args)
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (isUnknownOp(error)) {
|
|
166
|
+
throw new LwsClientError({ code: 'unknownOp', op }, { unknownOp: true })
|
|
167
|
+
}
|
|
168
|
+
throw error
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Send one request exactly as the engine wrote it; the answer as an
|
|
173
|
+
* event document. */
|
|
174
|
+
async function exchange (request) {
|
|
175
|
+
const init = {
|
|
176
|
+
method: request.method,
|
|
177
|
+
headers: Array.isArray(request.headers) ? request.headers : [],
|
|
178
|
+
redirect: 'manual'
|
|
179
|
+
}
|
|
180
|
+
if (typeof request.bodyBase64 === 'string') init.body = bytesOfBase64(request.bodyBase64)
|
|
181
|
+
let reply
|
|
182
|
+
try {
|
|
183
|
+
reply = await wire(request.url, init)
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return { error: error && error.message ? String(error.message) : String(error) }
|
|
186
|
+
}
|
|
187
|
+
const headers = []
|
|
188
|
+
reply.headers.forEach((value, name) => { headers.push([name, value]) })
|
|
189
|
+
const bytes = new Uint8Array(await reply.arrayBuffer())
|
|
190
|
+
// The host's clock and random source, which the engine cannot reach: the
|
|
191
|
+
// iat, exp, nonce and jti of a self-issued credential.
|
|
192
|
+
const random = new Uint8Array(48)
|
|
193
|
+
globalThis.crypto.getRandomValues(random)
|
|
194
|
+
return {
|
|
195
|
+
status: reply.status,
|
|
196
|
+
headers,
|
|
197
|
+
bodyBase64: base64OfBytes(bytes),
|
|
198
|
+
now: Math.floor(Date.now() / 1000),
|
|
199
|
+
randomBase64: base64OfBytes(random)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Run one operation to its end.
|
|
205
|
+
*
|
|
206
|
+
* @param {object} op `{ kind, url, ... }`; `body` may be a string or bytes
|
|
207
|
+
* @returns {Promise<object>} the engine's `done` document
|
|
208
|
+
*/
|
|
209
|
+
async function run (op) {
|
|
210
|
+
let envelope = call('lwsClientStart', [JSON.stringify(opDocument(op)), JSON.stringify(session)])
|
|
211
|
+
for (let step = 0; step < maxSteps; step += 1) {
|
|
212
|
+
const action = envelope.action || {}
|
|
213
|
+
if (action.fetch !== undefined) {
|
|
214
|
+
const event = await exchange(action.fetch)
|
|
215
|
+
envelope = call('lwsClientStep', [JSON.stringify(envelope.state), JSON.stringify(event)])
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
if (action.session !== undefined) session = action.session
|
|
219
|
+
if (action.done !== undefined) return action.done
|
|
220
|
+
throw new LwsClientError(action.fail)
|
|
221
|
+
}
|
|
222
|
+
throw new LwsClientError({ code: 'hostStepLimit', steps: maxSteps })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const with_ = (kind) => (url, args = {}) => run({ ...args, kind, url })
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
engine,
|
|
229
|
+
run,
|
|
230
|
+
/** The session (access tokens and subject token), for a later client. */
|
|
231
|
+
session: () => ({ ...session }),
|
|
232
|
+
discover: with_('discover'),
|
|
233
|
+
read: with_('read'),
|
|
234
|
+
head: with_('head'),
|
|
235
|
+
/** `create(containerUrl, { body, mediaType, slug, container, types, links })` */
|
|
236
|
+
create: with_('create'),
|
|
237
|
+
/** `update(url, { body, mediaType, precondition, tag, retry412, links })` */
|
|
238
|
+
update: with_('update'),
|
|
239
|
+
patch: with_('patch'),
|
|
240
|
+
/** `delete(url, { recursive, precondition, tag })` */
|
|
241
|
+
delete: with_('delete'),
|
|
242
|
+
readLinkset: with_('readLinkset'),
|
|
243
|
+
updateLinkset: with_('updateLinkset'),
|
|
244
|
+
/** `move(url, { destination })` */
|
|
245
|
+
move: with_('move'),
|
|
246
|
+
list: with_('list'),
|
|
247
|
+
typeIndex: with_('typeIndex'),
|
|
248
|
+
/** `search(anyUrlInTheStorage, { query })` */
|
|
249
|
+
search: with_('search'),
|
|
250
|
+
subscribe: with_('subscribe'),
|
|
251
|
+
unsubscribe: with_('unsubscribe'),
|
|
252
|
+
requestAccess: with_('requestAccess'),
|
|
253
|
+
grantAccess: with_('grantAccess'),
|
|
254
|
+
getAccess: with_('getAccess'),
|
|
255
|
+
revokeAccess: with_('revokeAccess'),
|
|
256
|
+
/** `verifyNotification(baseUrl, { requestTarget, method, headers, body })`:
|
|
257
|
+
* the engine's verdict `{ accept, status, reason? }`. */
|
|
258
|
+
verifyNotification: with_('verifyNotification')
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* An HTTP receiver for webhook notifications. For each request it asks the
|
|
264
|
+
* engine (`verifyNotification`) and answers with the status of the
|
|
265
|
+
* verdict; an accepted notification goes to `onNotification`. The receiver
|
|
266
|
+
* reads no header and decides nothing.
|
|
267
|
+
*
|
|
268
|
+
* @param {object} options
|
|
269
|
+
* @param {object} options.client a client from `createLwsClient`
|
|
270
|
+
* @param {Function} [options.onNotification] `(verdict, bodyBytes, request)`
|
|
271
|
+
* @param {number} [options.port] default 0 (an ephemeral port)
|
|
272
|
+
* @param {string} [options.host] default 127.0.0.1
|
|
273
|
+
* @returns {Promise<{ url: string, close: Function, received: Function }>}
|
|
274
|
+
* `url` is the receiver's base URL; any path under it receives
|
|
275
|
+
*/
|
|
276
|
+
export async function createNotificationReceiver (options) {
|
|
277
|
+
const { createServer } = await import('node:http')
|
|
278
|
+
const client = options.client
|
|
279
|
+
const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
|
|
280
|
+
let base = ''
|
|
281
|
+
const verdicts = []
|
|
282
|
+
const server = createServer((request, response) => {
|
|
283
|
+
const chunks = []
|
|
284
|
+
request.on('data', (chunk) => chunks.push(chunk))
|
|
285
|
+
request.on('end', async () => {
|
|
286
|
+
const body = new Uint8Array(Buffer.concat(chunks))
|
|
287
|
+
const headers = []
|
|
288
|
+
for (let index = 0; index + 1 < request.rawHeaders.length; index += 2) {
|
|
289
|
+
headers.push([request.rawHeaders[index], request.rawHeaders[index + 1]])
|
|
290
|
+
}
|
|
291
|
+
let verdict
|
|
292
|
+
try {
|
|
293
|
+
verdict = await client.verifyNotification(base, {
|
|
294
|
+
requestTarget: request.url, method: request.method, headers, body
|
|
295
|
+
})
|
|
296
|
+
} catch (error) {
|
|
297
|
+
response.statusCode = 500
|
|
298
|
+
response.end()
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
verdicts.push(verdict)
|
|
302
|
+
if (verdict.accept === true && typeof options.onNotification === 'function') {
|
|
303
|
+
try { options.onNotification(verdict, body, { target: request.url, headers }) } catch (_e) { /* the caller's */ }
|
|
304
|
+
}
|
|
305
|
+
response.statusCode = verdict.status
|
|
306
|
+
response.end()
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
await new Promise((resolve, reject) => {
|
|
310
|
+
server.once('error', reject)
|
|
311
|
+
server.listen(typeof options.port === 'number' ? options.port : 0, host, resolve)
|
|
312
|
+
})
|
|
313
|
+
base = `http://${host}:${server.address().port}/`
|
|
314
|
+
return {
|
|
315
|
+
url: base,
|
|
316
|
+
received: () => verdicts.slice(),
|
|
317
|
+
close: () => new Promise((resolve) => server.close(resolve))
|
|
318
|
+
}
|
|
319
|
+
}
|
package/lws/persist.mjs
CHANGED
|
@@ -155,7 +155,9 @@ export function persistIfChanged (engine, snapshotOp, handle, envelope, dir) {
|
|
|
155
155
|
// part of the file only performs what the engine names:
|
|
156
156
|
//
|
|
157
157
|
// * reads: the engine names files (paths relative to DIR); each is read
|
|
158
|
-
// whole, or reported absent
|
|
158
|
+
// whole, or reported absent. At start (`lwsLoad`) and before a step that
|
|
159
|
+
// needs the bytes of a resource (a `read` answer of `lwsStep`, given to
|
|
160
|
+
// `lwsRead`, stage 2 of issue 918);
|
|
159
161
|
// * a plan of file operations, in order, with their bytes in one region:
|
|
160
162
|
// write {path, temp, offset, length} parent directory, temp file,
|
|
161
163
|
// fsync, rename, fsync directory
|
|
@@ -297,6 +299,20 @@ export function loadFiles (engine, handle, opened, dir) {
|
|
|
297
299
|
throw new Error('lwsLoad asked for more reads than one start may make')
|
|
298
300
|
}
|
|
299
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Before one step of a storage kept in files (stage 2): read the body files
|
|
304
|
+
* the engine named in a `read` answer and give them to `lwsRead`. The engine
|
|
305
|
+
* checks each one (seal, length, digests); a refusal is thrown as its error.
|
|
306
|
+
*/
|
|
307
|
+
export function readStepBodies (engine, handle, names, dir) {
|
|
308
|
+
const { files, region } = readNamedFiles(dir, Array.isArray(names) ? names : [])
|
|
309
|
+
const answered = engine.callBlobIO('lwsRead', [handle, JSON.stringify({ files })], region)
|
|
310
|
+
const envelope = answered.envelope
|
|
311
|
+
if (envelope === null || typeof envelope !== 'object' || envelope.ok !== true) {
|
|
312
|
+
throw new Error(`lwsRead: ${envelope !== null && typeof envelope === 'object' ? envelope.error : 'no answer'}`)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
300
316
|
/**
|
|
301
317
|
* After one step of a storage kept in files: when the engine says the
|
|
302
318
|
* storage changed, ask it for the write plan (`lwsPersist`) and perform it.
|
package/lws/server.mjs
CHANGED
|
@@ -84,11 +84,11 @@
|
|
|
84
84
|
// produced and this code keeps working unchanged.
|
|
85
85
|
|
|
86
86
|
import { loadEngine } from '../bin/engine.mjs'
|
|
87
|
-
import { readSnapshot, persistIfChanged, storageKeyText, storageModeText, loadFiles, persistFilesIfChanged } from './persist.mjs'
|
|
87
|
+
import { readSnapshot, persistIfChanged, storageKeyText, storageModeText, loadFiles, persistFilesIfChanged, readStepBodies } from './persist.mjs'
|
|
88
88
|
|
|
89
89
|
/** The ops this host needs from the engine. */
|
|
90
90
|
export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched', 'lwsDelivered',
|
|
91
|
-
'lwsLoad', 'lwsPersist']
|
|
91
|
+
'lwsLoad', 'lwsPersist', 'lwsRead']
|
|
92
92
|
|
|
93
93
|
/** How long this host waits for the answer to one delivery request before
|
|
94
94
|
* it reports "no answer" (status 0) to the engine. */
|
|
@@ -252,6 +252,8 @@ function contentBytes (request) {
|
|
|
252
252
|
* default the environment variable FACTOIDAL_LWS_KEY; `null` reads no key
|
|
253
253
|
* @param {string} [options.storage] where DIR keeps the storage: "snapshot" (default, one
|
|
254
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
|
|
255
257
|
* @param {object} [options.engine] an engine handle to reuse
|
|
256
258
|
* @returns {Promise<{step: Function, close: Function, handle: string, engine: object}>}
|
|
257
259
|
*/
|
|
@@ -311,6 +313,10 @@ export async function openLwsStorage (options = {}) {
|
|
|
311
313
|
if (typeof options.deliveryWindow === 'number') config.deliveryWindow = options.deliveryWindow
|
|
312
314
|
if (typeof options.redirectsMax === 'number') config.redirectsMax = options.redirectsMax
|
|
313
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
|
|
314
320
|
// What this host bound: data for the engine's inbox check (issue 915).
|
|
315
321
|
if (options.listen !== undefined && options.listen !== null) config.listen = options.listen
|
|
316
322
|
config.entropy = entropyBase64()
|
|
@@ -460,7 +466,23 @@ export async function openLwsStorage (options = {}) {
|
|
|
460
466
|
// The engine may ask for the addresses of a host name first
|
|
461
467
|
// (`resolve`); this host resolves it and steps the request again.
|
|
462
468
|
let resolves = 0
|
|
463
|
-
|
|
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
|
+
}
|
|
464
486
|
let answered = callStep()
|
|
465
487
|
let envelope = answered.envelope
|
|
466
488
|
while (envelope !== null && typeof envelope === 'object' &&
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@factoidal/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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/",
|
package/sample-store.mjs
CHANGED
|
@@ -27,7 +27,7 @@ ex:c3 a skos:Concept ; skos:inScheme ex:scheme ; skos:prefLabel "SECAM"@en ; sko
|
|
|
27
27
|
export const sampleStoreFacts = Object.freeze({
|
|
28
28
|
triples: 15,
|
|
29
29
|
source: 'SAMPLE_TURTLE in sample-store.mjs (written for this package)',
|
|
30
|
-
layout: '
|
|
30
|
+
layout: 'ibk5'
|
|
31
31
|
})
|
|
32
32
|
|
|
33
33
|
/**
|
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
|