@factoidal/core 0.32.0 → 0.34.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 +21 -2
- package/bin/factoidal.mjs +184 -23
- package/bin/foafnaut.mjs +29 -7
- package/browser.js +5 -2
- package/index.d.ts +20 -7
- package/index.mjs +7 -4
- 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 +5 -3
- package/lib/sbm11-lws.mjs +25 -35
- package/lib/version.mjs +1 -1
- package/lws/server.mjs +82 -14
- package/package.json +1 -1
- package/webdav/server.mjs +12 -0
- package/xmpp/README.md +58 -4
- package/xmpp/client.mjs +18 -3
- package/xmpp/inpage.mjs +7 -1
- package/xmpp/server.mjs +314 -25
package/l4-assets/version.json
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
"engine": "lean4",
|
|
3
3
|
"note": "The Lean 4 (L4Factoidal) engine build provenance and claims: the only engine of @factoidal/core since 0.9.0 (issue 713). This committed file is a placeholder. tools/fetch-builds.sh --set web (through tools/stamp-l4-version.mjs) writes the provenance members from the danbri/factoidal-builds release that built the wasm; formal/lean4/Wasm/build-wasm.sh step 9 writes them for a local build. Do not commit a stamped copy.",
|
|
4
4
|
"stamped": true,
|
|
5
|
-
"release": "danbri/factoidal-builds build-
|
|
6
|
-
"releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/
|
|
7
|
-
"gitSha": "
|
|
8
|
-
"builtAt": "2026-11-
|
|
5
|
+
"release": "danbri/factoidal-builds build-20ff52ea9005-web",
|
|
6
|
+
"releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/42520417546",
|
|
7
|
+
"gitSha": "20ff52ea90050c8db7bb7135aa0c20ee118183e5",
|
|
8
|
+
"builtAt": "2026-11-08T21:35:07Z",
|
|
9
9
|
"leanToolchain": "leanprover/lean4:v4.33.1",
|
|
10
10
|
"emscripten": "6.0.8",
|
|
11
11
|
"abiVersion": "1",
|
|
12
12
|
"engineVersion": "l4factoidal-wasm 0.2.0 (dispatch: RDF parse/serialize, SPARQL query+update, RDFS/OWL-RL closures, RDFC-1.0, CL/IKL)",
|
|
13
13
|
"version": "l4factoidal-wasm 0.2.0 (dispatch: RDF parse/serialize, SPARQL query+update, RDFS/OWL-RL closures, RDFC-1.0, CL/IKL)",
|
|
14
|
-
"wasmSha256": "
|
|
15
|
-
"wasmBytes":
|
|
14
|
+
"wasmSha256": "a92a94475fa0cfc6fe9aeb28b197e81c3c3162806ecbba562f200fbaea51416b",
|
|
15
|
+
"wasmBytes": 32702060,
|
|
16
16
|
"claims": {
|
|
17
17
|
"source": "formal/lean4 (L4Factoidal): no sorry, no user axioms, no native_decide; W3C behaviour pinned by build-time #guard",
|
|
18
18
|
"suitesAtBuildSha": "see docs/test-results and formal/lean4/PORT_NOTES.md at gitSha"
|
package/lib/api.mjs
CHANGED
|
@@ -693,7 +693,7 @@ function buildApi(driver) {
|
|
|
693
693
|
}
|
|
694
694
|
const r = await withExtensionRounds(() => entryResult(e.datasetQueryRaw(ds.handle, sparql,
|
|
695
695
|
JSON.stringify({ entail, output })), 'query', sparql));
|
|
696
|
-
return output === 'json' && !graphForm ? JSON.parse(r.text) : r.text;
|
|
696
|
+
return (output === 'json' || output === 'xml-report') && !graphForm ? JSON.parse(r.text) : r.text;
|
|
697
697
|
}
|
|
698
698
|
const fn = sparql12 ? e.datasetQuery12 : e.datasetQuery;
|
|
699
699
|
if (typeof fn !== 'function') {
|
|
@@ -2487,7 +2487,9 @@ function buildApi(driver) {
|
|
|
2487
2487
|
* One call, serialised answer (op queryRaw): parse `dataString`, close
|
|
2488
2488
|
* the default graph under `entail`, run the query, and write the answer
|
|
2489
2489
|
* in `output`: 'json' (default; parsed SPARQL results JSON), or 'csv',
|
|
2490
|
-
* 'tsv', 'xml', 'table', 'ntriples' (text)
|
|
2490
|
+
* 'tsv', 'xml', 'table', 'ntriples' (text), or 'xml-report' (a checked
|
|
2491
|
+
* XML/diagnostic object from Lean). Ordinary 'xml' rejects an XML
|
|
2492
|
+
* admission failure; 'xml-report' returns it as data. A CONSTRUCT or DESCRIBE
|
|
2491
2493
|
* answer is a graph: 'ntriples' or 'table' only.
|
|
2492
2494
|
* @param {string} dataString
|
|
2493
2495
|
* @param {string} queryString
|
|
@@ -2511,7 +2513,7 @@ function buildApi(driver) {
|
|
|
2511
2513
|
const r = entryResult(e.queryRaw(dataString, queryString,
|
|
2512
2514
|
JSON.stringify({ dataFormat: tables.rawDataFormats[dataFormat], entail, output })),
|
|
2513
2515
|
'queryRaw', queryString);
|
|
2514
|
-
return output === 'json' ? JSON.parse(r.text) : r.text;
|
|
2516
|
+
return output === 'json' || output === 'xml-report' ? JSON.parse(r.text) : r.text;
|
|
2515
2517
|
}
|
|
2516
2518
|
|
|
2517
2519
|
// ---------------------------------------------------------------
|
package/lib/sbm11-lws.mjs
CHANGED
|
@@ -40,40 +40,23 @@
|
|
|
40
40
|
// thread has neither: pass `transport` or run the engine in a worker.
|
|
41
41
|
|
|
42
42
|
// This reader grants its transport authority only within one HTTP container.
|
|
43
|
-
// A file is a raw relative filename/path, never a URL reference.
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
// A file is a raw relative filename/path, never a URL reference. The engine
|
|
44
|
+
// decides the container URL and every resource URL (op containerPath,
|
|
45
|
+
// formal/lean4/L4Factoidal/LWS/ContainerPath.lean: containerOf, resourceIn,
|
|
46
|
+
// theorem resourceIn_under) before any header is attached; this file only
|
|
47
|
+
// turns a refusal into a TypeError.
|
|
48
|
+
function containerUrl (engine, url) {
|
|
49
|
+
const r = engine.call('containerPath', [JSON.stringify({ kind: 'container', url: String(url) })])
|
|
50
|
+
if (!r.allowed) {
|
|
51
|
+
throw new TypeError(`lwsSbm11Reader: ${r.reason}; the container must be an absolute HTTP(S) URL without userinfo, query or fragment (use opts.auth/headers)`)
|
|
50
52
|
}
|
|
51
|
-
|
|
52
|
-
return base
|
|
53
|
+
return r.url
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
function resourceUrl (base, file) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
let probe = file
|
|
60
|
-
for (;;) {
|
|
61
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(probe) || /[\\?#\x00-\x1f\x7f]/.test(probe) ||
|
|
62
|
-
probe.split('/').some(part => part === '' || part === '.' || part === '..') ||
|
|
63
|
-
/%(?:2f|5c|3f|23)/i.test(probe)) {
|
|
64
|
-
throw new TypeError('resource path must remain inside the configured container')
|
|
65
|
-
}
|
|
66
|
-
// Decode only for rejection, never for the requested filename. Each changed
|
|
67
|
-
// pass shortens the string, so nested percent encodings are also bounded.
|
|
68
|
-
const next = probe.replace(/%([a-f0-9]{2})/gi, (_match, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
69
|
-
if (next === probe) break
|
|
70
|
-
probe = next
|
|
71
|
-
}
|
|
72
|
-
const resource = new URL(file.split('/').map(encodeURIComponent).join('/'), base)
|
|
73
|
-
if (resource.origin !== base.origin || !resource.pathname.startsWith(base.pathname)) {
|
|
74
|
-
throw new TypeError('resource URL escaped the configured container')
|
|
75
|
-
}
|
|
76
|
-
return resource.href
|
|
56
|
+
function resourceUrl (engine, base, file) {
|
|
57
|
+
const r = engine.call('containerPath', [JSON.stringify({ kind: 'resource', container: base, path: file })])
|
|
58
|
+
if (!r.allowed) throw new TypeError(`resource path must remain inside the configured container: ${r.reason}`)
|
|
59
|
+
return r.url
|
|
77
60
|
}
|
|
78
61
|
|
|
79
62
|
/** A synchronous GET with XMLHttpRequest (a browser worker). */
|
|
@@ -85,7 +68,8 @@ export function xhrSyncTransport (url, headers) {
|
|
|
85
68
|
x.send()
|
|
86
69
|
// XHR follows redirects internally. This rejects returned bytes, not the
|
|
87
70
|
// already-sent request. Caller headers require an explicit trusted transport.
|
|
88
|
-
|
|
71
|
+
// `url` is the engine's serialization, so any other response URL is a redirect.
|
|
72
|
+
if (x.responseURL !== url) throw new Error('LWS reader: redirected XHR resource refused')
|
|
89
73
|
return {
|
|
90
74
|
status: x.status,
|
|
91
75
|
header: (name) => x.getResponseHeader(name),
|
|
@@ -110,14 +94,20 @@ function unsatisfiedTotal (value) {
|
|
|
110
94
|
*
|
|
111
95
|
* @param {string} url absolute HTTP(S), no userinfo/query/fragment; container that holds CURRENT and the generation
|
|
112
96
|
* (or the generation container itself)
|
|
113
|
-
* @param {object}
|
|
97
|
+
* @param {object} opts
|
|
98
|
+
* @param {object} opts.engine a loaded engine handle (`call(op, args)`); its op
|
|
99
|
+
* containerPath decides the container URL and every resource URL
|
|
114
100
|
* @param {Function} [opts.transport] trusted synchronous GET; must constrain redirect/header forwarding
|
|
115
101
|
* @param {string} [opts.auth] a value for the Authorization header, such as
|
|
116
102
|
* `Bearer <access token>` (an LWS access token)
|
|
117
103
|
* @param {object} [opts.headers] further request headers; browser use requires explicit trusted transport
|
|
118
104
|
*/
|
|
119
105
|
export function lwsSbm11Reader (url, opts = {}) {
|
|
120
|
-
const
|
|
106
|
+
const engine = opts.engine
|
|
107
|
+
if (!engine || typeof engine.call !== 'function') {
|
|
108
|
+
throw new TypeError('lwsSbm11Reader: opts.engine (a loaded engine handle) decides the resource URLs')
|
|
109
|
+
}
|
|
110
|
+
const base = containerUrl(engine, url)
|
|
121
111
|
const extra = { ...(opts.headers || {}) }
|
|
122
112
|
if (opts.auth) extra.authorization = String(opts.auth)
|
|
123
113
|
let transport = opts.transport
|
|
@@ -137,7 +127,7 @@ export function lwsSbm11Reader (url, opts = {}) {
|
|
|
137
127
|
let e = files.get(file)
|
|
138
128
|
if (!e) {
|
|
139
129
|
let url
|
|
140
|
-
try { url = resourceUrl(base, file) } catch (error) {
|
|
130
|
+
try { url = resourceUrl(engine, base, file) } catch (error) {
|
|
141
131
|
fail(file, error.message)
|
|
142
132
|
return null
|
|
143
133
|
}
|
package/lib/version.mjs
CHANGED
package/lws/server.mjs
CHANGED
|
@@ -24,6 +24,16 @@
|
|
|
24
24
|
// lwsFetched [handle, documentJson] -> { ok, fetch? | installed? | refused? }
|
|
25
25
|
// lwsDelivered [handle, reportJson] -> { ok, changed, retry? }
|
|
26
26
|
// lwsFlush [handle, { now? }] -> { ok, changed, deliveries?, wake? }
|
|
27
|
+
// lwsEgress [handle, { url, addresses }] -> { ok, host, address } | { ok, refused }
|
|
28
|
+
//
|
|
29
|
+
// REQUESTS TO PRIVATE ADDRESSES (https://github.com/danbri/factoidal/issues/1126)
|
|
30
|
+
// Just before each delivery this host resolves the name the delivery names
|
|
31
|
+
// (`resolve`, every address), passes the addresses to `lwsEgress`, and
|
|
32
|
+
// connects to the address the answer names, with no second resolution
|
|
33
|
+
// (`postTo`). A refused delivery is not made; it is reported to
|
|
34
|
+
// `lwsDelivered` with `refused: true`. Which addresses are refused (the
|
|
35
|
+
// IANA special-purpose ranges) and the opt-in `allowPrivateFetch` are
|
|
36
|
+
// decided by `L4Factoidal/Net/Egress.lean` and `Notifications.egressOf`.
|
|
27
37
|
//
|
|
28
38
|
// RATE LIMIT AND INBOX CHECK (https://github.com/danbri/factoidal/issues/915)
|
|
29
39
|
// An answer may carry `wake: SECONDS`: deliveries wait for a rate-limit
|
|
@@ -87,7 +97,7 @@ import { loadEngine } from '../bin/engine.mjs'
|
|
|
87
97
|
import { readSnapshot, persistIfChanged, storageKeyText, storageModeText, loadFiles, persistFilesIfChanged, readStepBodies, acquireDirLock, atRestNotice } from './persist.mjs'
|
|
88
98
|
|
|
89
99
|
/** The ops this host needs from the engine. */
|
|
90
|
-
export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched', 'lwsDelivered',
|
|
100
|
+
export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched', 'lwsDelivered', 'lwsEgress',
|
|
91
101
|
'lwsLoad', 'lwsPersist', 'lwsRead', 'lwsDirLock', 'lwsAtRestNotice']
|
|
92
102
|
|
|
93
103
|
/** How long this host waits for the answer to one delivery request before
|
|
@@ -128,6 +138,59 @@ async function resolveHost (host) {
|
|
|
128
138
|
}
|
|
129
139
|
}
|
|
130
140
|
|
|
141
|
+
/** The most response bytes this host reads from an inbox; the rest is
|
|
142
|
+
* dropped (the engine reads only the status). */
|
|
143
|
+
const MAX_DELIVERY_ANSWER_BYTES = 65536
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* POST one delivery to the address the engine chose (`lwsEgress`). Pure
|
|
147
|
+
* I/O: the connection goes to `address` only (the lookup answers it, so the
|
|
148
|
+
* name is not resolved again), TLS still checks the certificate against the
|
|
149
|
+
* URL's host name, redirects are not followed. The status, or 0 when no
|
|
150
|
+
* answer came in time.
|
|
151
|
+
*/
|
|
152
|
+
async function postTo (url, address, headers, body) {
|
|
153
|
+
let target
|
|
154
|
+
try {
|
|
155
|
+
target = new URL(url)
|
|
156
|
+
} catch (_error) {
|
|
157
|
+
return 0
|
|
158
|
+
}
|
|
159
|
+
const module = target.protocol === 'https:' ? await import('node:https') : await import('node:http')
|
|
160
|
+
const family = address.indexOf(':') >= 0 ? 6 : 4
|
|
161
|
+
const fields = {}
|
|
162
|
+
for (const pair of Array.isArray(headers) ? headers : []) {
|
|
163
|
+
if (Array.isArray(pair) && pair.length === 2) fields[String(pair[0])] = String(pair[1])
|
|
164
|
+
}
|
|
165
|
+
const bytes = Buffer.from(typeof body === 'string' ? body : '', 'utf8')
|
|
166
|
+
fields['content-length'] = String(bytes.length)
|
|
167
|
+
return new Promise((done) => {
|
|
168
|
+
let finished = false
|
|
169
|
+
const finish = (status) => { if (!finished) { finished = true; done(status) } }
|
|
170
|
+
const request = module.request(target, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: fields,
|
|
173
|
+
lookup: (_host, options, callback) => {
|
|
174
|
+
if (options && options.all) callback(null, [{ address, family }])
|
|
175
|
+
else callback(null, address, family)
|
|
176
|
+
},
|
|
177
|
+
timeout: DELIVERY_TIMEOUT_MS
|
|
178
|
+
}, (response) => {
|
|
179
|
+
let size = 0
|
|
180
|
+
response.on('data', (chunk) => {
|
|
181
|
+
size += chunk.length
|
|
182
|
+
if (size > MAX_DELIVERY_ANSWER_BYTES) response.destroy()
|
|
183
|
+
})
|
|
184
|
+
response.on('end', () => finish(response.statusCode))
|
|
185
|
+
response.on('close', () => finish(response.statusCode))
|
|
186
|
+
response.on('error', () => finish(response.statusCode))
|
|
187
|
+
})
|
|
188
|
+
request.on('timeout', () => request.destroy(new Error('timeout')))
|
|
189
|
+
request.on('error', () => finish(0))
|
|
190
|
+
request.end(bytes)
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
131
194
|
/** The addresses of this machine's network interfaces. Pure I/O. */
|
|
132
195
|
async function interfaceAddresses () {
|
|
133
196
|
try {
|
|
@@ -323,6 +386,11 @@ export async function openLwsStorage (options = {}) {
|
|
|
323
386
|
// engine checks and decides.
|
|
324
387
|
if (typeof options.deliveryLimit === 'number') config.deliveryLimit = options.deliveryLimit
|
|
325
388
|
if (typeof options.deliveryWindow === 'number') config.deliveryWindow = options.deliveryWindow
|
|
389
|
+
// The limit per destination and the opt-in for private and other
|
|
390
|
+
// special-purpose delivery targets (issue 1126): passed unread; the
|
|
391
|
+
// engine decides (L4Factoidal/Net/Egress.lean, Notifications.admitDest).
|
|
392
|
+
if (typeof options.deliveryDestLimit === 'number') config.deliveryDestLimit = options.deliveryDestLimit
|
|
393
|
+
if (options.allowPrivateFetch === true) config.allowPrivateFetch = true
|
|
326
394
|
if (typeof options.redirectsMax === 'number') config.redirectsMax = options.redirectsMax
|
|
327
395
|
if (typeof options.controllerInbox === 'string') config.controllerInbox = options.controllerInbox
|
|
328
396
|
// CORS (https://github.com/danbri/factoidal/issues/923): `cors` is false or
|
|
@@ -440,22 +508,22 @@ export async function openLwsStorage (options = {}) {
|
|
|
440
508
|
function deliver (delivery) {
|
|
441
509
|
const job = (async () => {
|
|
442
510
|
let status = 0
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
}
|
|
454
|
-
status =
|
|
511
|
+
// Resolve now, let the engine choose the address (issue 1126), and
|
|
512
|
+
// connect to that address only.
|
|
513
|
+
const addresses = typeof delivery.resolve === 'string' ? await resolveHost(delivery.resolve) : []
|
|
514
|
+
if (closed) return
|
|
515
|
+
const decided = engine.call('lwsEgress', [handle, JSON.stringify({ url: delivery.url, addresses })])
|
|
516
|
+
const refused = decided !== null && typeof decided === 'object' && typeof decided.refused === 'string'
|
|
517
|
+
? decided.refused
|
|
518
|
+
: null
|
|
519
|
+
if (refused !== null) {
|
|
520
|
+
console.error(`lws-serve: delivery to ${delivery.url} not made: ${refused}`)
|
|
521
|
+
} else {
|
|
522
|
+
status = await postTo(delivery.url, String(decided.address), delivery.headers, delivery.body)
|
|
455
523
|
}
|
|
456
524
|
if (closed) return
|
|
457
525
|
const told = engine.call('lwsDelivered', [handle, JSON.stringify({
|
|
458
|
-
id: delivery.id, status, ...clockNow()
|
|
526
|
+
id: delivery.id, status, ...(refused !== null ? { refused: true } : {}), ...clockNow()
|
|
459
527
|
})])
|
|
460
528
|
persist(told)
|
|
461
529
|
const retry = told !== null && typeof told === 'object' ? told.retry : null
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@factoidal/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "EXPERIMENTAL UNDER-REVIEWED CODE: do not run public services on it without careful independent expert review. 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",
|
package/webdav/server.mjs
CHANGED
|
@@ -186,6 +186,13 @@ export async function openWebdav (options = {}) {
|
|
|
186
186
|
}
|
|
187
187
|
// The guest user (issue 1147), passed unread; the engine checks it.
|
|
188
188
|
if (typeof options.guest === 'string') login.guest = options.guest
|
|
189
|
+
// HTTP over XMPP (issue 1125): the transport, the account and, when the
|
|
190
|
+
// operator gave one, the XMPP server's address; the engine decides.
|
|
191
|
+
if (options.transport === 'xmpp') {
|
|
192
|
+
login.transport = 'xmpp'
|
|
193
|
+
if (typeof options.jid === 'string') login.jid = options.jid
|
|
194
|
+
if (typeof options.host !== 'string') delete login.host
|
|
195
|
+
}
|
|
189
196
|
const config = { listing, entropy: randomBase64(32), now: nowSeconds(), login }
|
|
190
197
|
// The Host and Origin guard of the HTTP listener (issue 1124): the bound
|
|
191
198
|
// address and the operator's names and origins, passed unread
|
|
@@ -257,6 +264,9 @@ export async function openWebdav (options = {}) {
|
|
|
257
264
|
randomBase64: randomBase64(16),
|
|
258
265
|
remoteAddress: typeof request.remoteAddress === 'string' ? request.remoteAddress : ''
|
|
259
266
|
}
|
|
267
|
+
// Over XMPP: the requester the XMPP client handle named (the sender's
|
|
268
|
+
// bare JID), passed unread (https://github.com/danbri/factoidal/issues/1128).
|
|
269
|
+
if (request.requester !== undefined && request.requester !== null) record.requester = request.requester
|
|
260
270
|
let got = blobCall(engine, 'webdavStep', [handle, JSON.stringify(record)], request.body || new Uint8Array(0))
|
|
261
271
|
for (let hop = 0; hop < MAX_HOPS; hop += 1) {
|
|
262
272
|
const env = got.envelope
|
|
@@ -294,6 +304,8 @@ export async function openWebdav (options = {}) {
|
|
|
294
304
|
engine,
|
|
295
305
|
mounts: ready.mounts || [],
|
|
296
306
|
warnings: ready.warnings || [],
|
|
307
|
+
/** The engine's `ready.login`: a users file is loaded. */
|
|
308
|
+
login: ready.login === true,
|
|
297
309
|
/** The start notices of the local mounts (a stale lock taken over,
|
|
298
310
|
* encryption at rest on or off), for standard error. */
|
|
299
311
|
storageNotices,
|
package/xmpp/README.md
CHANGED
|
@@ -95,12 +95,14 @@ process per connection behind socat) serves; the wasm session ops are
|
|
|
95
95
|
| `xmppSessionFeed` | `[session, text]` | `{ ok, send: [...], route: [{to, text}], presence: [...], rostersSaved: [...], close, stage, bare, full, bufferedBytes }` |
|
|
96
96
|
| `xmppSessionDeliver` | `[session, text]` | `{ ok, delivered, send }` — `delivered:false` for an unbound session; nothing is lost |
|
|
97
97
|
| `xmppSessionClose` | `[session]` | `{ ok, send }` |
|
|
98
|
+
| `xmppSessionPing` | `[session, id]` | `{ ok, sent, send }` — an XEP-0199 ping for a bound session; `sent:false` otherwise. On a WebSocket, `xmppWsPing` writes a ping frame instead |
|
|
98
99
|
| `xmppSessionTlsUp` | `[session]` | `{ ok, stage }` — after a feed answered `startTls: true` and the host finished the TLS handshake; refused otherwise |
|
|
99
100
|
| `xmppRosterGet` / `xmppRosterSet` | `[server, bare]` / `[server, bare, encoded]` | the roster in the engine's own on-disk encoding |
|
|
100
101
|
| `xmppClose` | `[server]` | `{ ok, sessions }` |
|
|
101
102
|
|
|
102
103
|
The state directory is laid out as the native carrier's, so the two
|
|
103
|
-
hosts can share one: `DIR/accounts` (one `user:password` per line
|
|
104
|
+
hosts can share one: `DIR/accounts` (one `user:password` per line, or a line with no
|
|
105
|
+
password from `factoidal xmpp-serve --verifier NAME`, for SCRAM only),
|
|
104
106
|
`DIR/roster/<bare jid>.tsv` (persisted on every `rostersSaved`, loaded
|
|
105
107
|
on start), and `DIR/spool/<bare jid>/<file>` — a stanza written there
|
|
106
108
|
by any process is delivered to that account's bound session and
|
|
@@ -130,8 +132,15 @@ RFC 6120 section 5 STARTTLS with `startTls: true` (op `xmppSessionTlsUp`;
|
|
|
130
132
|
the engine decides, the host does the handshake; see the theorems in
|
|
131
133
|
`formal/lean4/L4Factoidal/XMPP/Server.lean`).
|
|
132
134
|
Not: server-to-server federation, MUC/MIX/GC3 rooms, PRECIS.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
Each name has its own SCRAM salt, from a seed the host gives the engine
|
|
136
|
+
(`saltSeed`; derived from the state key when there is one). The native
|
|
137
|
+
harness and a fixed `saltB64` keep one salt for all.
|
|
138
|
+
|
|
139
|
+
A live session keeps its full JID. A client that binds a resource a live session holds gets a
|
|
140
|
+
server-generated one (RFC 6120 section 7.7.2.2), so a client must use the JID in the bind result.
|
|
141
|
+
A resource is free as soon as the stream that held it ends. A connection lost with no notice to the
|
|
142
|
+
host stays live until the host sees that. The native harness has no shared session table and does
|
|
143
|
+
not make this check.
|
|
135
144
|
|
|
136
145
|
The shared engine currently reduces routed destinations to bare JIDs and
|
|
137
146
|
the Node host delivers to every bound resource. It does not implement full
|
|
@@ -203,5 +212,50 @@ The host closes a socket when a limit is reached. These are limits on bytes and
|
|
|
203
212
|
| `loginTimeoutMs` | `--login-timeout SECONDS` | 30 s | Time to bind a resource |
|
|
204
213
|
| `maxQueuedBytes` | | 8 MiB | Bytes waiting for a client that is not reading |
|
|
205
214
|
| `maxBuffered` | | 1 MiB | Bytes of an unfinished unit |
|
|
215
|
+
| `proxyHeaderTimeoutMs` | | 10 s | With `proxyProtocol`: time to send the PROXY header and, on a TLS listener, to finish the TLS handshake |
|
|
216
|
+
|
|
217
|
+
There is no connection limit per address: behind a proxy, every client has the same one.
|
|
218
|
+
|
|
219
|
+
## Limits the engine applies
|
|
206
220
|
|
|
207
|
-
|
|
221
|
+
| Option | CLI | Default | Limit |
|
|
222
|
+
|---|---|---|---|
|
|
223
|
+
| `heldLimits.perAccount`, `.perAccountBytes` | | 256 stanzas, 4 MiB | Held for one offline account |
|
|
224
|
+
| `heldLimits.total`, `.totalBytes` | | 10,000 stanzas, 64 MiB | Held on the server |
|
|
225
|
+
| `heldLimits.senderPerWindow`, `.senderWindow` | | 60 in 60 s | Stanzas one account may have held; roster writes of one account |
|
|
226
|
+
| `addressLimit: false` | `--no-address-limit` | on | Failed logins per client address (5 in 60 s, 20 in an hour). Turn it off only behind a proxy that sends no PROXY header, where every client has one address |
|
|
227
|
+
|
|
228
|
+
Fixed in the engine: 5 failed logins in 60 s per account name, 3 failed attempts on a stream, 1,000 items in a roster, 64 levels and 8,192 elements in a stanza, and room nicks of at most 64 bytes.
|
|
229
|
+
|
|
230
|
+
## Client addresses and the PROXY protocol
|
|
231
|
+
|
|
232
|
+
The server keeps no client address. At start the host makes a random 32-byte key (`addressKey` of `xmppOpen`) that is in
|
|
233
|
+
memory only and new at each start. The engine keeps, for each session, the tag HMAC-SHA-256(key, prefix), where the
|
|
234
|
+
prefix is the IPv4 address or the /64 of an IPv6 address (`LoginLimit.clientPrefix`, the one place that groups addresses;
|
|
235
|
+
`LoginLimit.addressTag`, theorem `addressTag_prefix`). The failed-login counters hold `address:` and the tag. A log line
|
|
236
|
+
shows the first 12 hexadecimal digits of the tag (`addressTag` of `xmppSessionOpen` and `xmppWsOpen`). After a restart
|
|
237
|
+
the tags change, and so do the counters, which are in memory too.
|
|
238
|
+
|
|
239
|
+
`proxyProtocol: true` (`xmpp-serve --proxy-protocol`) is for a listener behind a TCP proxy that ends no TLS, such as Fly
|
|
240
|
+
with `handlers = ["proxy_proto"]` and `proxy_proto_options = { version = "v2" }`. Every connection on every listener
|
|
241
|
+
(the main port, `--tls-port`, `--ws-port`) must start with a PROXY protocol version 2 header (HAProxy,
|
|
242
|
+
https://www.haproxy.org/download/2.9/doc/proxy-protocol.txt section 2.2), and the client address in it is the one the
|
|
243
|
+
limit counts. The host buffers the first bytes and calls `proxyHeaderParse` (IN region: the bytes so far) until the
|
|
244
|
+
engine decides (`L4Factoidal/Net/ProxyProtocol.lean`, `parse`):
|
|
245
|
+
|
|
246
|
+
| Decision | Host |
|
|
247
|
+
|---|---|
|
|
248
|
+
| `more` | Waits. Theorem `parse_more_bounded`: only while fewer than 1,040 bytes are held. `proxyHeaderTimeoutMs` closes a connection that never completes a header |
|
|
249
|
+
| `refuse`, with `reason` `signature`, `version`, `command`, `family` or `length` | Closes the connection. No XMPP and no TLS byte is read. A connection that starts with anything else (an XMPP stream header, a TLS ClientHello) is refused at its first byte |
|
|
250
|
+
| `local` | A connection the proxy made itself (a health check). Spec: the receiver uses the real endpoints, so the address is the socket's |
|
|
251
|
+
| `proxy`, with `address` and `port` | The address goes to the session as `remoteAddress` |
|
|
252
|
+
|
|
253
|
+
The bytes after the header, in the same read, go to TLS or to the XMPP session (theorems `parse_proxy_le`,
|
|
254
|
+
`parse_local_le`). PROXY is accepted for TCP over IPv4 (`0x11`) and IPv6 (`0x21`) only; UNSPEC, UNIX and UDP name no
|
|
255
|
+
client address for the limit and are refused. TLVs are skipped. A length field above 1,024 is refused. An IPv4-mapped IPv6
|
|
256
|
+
source is counted as its IPv4 address. Version 1 (the text form) is not read: Fly sends version 2 when asked, and a second
|
|
257
|
+
parser would only be one more input path.
|
|
258
|
+
|
|
259
|
+
Without `proxyProtocol` no header is read, so no client can claim an address; the socket's address is tagged the same way.
|
|
260
|
+
`proxyProtocol` on a listener that is reachable without the proxy lets a client choose its address for the limit: use it
|
|
261
|
+
only where every connection comes through the proxy.
|
package/xmpp/client.mjs
CHANGED
|
@@ -196,7 +196,9 @@ async function openStream (engine, config, options, onFeed, onClosed) {
|
|
|
196
196
|
* @param {string} [options.resource]
|
|
197
197
|
* @param {object[]} [options.mounts] the WebDAV handle's mounts, passed unread (the engine refuses writable local mounts without the opt-in)
|
|
198
198
|
* @param {boolean} [options.dangerouslyAllowWritableWithoutLogin]
|
|
199
|
-
* @param {
|
|
199
|
+
* @param {boolean} [options.login] the WebDAV handle has a users file (its `ready.login`, passed unread)
|
|
200
|
+
* @param {number} [options.tickMs] how often the clock is given to the engine (default 1000)
|
|
201
|
+
* @param {Function} options.step `async ({method, target, headers, body, requester}) => ({status, headers, body})`
|
|
200
202
|
* @param {object} [options.engine]
|
|
201
203
|
* @param {Function} [options.onError]
|
|
202
204
|
* @param {number} [options.maxBuffered] residual bytes the engine may hold before the socket is closed (default 16 MiB)
|
|
@@ -211,6 +213,7 @@ export async function connectHttpTarget (options = {}) {
|
|
|
211
213
|
config.allow = Array.isArray(options.allow) ? options.allow : []
|
|
212
214
|
if (Array.isArray(options.mounts)) config.mounts = options.mounts
|
|
213
215
|
if (options.dangerouslyAllowWritableWithoutLogin === true) config.dangerouslyAllowWritableWithoutLogin = true
|
|
216
|
+
if (options.login === true) config.login = true
|
|
214
217
|
|
|
215
218
|
let stream = null
|
|
216
219
|
/** One request the engine named: carried to `step`, answered. */
|
|
@@ -221,7 +224,10 @@ export async function connectHttpTarget (options = {}) {
|
|
|
221
224
|
method: request.method,
|
|
222
225
|
target: request.target,
|
|
223
226
|
headers: request.headers || [],
|
|
224
|
-
body: new Uint8Array(Buffer.from(request.bodyBase64 || '', 'base64'))
|
|
227
|
+
body: new Uint8Array(Buffer.from(request.bodyBase64 || '', 'base64')),
|
|
228
|
+
// The sender's bare JID as the engine named it (issue 1128),
|
|
229
|
+
// passed unread to the WebDAV handle's login.
|
|
230
|
+
requester: request.requester
|
|
225
231
|
})
|
|
226
232
|
answer = {
|
|
227
233
|
status: response.status,
|
|
@@ -243,11 +249,20 @@ export async function connectHttpTarget (options = {}) {
|
|
|
243
249
|
for (const r of answer.requests || []) carry(r)
|
|
244
250
|
})
|
|
245
251
|
const jid = await stream.bound
|
|
252
|
+
// The clock for the time limit of requests that wait for chunks
|
|
253
|
+
// (https://github.com/danbri/factoidal/issues/1128); the engine decides.
|
|
254
|
+
const timer = setInterval(() => {
|
|
255
|
+
if (!stream.isOpen()) return
|
|
256
|
+
try {
|
|
257
|
+
stream.write(call(engine, 'xmppClientTick', [stream.handle, String(Date.now())]).send)
|
|
258
|
+
} catch (_error) { /* handle gone */ }
|
|
259
|
+
}, typeof options.tickMs === 'number' ? options.tickMs : 1000)
|
|
260
|
+
stream.closed.then(() => clearInterval(timer))
|
|
246
261
|
return {
|
|
247
262
|
jid,
|
|
248
263
|
writableWithoutLogin: stream.opened.writableWithoutLogin || [],
|
|
249
264
|
closed: stream.closed,
|
|
250
|
-
close: () => stream.close()
|
|
265
|
+
close: () => { clearInterval(timer); return stream.close() }
|
|
251
266
|
}
|
|
252
267
|
}
|
|
253
268
|
|
package/xmpp/inpage.mjs
CHANGED
|
@@ -53,6 +53,8 @@ export async function openInPageXmppServer (options = {}) {
|
|
|
53
53
|
for (const k of ['domain', 'mucDomain', 'mixDomain', 'gc3Domain', 'saltB64']) {
|
|
54
54
|
if (typeof options[k] === 'string') config[k] = options[k]
|
|
55
55
|
}
|
|
56
|
+
if (typeof config.saltB64 !== 'string') config.saltSeed = randomToken()
|
|
57
|
+
config.occupantSeed = randomToken()
|
|
56
58
|
if (Array.isArray(options.accounts)) config.accounts = options.accounts
|
|
57
59
|
if (typeof options.iterationCount === 'number') config.iterationCount = options.iterationCount
|
|
58
60
|
const opened = engine.call('xmppOpen', [JSON.stringify(config)])
|
|
@@ -106,7 +108,11 @@ export async function openInPageXmppServer (options = {}) {
|
|
|
106
108
|
send (message) {
|
|
107
109
|
if (!open) throw new Error('the in-page XMPP connection is closed')
|
|
108
110
|
let answer
|
|
109
|
-
try {
|
|
111
|
+
try {
|
|
112
|
+
// The rate limits read the host's clock.
|
|
113
|
+
engine.call('xmppClock', [handle, String(Math.floor(Date.now() / 1000))])
|
|
114
|
+
answer = engine.call('xmppMsgFeed', [session, String(message)])
|
|
115
|
+
} catch (error) { onError(error); end(); return }
|
|
110
116
|
emit(answer.send || [])
|
|
111
117
|
router.apply(c, answer)
|
|
112
118
|
if (answer.close === true) end()
|