@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.
@@ -0,0 +1,549 @@
1
+ // factoidal/xmpp/server.mjs — a Node host for the Lean XMPP server
2
+ // (`formal/lean4/L4Factoidal/XMPP/`), through the wasm session ops of
3
+ // `formal/lean4/Wasm/Ops/XmppSession.lean`.
4
+ //
5
+ // This file is a socket, a UTF-8 decoder and a routing table. Every
6
+ // protocol decision — what answers a stream header, whether a password
7
+ // is right, where a stanza goes, what a roster looks like as bytes —
8
+ // is made by `L4Factoidal.XMPP.Server.step` inside the engine and
9
+ // reaches this file as the `send`/`route`/`presence`/`close` members of
10
+ // one `xmppSessionFeed` answer. Iron rule 7 of `CLAUDE.md`: no protocol
11
+ // logic in a host. What this host does instead, and the native harness
12
+ // (`formal/lean4/Harness/XmppServe.lean`) does with files:
13
+ //
14
+ // * one engine session per TCP (or direct-TLS, XEP-0368) connection;
15
+ // * `route` → find the sessions bound to that bare JID and call
16
+ // `xmppSessionDeliver` on each; none bound → hold the stanza in
17
+ // memory until one binds (`xmppSessionDeliver` itself refuses an
18
+ // unbound session, so nothing is destroyed on the way);
19
+ // * `rostersSaved` → fetch the roster bytes with `xmppRosterGet` and
20
+ // write them under DIR/roster/, unread, so a restart gets them back
21
+ // through `xmppRosterSet`;
22
+ // * DIR/spool/<bare jid>/<file> → the harness's mailbox layout, polled
23
+ // for bound sessions and delivered, so a process that is not an XMPP
24
+ // client (a web application, say) can hand a stanza to a user by
25
+ // writing a file, exactly as it can with the native carrier.
26
+ //
27
+ // The wasm contract (`xmppOpen`, `xmppSessionOpen`, `xmppSessionFeed`,
28
+ // `xmppSessionDeliver`, `xmppSessionClose`, `xmppRosterGet`,
29
+ // `xmppRosterSet`, `xmppClose`) is stated in the Lean module's header.
30
+ //
31
+ // Not implemented, said here rather than left to be discovered: STARTTLS
32
+ // (RFC 6120 section 5 — give `cert` and `key` for direct TLS on 5223
33
+ // instead), server-to-server federation, and any limit on how many bytes
34
+ // a client may send without completing a unit beyond `maxBuffered`,
35
+ // which is a transport limit this host applies by closing the socket.
36
+
37
+ import { randomBytes } from 'node:crypto'
38
+ import { StringDecoder } from 'node:string_decoder'
39
+ import { mkdir, readFile, writeFile, readdir, unlink, rename } from 'node:fs/promises'
40
+ import { join } from 'node:path'
41
+ import { loadEngine } from '../bin/engine.mjs'
42
+
43
+ /** The ops this host needs from the engine. */
44
+ export const XMPP_SERVER_OPS = [
45
+ 'xmppOpen', 'xmppClose',
46
+ 'xmppSessionOpen', 'xmppSessionFeed', 'xmppSessionDeliver', 'xmppSessionClose',
47
+ 'xmppRosterGet', 'xmppRosterSet'
48
+ ]
49
+
50
+ /** An error raised by this host, as opposed to one the engine reported.
51
+ * `unknownOp` is set when the loaded WebAssembly module does not carry
52
+ * the operation at all, which is what a test skips on and what the CLI
53
+ * reports as "not yet wired". */
54
+ export class XmppHostError extends Error {
55
+ constructor (message, detail = {}) {
56
+ super(message)
57
+ this.name = 'XmppHostError'
58
+ this.unknownOp = detail.unknownOp === true
59
+ }
60
+ }
61
+
62
+ function isUnknownOp (error) {
63
+ const text = error && error.message ? String(error.message) : String(error)
64
+ return text.indexOf('unknown op') >= 0
65
+ }
66
+
67
+ /**
68
+ * Whether the loaded engine carries the XMPP server operations.
69
+ *
70
+ * @param {object} [options]
71
+ * @param {object} [options.engine] an engine handle to reuse
72
+ * @returns {Promise<{available: boolean, missing: string[]}>}
73
+ */
74
+ export async function xmppServerOpsAvailable (options = {}) {
75
+ const engine = options.engine !== undefined ? options.engine : await loadEngine()
76
+ let names
77
+ try {
78
+ names = engine.call('ops', []).ops
79
+ } catch (_error) {
80
+ return { available: false, missing: XMPP_SERVER_OPS.slice() }
81
+ }
82
+ const missing = XMPP_SERVER_OPS.filter((op) => !names.includes(op))
83
+ return { available: missing.length === 0, missing }
84
+ }
85
+
86
+ /** A bare JID as one path segment: the harness's `pathSafe`
87
+ * (`Harness/XmppServe.lean`), so the two hosts share a state directory.
88
+ * Every byte that is not a letter, a digit, `.`, `-` or `_` is written
89
+ * as `%` and two lowercase hex digits. */
90
+ export function pathSafe (s) {
91
+ let out = ''
92
+ for (const byte of Buffer.from(s, 'utf8')) {
93
+ const ch = String.fromCharCode(byte)
94
+ if (byte < 0x80 && /[A-Za-z0-9._-]/.test(ch)) out += ch
95
+ else out += '%' + byte.toString(16).padStart(2, '0')
96
+ }
97
+ return out
98
+ }
99
+
100
+ /** Unpredictable values for the stream id and the SCRAM server nonce
101
+ * (RFC 6120 section 4.7.3, RFC 5802 section 5.1). The engine has no
102
+ * random source and asks the host for both. */
103
+ function randomToken (bytes = 18) {
104
+ return randomBytes(bytes).toString('base64url')
105
+ }
106
+
107
+ /**
108
+ * Open one XMPP server on the engine and return the handle plus the
109
+ * calls a host makes against it.
110
+ *
111
+ * @param {object} options
112
+ * @param {string} [options.domain] the domain the server is authoritative for (default `localhost`)
113
+ * @param {Array<{username: string, password: string}>} [options.accounts]
114
+ * @param {string} [options.accountsText] `user:password` lines, the harness's accounts-file format
115
+ * @param {string} [options.saltB64] the SCRAM salt, base64
116
+ * @param {number} [options.iterationCount] the SCRAM iteration count
117
+ * @param {boolean} [options.tlsCarrier] whether the carrier terminates TLS
118
+ * @param {object} [options.engine] an engine handle to reuse
119
+ * @returns {Promise<{engine: object, handle: string, domain: string,
120
+ * openSession: Function, roster: Function, setRoster: Function, close: Function}>}
121
+ */
122
+ export async function openXmppServer (options = {}) {
123
+ const engine = options.engine !== undefined ? options.engine : await loadEngine()
124
+ const config = {}
125
+ if (typeof options.domain === 'string') config.domain = options.domain
126
+ if (Array.isArray(options.accounts)) config.accounts = options.accounts
127
+ if (typeof options.accountsText === 'string') config.accountsText = options.accountsText
128
+ if (typeof options.saltB64 === 'string') config.saltB64 = options.saltB64
129
+ if (typeof options.iterationCount === 'number') config.iterationCount = options.iterationCount
130
+ if (typeof options.tlsCarrier === 'boolean') config.tlsCarrier = options.tlsCarrier
131
+ let opened
132
+ try {
133
+ opened = engine.call('xmppOpen', [JSON.stringify(config)])
134
+ } catch (error) {
135
+ if (isUnknownOp(error)) {
136
+ throw new XmppHostError(
137
+ 'the loaded l4factoidal WebAssembly module does not carry xmppOpen',
138
+ { unknownOp: true })
139
+ }
140
+ throw error
141
+ }
142
+ if (typeof opened.handle !== 'string' || opened.handle.length === 0) {
143
+ throw new XmppHostError('xmppOpen answered with no handle')
144
+ }
145
+ const handle = opened.handle
146
+ return {
147
+ engine,
148
+ handle,
149
+ domain: typeof opened.domain === 'string' ? opened.domain : (config.domain || 'localhost'),
150
+ accounts: typeof opened.accounts === 'number' ? opened.accounts : undefined,
151
+ /**
152
+ * One client connection.
153
+ * @param {{streamId?: string, nonce?: string, tlsCarrier?: boolean, maxUnits?: number}} [sessionOptions]
154
+ */
155
+ openSession (sessionOptions = {}) {
156
+ const record = {
157
+ streamId: typeof sessionOptions.streamId === 'string' ? sessionOptions.streamId : randomToken(),
158
+ nonce: typeof sessionOptions.nonce === 'string' ? sessionOptions.nonce : randomToken()
159
+ }
160
+ if (typeof sessionOptions.tlsCarrier === 'boolean') record.tlsCarrier = sessionOptions.tlsCarrier
161
+ if (typeof sessionOptions.maxUnits === 'number') record.maxUnits = sessionOptions.maxUnits
162
+ const answer = engine.call('xmppSessionOpen', [handle, JSON.stringify(record)])
163
+ if (typeof answer.handle !== 'string') throw new XmppHostError('xmppSessionOpen answered with no handle')
164
+ const session = answer.handle
165
+ let open = true
166
+ return {
167
+ handle: session,
168
+ /** Feed received text; returns the engine's answer document. */
169
+ feed (text) {
170
+ return engine.call('xmppSessionFeed', [session, text])
171
+ },
172
+ /** Hand this session a stanza another session routed to it. */
173
+ deliver (text) {
174
+ return engine.call('xmppSessionDeliver', [session, text])
175
+ },
176
+ /** End of input: returns `{send: string[]}` and releases the handle. */
177
+ close () {
178
+ if (!open) return { send: [] }
179
+ open = false
180
+ try {
181
+ return engine.call('xmppSessionClose', [session])
182
+ } catch (_error) {
183
+ return { send: [] }
184
+ }
185
+ }
186
+ }
187
+ },
188
+ /** The roster of a bare JID: `{encoded, items}`. */
189
+ roster (bare) {
190
+ return engine.call('xmppRosterGet', [handle, bare])
191
+ },
192
+ /** Load a roster the host persisted (the `encoded` of `roster()`). */
193
+ setRoster (bare, encoded) {
194
+ engine.call('xmppRosterSet', [handle, bare, encoded])
195
+ },
196
+ close () {
197
+ try { engine.call('xmppClose', [handle]) } catch (_error) { /* already gone */ }
198
+ }
199
+ }
200
+ }
201
+
202
+ /** Load `DIR/accounts` (one `user:password` per line) if it exists. */
203
+ async function readAccountsFile (path) {
204
+ try {
205
+ return await readFile(path, 'utf8')
206
+ } catch (error) {
207
+ if (error && error.code === 'ENOENT') return null
208
+ throw error
209
+ }
210
+ }
211
+
212
+ /**
213
+ * A `net.Server` (or `tls.Server`, when `cert` and `key` are given —
214
+ * XEP-0368 direct TLS) that runs one engine session per connection.
215
+ *
216
+ * The server is returned unlistened: the caller chooses the port.
217
+ *
218
+ * @param {object} options as `openXmppServer`, plus:
219
+ * @param {string} [options.dir] state directory: `accounts` is read from it when
220
+ * no accounts are given; rosters are persisted under `roster/`; `spool/` is
221
+ * polled for stanzas written by other processes
222
+ * @param {string|Buffer} [options.cert] PEM certificate chain for direct TLS
223
+ * @param {string|Buffer} [options.key] PEM private key for direct TLS
224
+ * @param {number} [options.pollMs] spool poll interval (default 250; 0 disables)
225
+ * @param {number} [options.maxBuffered] residual UTF-8 bytes held by the engine
226
+ * after a feed before the socket is closed (default 1 MiB); the decoder
227
+ * may separately hold up to three bytes of an incomplete UTF-8 sequence
228
+ * @param {Function} [options.onError]
229
+ * @param {Function} [options.onLog]
230
+ * @returns {Promise<{server: object, xmpp: object, close: Function, sessions: Function}>}
231
+ */
232
+ export async function createXmppServer (options = {}) {
233
+ const onError = typeof options.onError === 'function'
234
+ ? options.onError
235
+ : (error) => { console.error(`xmpp-serve: ${error.message}`) }
236
+ const onLog = typeof options.onLog === 'function' ? options.onLog : () => {}
237
+ const dir = typeof options.dir === 'string' ? options.dir : null
238
+ const pollMs = typeof options.pollMs === 'number' ? options.pollMs : 250
239
+ const maxBuffered = typeof options.maxBuffered === 'number' ? options.maxBuffered : 1024 * 1024
240
+ if (!Number.isSafeInteger(maxBuffered) || maxBuffered < 0) {
241
+ throw new XmppHostError('maxBuffered must be a nonnegative safe integer')
242
+ }
243
+ if ((options.cert !== undefined) !== (options.key !== undefined)) {
244
+ throw new XmppHostError('direct TLS requires both cert and key')
245
+ }
246
+
247
+ const serverOptions = { ...options }
248
+ if (dir !== null && !Array.isArray(options.accounts) && typeof options.accountsText !== 'string') {
249
+ const text = await readAccountsFile(join(dir, 'accounts'))
250
+ if (text !== null) serverOptions.accountsText = text
251
+ }
252
+ const useTls = options.cert !== undefined && options.key !== undefined
253
+ if (typeof serverOptions.tlsCarrier !== 'boolean') serverOptions.tlsCarrier = useTls
254
+ const xmpp = await openXmppServer(serverOptions)
255
+
256
+ // Rosters the previous run persisted.
257
+ try {
258
+ if (dir !== null) {
259
+ await mkdir(join(dir, 'roster'), { recursive: true })
260
+ await mkdir(join(dir, 'spool'), { recursive: true })
261
+ for (const name of await readdir(join(dir, 'roster'))) {
262
+ if (!name.endsWith('.tsv')) continue
263
+ try {
264
+ const bare = decodeURIComponent(name.slice(0, -4))
265
+ xmpp.setRoster(bare, await readFile(join(dir, 'roster', name), 'utf8'))
266
+ } catch (error) {
267
+ onError(new XmppHostError(`roster ${name}: ${error.message}`))
268
+ }
269
+ }
270
+ }
271
+ } catch (error) {
272
+ xmpp.close()
273
+ throw error
274
+ }
275
+
276
+ /** Every live connection: socket, engine session, and the last `bare`/`full` the engine reported. */
277
+ const connections = new Set()
278
+ // Raw transports also include TLS peers that have not reached the secure
279
+ // connection callback and therefore have no engine session yet.
280
+ const transports = new Set()
281
+ /** bare JID → stanzas routed to it while no session was bound. */
282
+ const pending = new Map()
283
+
284
+ function boundTo (bare) {
285
+ const found = []
286
+ for (const c of connections) if (c.full !== null && c.bare === bare) found.push(c)
287
+ return found
288
+ }
289
+
290
+ function writeAll (c, lines) {
291
+ for (const line of lines) if (!c.socket.destroyed) c.socket.write(line)
292
+ }
293
+
294
+ /** Deliver one stanza to every session bound to `bare`, or hold it. */
295
+ function routeStanza (bare, text) {
296
+ const targets = boundTo(bare)
297
+ if (targets.length === 0) {
298
+ if (!pending.has(bare)) pending.set(bare, [])
299
+ pending.get(bare).push(text)
300
+ return
301
+ }
302
+ for (const t of targets) {
303
+ let answer
304
+ try { answer = t.session.deliver(text) } catch (error) { onError(error); continue }
305
+ if (answer.delivered === true) writeAll(t, answer.send)
306
+ else {
307
+ if (!pending.has(bare)) pending.set(bare, [])
308
+ pending.get(bare).push(text)
309
+ }
310
+ }
311
+ }
312
+
313
+ /** A session just bound: hand it what was waiting. */
314
+ function flushPending (c) {
315
+ const queue = pending.get(c.bare)
316
+ if (!queue || queue.length === 0) return
317
+ pending.delete(c.bare)
318
+ for (const text of queue) routeStanza(c.bare, text)
319
+ }
320
+
321
+ let rosterWrites = Promise.resolve()
322
+ let persistenceError = null
323
+ function persistRosters (bares) {
324
+ if (dir === null) return
325
+ for (const bare of bares) {
326
+ try {
327
+ // Capture the engine's bytes at this feed, before another feed can
328
+ // change the roster. Serialize writes; replace each file atomically.
329
+ const { encoded } = xmpp.roster(bare)
330
+ rosterWrites = rosterWrites.then(async () => {
331
+ const path = join(dir, 'roster', pathSafe(bare) + '.tsv')
332
+ const temporary = path + '.' + randomToken() + '.tmp'
333
+ try {
334
+ await writeFile(temporary, encoded, { flag: 'wx' })
335
+ await rename(temporary, path)
336
+ } finally {
337
+ await unlink(temporary).catch(() => {})
338
+ }
339
+ }).catch(error => {
340
+ persistenceError = new XmppHostError(`persisting roster of ${bare}: ${error.message}`)
341
+ onError(persistenceError)
342
+ })
343
+ } catch (error) {
344
+ persistenceError = new XmppHostError(`reading roster of ${bare}: ${error.message}`)
345
+ onError(persistenceError)
346
+ }
347
+ }
348
+ }
349
+
350
+ /** Perform one feed answer. */
351
+ function perform (c, answer) {
352
+ writeAll(c, answer.send)
353
+ const wasBound = c.full !== null
354
+ c.bare = typeof answer.bare === 'string' ? answer.bare : null
355
+ c.full = typeof answer.full === 'string' ? answer.full : null
356
+ for (const r of answer.route) routeStanza(r.to, r.text)
357
+ if (Array.isArray(answer.rostersSaved) && answer.rostersSaved.length > 0) {
358
+ persistRosters(answer.rostersSaved)
359
+ }
360
+ if (!wasBound && c.full !== null) flushPending(c)
361
+ if (answer.close === true) {
362
+ c.closing = true
363
+ c.socket.end()
364
+ }
365
+ }
366
+
367
+ function endSession (c) {
368
+ if (!connections.has(c)) return
369
+ connections.delete(c)
370
+ let answer
371
+ try { answer = c.session.close() } catch (error) { onError(error); answer = { send: [] } }
372
+ if (!c.closing && !c.socket.destroyed) {
373
+ writeAll(c, answer.send)
374
+ c.socket.end()
375
+ }
376
+ }
377
+
378
+ function onConnection (socket) {
379
+ let session
380
+ try {
381
+ session = xmpp.openSession({ tlsCarrier: useTls })
382
+ } catch (error) {
383
+ onError(error)
384
+ socket.destroy()
385
+ return
386
+ }
387
+ const c = {
388
+ socket,
389
+ session,
390
+ bare: null,
391
+ full: null,
392
+ closing: false,
393
+ decoder: new StringDecoder('utf8')
394
+ }
395
+ connections.add(c)
396
+ onLog(`connection from ${socket.remoteAddress}:${socket.remotePort}`)
397
+ socket.on('data', (chunk) => {
398
+ if (!connections.has(c) || c.closing) return
399
+ const text = c.decoder.write(chunk)
400
+ if (text.length === 0) return
401
+ let answer
402
+ try {
403
+ answer = session.feed(text)
404
+ if (!Number.isSafeInteger(answer.bufferedBytes) || answer.bufferedBytes < 0) {
405
+ throw new XmppHostError('xmppSessionFeed lacks a valid bufferedBytes result; rebuild the engine')
406
+ }
407
+ } catch (error) {
408
+ onError(error)
409
+ socket.destroy()
410
+ endSession(c)
411
+ return
412
+ }
413
+ // A transport limit, not a protocol one: a client that sends this
414
+ // much without completing a unit is cut off.
415
+ perform(c, answer)
416
+ if (answer.bufferedBytes > maxBuffered) {
417
+ onError(new XmppHostError(`connection exceeded ${maxBuffered} buffered bytes`))
418
+ socket.destroy()
419
+ endSession(c)
420
+ }
421
+ })
422
+ socket.on('end', () => endSession(c))
423
+ socket.on('close', () => endSession(c))
424
+ socket.on('error', (error) => {
425
+ if (error && error.code !== 'ECONNRESET') onError(error)
426
+ endSession(c)
427
+ })
428
+ }
429
+
430
+ let server
431
+ try {
432
+ if (useTls) {
433
+ const tls = await import('node:tls')
434
+ server = tls.createServer({ cert: options.cert, key: options.key }, onConnection)
435
+ } else {
436
+ const net = await import('node:net')
437
+ server = net.createServer(onConnection)
438
+ }
439
+ } catch (error) {
440
+ xmpp.close()
441
+ throw error
442
+ }
443
+ server.on('connection', socket => {
444
+ transports.add(socket)
445
+ socket.once('close', () => transports.delete(socket))
446
+ })
447
+
448
+ // The harness's mailbox: `<dir>/spool/<bare jid>/<name>` holds one
449
+ // stanza's bytes for that account. Polled for every bound session,
450
+ // delivered in name order, unlinked once the engine accepted it.
451
+ let poller = null
452
+ let polling = false
453
+ let pollTask = Promise.resolve()
454
+ async function pollSpool () {
455
+ if (polling || dir === null) return
456
+ polling = true
457
+ try {
458
+ const bares = new Set()
459
+ for (const c of connections) if (c.full !== null) bares.add(c.bare)
460
+ for (const bare of bares) {
461
+ const box = join(dir, 'spool', pathSafe(bare))
462
+ let names
463
+ try { names = (await readdir(box)).sort() } catch (_error) { continue }
464
+ for (const name of names) {
465
+ const path = join(box, name)
466
+ let text
467
+ try { text = await readFile(path, 'utf8') } catch (_error) { continue }
468
+ const targets = boundTo(bare)
469
+ if (targets.length === 0) break
470
+ let accepted = false
471
+ for (const t of targets) {
472
+ try {
473
+ const answer = t.session.deliver(text)
474
+ if (answer.delivered === true) { writeAll(t, answer.send); accepted = true }
475
+ } catch (error) { onError(error) }
476
+ }
477
+ if (accepted) await unlink(path).catch(() => {})
478
+ }
479
+ }
480
+ } finally {
481
+ polling = false
482
+ }
483
+ }
484
+ if (dir !== null && pollMs > 0) {
485
+ poller = setInterval(() => {
486
+ if (!polling) pollTask = pollSpool().catch(onError)
487
+ }, pollMs)
488
+ if (typeof poller.unref === 'function') poller.unref()
489
+ }
490
+
491
+ let closePromise = null
492
+ return {
493
+ server,
494
+ xmpp,
495
+ /** The live connections' `{bare, full}` pairs, for a host's own view. */
496
+ sessions () {
497
+ return Array.from(connections, (c) => ({ bare: c.bare, full: c.full }))
498
+ },
499
+ /** Stanzas held for accounts with no bound session. */
500
+ pending () {
501
+ return new Map(pending)
502
+ },
503
+ close () {
504
+ if (closePromise !== null) return closePromise
505
+ closePromise = (async () => {
506
+ if (poller !== null) clearInterval(poller)
507
+ for (const c of Array.from(connections)) {
508
+ endSession(c)
509
+ c.socket.destroy()
510
+ }
511
+ for (const socket of transports) socket.destroy()
512
+ try {
513
+ await new Promise(resolve => server.close(resolve))
514
+ await pollTask
515
+ await rosterWrites
516
+ if (persistenceError !== null) throw persistenceError
517
+ } finally {
518
+ xmpp.close()
519
+ }
520
+ })()
521
+ return closePromise
522
+ }
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Start a server and resolve once it is listening.
528
+ *
529
+ * @param {object} options as `createXmppServer`, plus `port` and `host`
530
+ * @returns {Promise<{server: object, xmpp: object, port: number, host: string,
531
+ * origin: string, close: Function, sessions: Function}>}
532
+ */
533
+ export async function listen (options = {}) {
534
+ const port = typeof options.port === 'number' ? options.port : 0
535
+ const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
536
+ const created = await createXmppServer(options)
537
+ try {
538
+ await new Promise((resolve, reject) => {
539
+ created.server.once('error', reject)
540
+ created.server.listen(port, host, resolve)
541
+ })
542
+ } catch (error) {
543
+ await created.close()
544
+ throw error
545
+ }
546
+ const bound = created.server.address()
547
+ const scheme = options.cert !== undefined && options.key !== undefined ? 'xmpps' : 'xmpp'
548
+ return { ...created, port: bound.port, host, origin: `${scheme}://${host}:${bound.port}` }
549
+ }