@factoidal/core 0.20.0 → 0.22.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,332 @@
1
+ // factoidal/xmpp/client.mjs — an OUTGOING XMPP client connection that
2
+ // carries HTTP over XMPP (XEP-0332), in one of two modes.
3
+ // https://github.com/danbri/factoidal/issues/1107. Design record:
4
+ // docs/designissues/2026-10-28-http-over-xmpp.md.
5
+ //
6
+ // connectHttpTarget (step 1, `factoidal webdav-serve CONFIGDIR --xmpp JID`)
7
+ // serves XEP-0332 requests with a local `step` function
8
+ // connectHttpRequester (step 2, `factoidal xmpp-http-proxy`) sends XEP-0332
9
+ // requests to ONE target JID and gives back responses
10
+ //
11
+ // WHAT THIS FILE IS ALLOWED TO DO
12
+ // Open one TCP socket, or one direct-TLS socket (XEP-0368), to an XMPP
13
+ // server; give every text it reads to the engine (`xmppClientFeed`); write
14
+ // every text the engine answers (`send`); carry each HTTP request the engine
15
+ // names (`requests`) to the caller's `step` function and give the response
16
+ // back (`xmppClientAnswer`); in requester mode, give each HTTP request to the
17
+ // engine (`xmppClientRequest`), give it the clock (`xmppClientTick`) and
18
+ // resolve each response the engine names (`responses`); close the socket when
19
+ // the engine says `close`.
20
+ //
21
+ // It makes NO protocol decision. The stream negotiation, SASL SCRAM-SHA-256,
22
+ // the check of the server signature, resource binding, which stanza is an
23
+ // HTTP request or response, who may send one (the allow list), the target,
24
+ // the refusals and limits, timeouts, chunked bodies and every status are
25
+ // decided in formal/lean4/L4Factoidal/XMPP/ (ClientStream, HttpOverXmpp,
26
+ // HttpRequester) and formal/lean4/Wasm/Ops/XmppClient.lean (iron rule 7 of
27
+ // CLAUDE.md). The host supplies the SCRAM client nonce (random bytes) and the
28
+ // clock, and moves bytes.
29
+ //
30
+ // THE WASM CONTRACT (formal/lean4/Wasm/Ops/XmppClient.lean)
31
+ // xmppClientOpen [configJson] -> { ok, handle, send, ... }
32
+ // xmppClientFeed [handle, text] -> { ok, send, requests, responses, bound, close, reason, stage, bufferedBytes }
33
+ // xmppClientAnswer [handle, id, responseJson] -> { ok, send }
34
+ // xmppClientRequest [handle, nowMs, requestJson] -> { ok, id, send, responses }
35
+ // xmppClientTick [handle, nowMs] -> { ok, send, responses }
36
+ // xmppClientClose [handle] -> { ok, send, responses }
37
+
38
+ import { randomBytes } from 'node:crypto'
39
+ import { StringDecoder } from 'node:string_decoder'
40
+ import { loadEngine } from '../bin/engine.mjs'
41
+
42
+ /** The ops this host needs from the engine. */
43
+ export const XMPP_CLIENT_OPS = ['xmppClientOpen', 'xmppClientFeed', 'xmppClientAnswer', 'xmppClientClose',
44
+ 'xmppClientRequest', 'xmppClientTick']
45
+
46
+ /** An error of this host, as opposed to one the engine reported. */
47
+ export class XmppClientError extends Error {
48
+ constructor (message, detail = {}) {
49
+ super(message)
50
+ this.name = 'XmppClientError'
51
+ this.unknownOp = detail.unknownOp === true
52
+ }
53
+ }
54
+
55
+ function call (engine, op, args) {
56
+ let envelope
57
+ try {
58
+ envelope = engine.call(op, args)
59
+ } catch (error) {
60
+ const text = error && error.message ? String(error.message) : String(error)
61
+ if (text.indexOf('unknown op') >= 0) {
62
+ throw new XmppClientError(`the loaded l4factoidal WebAssembly module does not carry ${op}`, { unknownOp: true })
63
+ }
64
+ throw error
65
+ }
66
+ if (envelope === null || typeof envelope !== 'object') throw new XmppClientError(`${op} answered with no envelope`)
67
+ if (envelope.ok === false) throw new XmppClientError(`${op}: ${envelope.error}`)
68
+ return envelope
69
+ }
70
+
71
+ /** The configuration fields both modes share; values go to the engine unread. */
72
+ function baseConfig (options) {
73
+ const config = {
74
+ jid: options.jid,
75
+ password: typeof options.password === 'string' ? options.password : '',
76
+ clientNonce: randomBytes(24).toString('base64url'),
77
+ tls: options.tls === true
78
+ }
79
+ if (typeof options.host === 'string') config.host = options.host
80
+ if (typeof options.resource === 'string') config.resource = options.resource
81
+ if (typeof options.minIterations === 'number') config.minIterations = options.minIterations
82
+ return config
83
+ }
84
+
85
+ /**
86
+ * Open the engine handle and the socket; run the stream until it is bound.
87
+ * `onFeed(answer)` gets every `xmppClientFeed` envelope after the bytes it
88
+ * names are written. Pure byte moving.
89
+ */
90
+ async function openStream (engine, config, options, onFeed, onClosed) {
91
+ const onError = typeof options.onError === 'function' ? options.onError : () => {}
92
+ const maxBuffered = typeof options.maxBuffered === 'number' ? options.maxBuffered : 16 * 1024 * 1024
93
+ const opened = call(engine, 'xmppClientOpen', [JSON.stringify(config)])
94
+ const handle = opened.handle
95
+ const domain = opened.domain
96
+ if (typeof domain !== 'string') throw new XmppClientError('xmppClientOpen answered with no domain; rebuild the engine')
97
+ const host = opened.host
98
+ const port = typeof options.port === 'number' ? options.port : (options.tls === true ? 5223 : 5222)
99
+
100
+ let socket
101
+ if (options.tls === true) {
102
+ const tls = await import('node:tls')
103
+ socket = tls.connect({ host, port, servername: domain, ...(options.tlsOptions || {}) })
104
+ } else {
105
+ const net = await import('node:net')
106
+ socket = net.connect({ host, port })
107
+ }
108
+
109
+ let open = true
110
+ let boundJid = null
111
+ let resolveBound
112
+ let rejectBound
113
+ const bound = new Promise((resolve, reject) => { resolveBound = resolve; rejectBound = reject })
114
+ let resolveClosed
115
+ const closed = new Promise((resolve) => { resolveClosed = resolve })
116
+
117
+ function write (texts) {
118
+ for (const t of texts || []) if (!socket.destroyed) socket.write(t)
119
+ }
120
+
121
+ function finish (reason) {
122
+ if (!open) return
123
+ open = false
124
+ let closing = null
125
+ try { closing = call(engine, 'xmppClientClose', [handle]) } catch (_error) { /* already gone */ }
126
+ if (!socket.destroyed) socket.end()
127
+ if (boundJid === null) rejectBound(new XmppClientError(reason || 'the XMPP connection closed before binding'))
128
+ if (closing !== null && typeof onClosed === 'function') onClosed(closing)
129
+ resolveClosed(reason || '')
130
+ }
131
+
132
+ const decoder = new StringDecoder('utf8')
133
+ socket.on('connect', () => write(opened.send))
134
+ socket.on('secureConnect', () => {})
135
+ socket.on('data', (chunk) => {
136
+ if (!open) return
137
+ const text = decoder.write(chunk)
138
+ if (text.length === 0) return
139
+ let answer
140
+ try {
141
+ answer = call(engine, 'xmppClientFeed', [handle, text])
142
+ } catch (error) {
143
+ onError(error)
144
+ socket.destroy()
145
+ finish(error.message)
146
+ return
147
+ }
148
+ write(answer.send)
149
+ if (typeof answer.bound === 'string' && boundJid === null) {
150
+ boundJid = answer.bound
151
+ resolveBound(boundJid)
152
+ }
153
+ onFeed(answer)
154
+ if (answer.close === true) finish(answer.reason)
155
+ else if (answer.bufferedBytes > maxBuffered) {
156
+ onError(new XmppClientError(`the server sent more than ${maxBuffered} bytes without completing a stanza`))
157
+ finish('buffer limit')
158
+ }
159
+ })
160
+ socket.on('error', (error) => { onError(error); finish(error.message) })
161
+ socket.on('close', () => finish('the server closed the connection'))
162
+
163
+ return {
164
+ opened,
165
+ handle,
166
+ bound,
167
+ closed,
168
+ write,
169
+ isOpen: () => open,
170
+ close () {
171
+ if (open && !socket.destroyed) {
172
+ try {
173
+ const closing = call(engine, 'xmppClientClose', [handle])
174
+ write(closing.send)
175
+ if (typeof onClosed === 'function') onClosed(closing)
176
+ } catch (_error) { /* gone */ }
177
+ }
178
+ finish('')
179
+ return closed
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Connect to an XMPP server as `jid` and serve HTTP over XMPP with `step`.
186
+ * Resolves once the resource is bound (or rejects with the engine's reason).
187
+ *
188
+ * @param {object} options
189
+ * @param {string} options.jid the account, `user@domain` (a resource may follow `/`)
190
+ * @param {string} options.password
191
+ * @param {string} [options.host] the XMPP server's address (default: the JID's domain; no SRV lookup)
192
+ * @param {number} [options.port] default 5222, or 5223 with `tls`
193
+ * @param {boolean} [options.tls] direct TLS (XEP-0368); without it the engine allows plain TCP to a loopback address only
194
+ * @param {object} [options.tlsOptions] passed to `tls.connect` (for example `ca`)
195
+ * @param {string[]} [options.allow] bare JIDs that may send requests
196
+ * @param {string} [options.resource]
197
+ * @param {object[]} [options.mounts] the WebDAV handle's mounts, passed unread (the engine refuses writable local mounts without the opt-in)
198
+ * @param {boolean} [options.dangerouslyAllowWritableWithoutLogin]
199
+ * @param {Function} options.step `async ({method, target, headers, body}) => ({status, headers, body})`
200
+ * @param {object} [options.engine]
201
+ * @param {Function} [options.onError]
202
+ * @param {number} [options.maxBuffered] residual bytes the engine may hold before the socket is closed (default 16 MiB)
203
+ * @returns {Promise<{jid: string, closed: Promise<string>, close: Function}>}
204
+ */
205
+ export async function connectHttpTarget (options = {}) {
206
+ if (typeof options.step !== 'function') throw new XmppClientError('connectHttpTarget needs step')
207
+ if (typeof options.jid !== 'string') throw new XmppClientError('connectHttpTarget needs jid')
208
+ const engine = options.engine !== undefined ? options.engine : await loadEngine()
209
+ const onError = typeof options.onError === 'function' ? options.onError : () => {}
210
+ const config = baseConfig(options)
211
+ config.allow = Array.isArray(options.allow) ? options.allow : []
212
+ if (Array.isArray(options.mounts)) config.mounts = options.mounts
213
+ if (options.dangerouslyAllowWritableWithoutLogin === true) config.dangerouslyAllowWritableWithoutLogin = true
214
+
215
+ let stream = null
216
+ /** One request the engine named: carried to `step`, answered. */
217
+ async function carry (request) {
218
+ let answer
219
+ try {
220
+ const response = await options.step({
221
+ method: request.method,
222
+ target: request.target,
223
+ headers: request.headers || [],
224
+ body: new Uint8Array(Buffer.from(request.bodyBase64 || '', 'base64'))
225
+ })
226
+ answer = {
227
+ status: response.status,
228
+ headers: response.headers || [],
229
+ bodyBase64: Buffer.from(response.body || new Uint8Array(0)).toString('base64')
230
+ }
231
+ } catch (error) {
232
+ onError(error)
233
+ answer = { status: 500, headers: [['content-type', 'text/plain']], bodyBase64: Buffer.from(`host error: ${error.message}\n`).toString('base64') }
234
+ }
235
+ if (stream === null || !stream.isOpen()) return
236
+ try {
237
+ stream.write(call(engine, 'xmppClientAnswer', [stream.handle, String(request.id), JSON.stringify(answer)]).send)
238
+ } catch (error) {
239
+ onError(error)
240
+ }
241
+ }
242
+ stream = await openStream(engine, config, options, (answer) => {
243
+ for (const r of answer.requests || []) carry(r)
244
+ })
245
+ const jid = await stream.bound
246
+ return {
247
+ jid,
248
+ writableWithoutLogin: stream.opened.writableWithoutLogin || [],
249
+ closed: stream.closed,
250
+ close: () => stream.close()
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Connect to an XMPP server as `jid` and send HTTP requests over XMPP to
256
+ * `target` (a full JID) and to no other entity. Resolves once the resource is
257
+ * bound. `request()` resolves with the response the engine names: from the
258
+ * target, or one the engine made (a refusal, 502, 504).
259
+ *
260
+ * @param {object} options the connection options of `connectHttpTarget`, without `allow`, `mounts` and `step`
261
+ * @param {string} options.target the full JID of the serving side
262
+ * @param {string} options.listenHost the address the caller will listen on (the engine accepts 127.0.0.1 only)
263
+ * @param {number} [options.timeoutSeconds] how long a request waits (the engine accepts 1 to 3600; default 60)
264
+ * @param {number} [options.tickMs] how often the clock is given to the engine (default 500)
265
+ * @returns {Promise<{jid: string, target: string, listenHost: string, maxBodyBytes: number,
266
+ * request: Function, closed: Promise<string>, close: Function}>}
267
+ */
268
+ export async function connectHttpRequester (options = {}) {
269
+ if (typeof options.jid !== 'string') throw new XmppClientError('connectHttpRequester needs jid')
270
+ if (typeof options.target !== 'string') throw new XmppClientError('connectHttpRequester needs target')
271
+ const engine = options.engine !== undefined ? options.engine : await loadEngine()
272
+ const config = baseConfig(options)
273
+ config.proxyTarget = options.target
274
+ config.listenHost = typeof options.listenHost === 'string' ? options.listenHost : ''
275
+ if (typeof options.timeoutSeconds === 'number') config.timeoutSeconds = options.timeoutSeconds
276
+
277
+ const waiting = new Map()
278
+ function deliver (responses) {
279
+ for (const r of responses || []) {
280
+ const resolve = waiting.get(r.id)
281
+ if (resolve === undefined) continue
282
+ waiting.delete(r.id)
283
+ resolve({
284
+ status: r.status,
285
+ headers: r.headers || [],
286
+ body: new Uint8Array(Buffer.from(r.bodyBase64 || '', 'base64'))
287
+ })
288
+ }
289
+ }
290
+ const stream = await openStream(engine, config, options,
291
+ (answer) => deliver(answer.responses),
292
+ (closing) => deliver(closing.responses))
293
+ const jid = await stream.bound
294
+ const timer = setInterval(() => {
295
+ if (!stream.isOpen()) return
296
+ try {
297
+ const t = call(engine, 'xmppClientTick', [stream.handle, String(Date.now())])
298
+ stream.write(t.send)
299
+ deliver(t.responses)
300
+ } catch (_error) { /* handle gone */ }
301
+ }, typeof options.tickMs === 'number' ? options.tickMs : 500)
302
+ stream.closed.then(() => clearInterval(timer))
303
+
304
+ return {
305
+ jid,
306
+ target: stream.opened.target,
307
+ listenHost: stream.opened.listenHost,
308
+ maxBodyBytes: Number(stream.opened.maxBodyBytes),
309
+ closed: stream.closed,
310
+ /** `{method, target, headers: [[n, v]...], body: Uint8Array}` -> `{status, headers, body}` */
311
+ request (req) {
312
+ return new Promise((resolve, reject) => {
313
+ let answer
314
+ try {
315
+ answer = call(engine, 'xmppClientRequest', [stream.handle, String(Date.now()), JSON.stringify({
316
+ method: req.method,
317
+ target: req.target,
318
+ headers: req.headers || [],
319
+ bodyBase64: Buffer.from(req.body || new Uint8Array(0)).toString('base64')
320
+ })])
321
+ } catch (error) {
322
+ reject(error)
323
+ return
324
+ }
325
+ waiting.set(answer.id, resolve)
326
+ stream.write(answer.send)
327
+ deliver(answer.responses)
328
+ })
329
+ },
330
+ close: () => { clearInterval(timer); return stream.close() }
331
+ }
332
+ }
package/xmpp/proxy.mjs ADDED
@@ -0,0 +1,114 @@
1
+ // factoidal/xmpp/proxy.mjs — `factoidal xmpp-http-proxy`: a local HTTP
2
+ // listener whose every request is sent over XMPP (XEP-0332) to ONE target
3
+ // JID, so a file browser, rclone, curl or our LWS client can use
4
+ // http://127.0.0.1:PORT/ to reach `webdav-serve --xmpp` behind NAT.
5
+ // https://github.com/danbri/factoidal/issues/1107 (step 2). Design record:
6
+ // docs/designissues/2026-10-28-http-over-xmpp.md, section "Step 2".
7
+ //
8
+ // WHAT THIS FILE IS ALLOWED TO DO
9
+ // Listen on the address the engine accepted (`listenHost`, 127.0.0.1 only in
10
+ // this step), read each HTTP request (method, request target, raw header
11
+ // pairs, body up to the engine's `maxBodyBytes` plus one byte), give it to
12
+ // `connectHttpRequester().request`, and write the status, header pairs and
13
+ // body it resolves with. No protocol decision: which headers are forwarded,
14
+ // where the stanzas go, the refusals, limits, timeouts and every status are
15
+ // decided in formal/lean4/L4Factoidal/XMPP/HttpRequester.lean and
16
+ // formal/lean4/Wasm/Ops/XmppClient.lean.
17
+
18
+ import http from 'node:http'
19
+ import { connectHttpRequester } from './client.mjs'
20
+
21
+ /**
22
+ * Start the proxy. Resolves when the XMPP stream is bound and the listener
23
+ * is open.
24
+ *
25
+ * @param {object} options the options of `connectHttpRequester` (jid,
26
+ * password, target, host, port, tls, tlsOptions, timeoutSeconds, engine,
27
+ * onError), plus:
28
+ * @param {string} [options.listenHost] default 127.0.0.1 (the engine refuses any other)
29
+ * @param {number} [options.listenPort] default 8091; 0 takes an ephemeral port
30
+ * @returns {Promise<{origin: string, jid: string, target: string, closed: Promise<string>, close: Function}>}
31
+ */
32
+ export async function startHttpProxy (options = {}) {
33
+ const onError = typeof options.onError === 'function' ? options.onError : () => {}
34
+ const conn = await connectHttpRequester({
35
+ ...options,
36
+ listenHost: typeof options.listenHost === 'string' ? options.listenHost : '127.0.0.1'
37
+ })
38
+ const limit = conn.maxBodyBytes
39
+
40
+ const server = http.createServer((req, res) => {
41
+ const parts = []
42
+ let size = 0
43
+ req.on('data', (b) => {
44
+ // Keep at most limit + 1 bytes: enough for the engine to answer 413.
45
+ if (size > limit) return
46
+ parts.push(b)
47
+ size += b.length
48
+ })
49
+ req.on('end', async () => {
50
+ const headers = []
51
+ for (let i = 0; i + 1 < req.rawHeaders.length; i += 2) headers.push([req.rawHeaders[i], req.rawHeaders[i + 1]])
52
+ let body = Buffer.concat(parts)
53
+ if (body.length > limit + 1) body = body.subarray(0, limit + 1)
54
+ let answer
55
+ try {
56
+ answer = await conn.request({ method: req.method, target: req.url, headers, body })
57
+ } catch (error) {
58
+ onError(error)
59
+ answer = { status: 502, headers: [['content-type', 'text/plain; charset=utf-8']], body: Buffer.from(`proxy host error: ${error.message}\n`) }
60
+ }
61
+ const flat = []
62
+ for (const [n, v] of answer.headers) flat.push(n, v)
63
+ try {
64
+ res.writeHead(answer.status, flat)
65
+ res.end(Buffer.from(answer.body))
66
+ } catch (error) {
67
+ onError(error)
68
+ if (!res.headersSent) { res.writeHead(502); res.end() } else res.destroy()
69
+ }
70
+ })
71
+ req.on('error', (error) => onError(error))
72
+ })
73
+ // Node gives CONNECT as its own event, with the raw socket. The request
74
+ // goes to the engine like any other (it answers 405); the answer is written
75
+ // on the socket, which is then closed. No tunnel is ever opened.
76
+ server.on('connect', async (req, socket) => {
77
+ const headers = []
78
+ for (let i = 0; i + 1 < req.rawHeaders.length; i += 2) headers.push([req.rawHeaders[i], req.rawHeaders[i + 1]])
79
+ let answer
80
+ try {
81
+ answer = await conn.request({ method: req.method, target: req.url, headers, body: new Uint8Array(0) })
82
+ } catch (error) {
83
+ onError(error)
84
+ answer = { status: 502, headers: [], body: new Uint8Array(0) }
85
+ }
86
+ const body = Buffer.from(answer.body)
87
+ const lines = [`HTTP/1.1 ${answer.status} ${http.STATUS_CODES[answer.status] || ''}`]
88
+ for (const [n, v] of answer.headers) lines.push(`${n}: ${v}`)
89
+ lines.push(`content-length: ${body.length}`, 'connection: close', '', '')
90
+ socket.end(Buffer.concat([Buffer.from(lines.join('\r\n'), 'latin1'), body]))
91
+ })
92
+ const port = typeof options.listenPort === 'number' ? options.listenPort : 8091
93
+ try {
94
+ await new Promise((resolve, reject) => {
95
+ server.once('error', reject)
96
+ server.listen(port, conn.listenHost, resolve)
97
+ })
98
+ } catch (error) {
99
+ await conn.close()
100
+ throw error
101
+ }
102
+ const addr = server.address()
103
+ conn.closed.then(() => server.close())
104
+ return {
105
+ origin: `http://${addr.address}:${addr.port}/`,
106
+ jid: conn.jid,
107
+ target: conn.target,
108
+ closed: conn.closed,
109
+ async close () {
110
+ server.close()
111
+ await conn.close()
112
+ }
113
+ }
114
+ }