@factoidal/core 0.12.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/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
- const callStep = () => engine.callBlobIO('lwsStep', [handle, JSON.stringify(record)], content)
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.12.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/",
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