@factoidal/core 0.31.1 → 0.32.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 +12 -0
- package/bin/factoidal.mjs +48 -4
- package/bin/foafnaut-bleedingedge.mjs +4 -0
- package/bin/foafnaut-latest.mjs +4 -0
- package/bin/foafnaut.mjs +151 -0
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/version.mjs +1 -1
- package/package.json +5 -2
- package/xmpp/README.md +14 -0
- package/xmpp/server.mjs +92 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- XMPP: `xmpp-serve --starttls --tls-port N` serves STARTTLS and XEP-0368 direct TLS from one server.
|
|
6
|
+
- XMPP: members-only rooms (XEP-0045 section 7.2.6). `xmpp-serve --muc-members FILE` lists who may join each room; anyone else gets `registration-required` and cannot see the room.
|
|
7
|
+
- XMPP: disco#info and disco#items results carry `from`. Without it, Profanity 0.19.0 stopped with a segmentation fault after login.
|
|
8
|
+
|
|
9
|
+
## 0.32.0 (2026-11-05)
|
|
10
|
+
|
|
11
|
+
- XMPP: stanzas are held to RFC 6120 section 11.1. A DTD, a comment or a processing instruction is refused (https://github.com/danbri/factoidal-core/pull/355). Upgrade if you run `xmpp-serve`.
|
|
12
|
+
- XMPP: `xmpp-serve` limits connections (`--max-connections`, default 1024), the time to log in (`--login-timeout`, default 30 seconds) and the bytes queued for a client that is not reading (https://github.com/danbri/factoidal-core/pull/358).
|
|
13
|
+
- `foafnaut` command to start the Lean browser on macOS (https://github.com/danbri/factoidal-core/pull/349). The document renderer itself ships through the foafnaut build sets, not through this package.
|
|
14
|
+
|
|
3
15
|
## 0.31.1 (2026-11-05)
|
|
4
16
|
|
|
5
17
|
- Engine start uses less stack. Two more test fixtures ran with one stack frame for each byte when the engine started: `swapLabelGo` (`Storage/ShardManifest.lean`) and `bytesOfHexLiteral` (`Storage/LiteralGramIndexWire.lean`). Both are now loops. In 0.31.0, WebKit needed between 640 KB and 768 KB of stack to start the engine.
|
package/bin/factoidal.mjs
CHANGED
|
@@ -747,10 +747,11 @@ Specification: https://solidproject.org/TR/protocol`,
|
|
|
747
747
|
'xmpp-serve': `factoidal xmpp-serve - serve XMPP (RFC 6120 and RFC 6121) over TCP, STARTTLS or direct TLS
|
|
748
748
|
|
|
749
749
|
usage: factoidal xmpp-serve DIR [--domain NAME] [--port N] [--host ADDRESS]
|
|
750
|
-
[--accounts FILE] [--cert FILE --key FILE [--starttls]]
|
|
750
|
+
[--accounts FILE] [--cert FILE --key FILE [--starttls [--tls-port N]]]
|
|
751
751
|
[--ws-port N [--ws-path PATH] [--ws-origin ORIGIN]...]
|
|
752
|
-
[--muc DOMAIN] [--mix DOMAIN] [--gc3 DOMAIN]
|
|
753
|
-
[--state-key FILE]
|
|
752
|
+
[--muc DOMAIN [--muc-members FILE]] [--mix DOMAIN] [--gc3 DOMAIN]
|
|
753
|
+
[--state-key FILE] [--max-connections N]
|
|
754
|
+
[--login-timeout SECONDS]
|
|
754
755
|
|
|
755
756
|
Starts a Node TCP server that runs one engine session per connection and
|
|
756
757
|
answers every byte through the engine's xmppSessionFeed operation.
|
|
@@ -773,6 +774,8 @@ DIR/spool/<bare jid>/ is delivered to that account's bound session.
|
|
|
773
774
|
--starttls listen in clear and REQUIRE STARTTLS (RFC 6120 section 5)
|
|
774
775
|
before anything else; no SASL mechanism is offered and no
|
|
775
776
|
credential is accepted until the TLS handshake finishes
|
|
777
|
+
--tls-port N with --starttls, also serve XEP-0368 direct TLS on port N,
|
|
778
|
+
in the same server (the same rooms and sessions)
|
|
776
779
|
--ws-port N also serve XMPP over WebSocket (RFC 7395, the "xmpp"
|
|
777
780
|
subprotocol of RFC 6455) for browser clients such as
|
|
778
781
|
Strophe.js; wss: with --cert and --key, else loopback only
|
|
@@ -785,10 +788,19 @@ DIR/spool/<bare jid>/ is delivered to that account's bound session.
|
|
|
785
788
|
conference.example.com: temporary rooms, made by the first
|
|
786
789
|
join and gone with the last occupant; no history, no
|
|
787
790
|
configuration, no persistence (L4Factoidal/XMPP/Muc.lean)
|
|
791
|
+
--muc-members FILE members-only rooms: one room per line, as
|
|
792
|
+
"room: alice@example.com bob@example.com". Only those accounts
|
|
793
|
+
may join or see that room; a room not in the file is open to
|
|
794
|
+
every account (L4Factoidal/XMPP/Muc.lean)
|
|
788
795
|
--mix DOMAIN also serve XEP-0369 MIX channels at DOMAIN, e.g. mix.example.com,
|
|
789
796
|
joined through this server (XEP-0405 MIX-PAM); channels are
|
|
790
797
|
made by an IQ <create/>, kept in memory only, JID-hidden; no
|
|
791
798
|
PubSub nodes, no archive (L4Factoidal/XMPP/Mix.lean)
|
|
799
|
+
--max-connections N open connections of all kinds before a new one is closed
|
|
800
|
+
at once (default 1024). There is no limit per address:
|
|
801
|
+
behind a proxy, every client has the same one
|
|
802
|
+
--login-timeout SECONDS how long a connection may stay without a bound
|
|
803
|
+
resource before it is closed (default 30; 0 disables)
|
|
792
804
|
--state-key FILE keep the server's state across restarts: rosters, messages held
|
|
793
805
|
for offline accounts, MIX channels and GC3 groups, in an LWS
|
|
794
806
|
storage in DIR/state, sealed (ChaCha20-Poly1305) under the key
|
|
@@ -1211,7 +1223,7 @@ const VALUE_OPTIONS = {
|
|
|
1211
1223
|
'authz-serve': new Set(['port', 'host', 'issuer', 'resource', 'alg', 'token-lifetime', 'rotate-after',
|
|
1212
1224
|
'cors-origin', 'saml-idp', 'saml-decrypt-key']),
|
|
1213
1225
|
'authz-keys': new Set([]),
|
|
1214
|
-
'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key', 'ws-port', 'ws-path', 'ws-origin', 'muc', 'mix', 'gc3', 'state-key']),
|
|
1226
|
+
'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key', 'tls-port', 'ws-port', 'ws-path', 'ws-origin', 'muc', 'muc-members', 'mix', 'gc3', 'state-key', 'max-connections', 'login-timeout']),
|
|
1215
1227
|
'webdav-serve': new Set(['port', 'host', 'users', 'guest', 'cert', 'key', 'login-limit', 'login-window', 'xmpp', 'xmpp-password-file',
|
|
1216
1228
|
'xmpp-host', 'xmpp-port', 'xmpp-allow', 'xmpp-resource', 'xmpp-ca', 'allow-host', 'allow-origin']),
|
|
1217
1229
|
'webdav-user': new Set(['iterations']),
|
|
@@ -2873,6 +2885,12 @@ async function commandXmppServe (positional, options) {
|
|
|
2873
2885
|
settings.key = await fs.readFile(options.key)
|
|
2874
2886
|
}
|
|
2875
2887
|
if (startTls) settings.startTls = true
|
|
2888
|
+
if (options['tls-port'] !== undefined) {
|
|
2889
|
+
if (!startTls) throw new UsageError('--tls-port needs --starttls')
|
|
2890
|
+
const tlsPort = Number(options['tls-port'])
|
|
2891
|
+
if (!Number.isInteger(tlsPort) || tlsPort < 0 || tlsPort > 65535) throw new UsageError('--tls-port needs a port number')
|
|
2892
|
+
settings.directTlsPort = tlsPort
|
|
2893
|
+
}
|
|
2876
2894
|
if (options['state-key'] !== undefined) {
|
|
2877
2895
|
if (typeof options['state-key'] !== 'string') throw new UsageError('--state-key needs a FILE')
|
|
2878
2896
|
// 64 hex digits or base64url of 32 bytes; the engine checks it.
|
|
@@ -2884,6 +2902,20 @@ async function commandXmppServe (positional, options) {
|
|
|
2884
2902
|
}
|
|
2885
2903
|
settings.mucDomain = options.muc
|
|
2886
2904
|
}
|
|
2905
|
+
if (options['muc-members'] !== undefined) {
|
|
2906
|
+
if (typeof options['muc-members'] !== 'string') throw new UsageError('--muc-members needs a FILE')
|
|
2907
|
+
if (settings.mucDomain === undefined) throw new UsageError('--muc-members needs --muc')
|
|
2908
|
+
settings.mucMembers = []
|
|
2909
|
+
for (const line of (await fs.readFile(options['muc-members'], 'utf8')).split('\n')) {
|
|
2910
|
+
if (line.trim() === '') continue
|
|
2911
|
+
const [, room, list] = /^([^:\s@/]+):(.*)$/.exec(line.trim()) ?? []
|
|
2912
|
+
const members = (list ?? '').split(/\s+/).filter(Boolean)
|
|
2913
|
+
if (room === undefined || members.some((m) => !/^[^\s@/]+@[^\s@/]+$/.test(m))) {
|
|
2914
|
+
throw new UsageError(`--muc-members: not "room: bare@jid ...": ${line}`)
|
|
2915
|
+
}
|
|
2916
|
+
settings.mucMembers.push({ room, members })
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2887
2919
|
if (options.mix !== undefined) {
|
|
2888
2920
|
if (typeof options.mix !== 'string' || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(options.mix)) {
|
|
2889
2921
|
throw new UsageError('--mix needs a domain name, e.g. mix.example.com')
|
|
@@ -2898,6 +2930,16 @@ async function commandXmppServe (positional, options) {
|
|
|
2898
2930
|
if (options.gc3 === options.muc || options.gc3 === options.mix) throw new UsageError('--gc3 needs a domain of its own')
|
|
2899
2931
|
settings.gc3Domain = options.gc3
|
|
2900
2932
|
}
|
|
2933
|
+
if (options['max-connections'] !== undefined) {
|
|
2934
|
+
const n = Number(options['max-connections'])
|
|
2935
|
+
if (!Number.isSafeInteger(n) || n < 1) throw new UsageError('--max-connections needs a positive integer')
|
|
2936
|
+
settings.maxConnections = n
|
|
2937
|
+
}
|
|
2938
|
+
if (options['login-timeout'] !== undefined) {
|
|
2939
|
+
const n = Number(options['login-timeout'])
|
|
2940
|
+
if (!Number.isSafeInteger(n) || n < 0) throw new UsageError('--login-timeout needs a number of seconds')
|
|
2941
|
+
settings.loginTimeoutMs = n * 1000
|
|
2942
|
+
}
|
|
2901
2943
|
if (options['ws-port'] !== undefined) {
|
|
2902
2944
|
const wsPort = Number(options['ws-port'])
|
|
2903
2945
|
if (!Number.isInteger(wsPort) || wsPort < 0 || wsPort > 65535) throw new UsageError('--ws-port needs a port number')
|
|
@@ -2936,11 +2978,13 @@ async function commandXmppServe (positional, options) {
|
|
|
2936
2978
|
if (settings.stateKey) err(`xmpp-serve: state kept across restarts in ${positional[0]}/state, sealed under the state key`)
|
|
2937
2979
|
else err('xmpp-serve: WARNING: no --state-key: held messages, MIX channels and GC3 groups are lost at a restart, and rosters are written in clear')
|
|
2938
2980
|
}
|
|
2981
|
+
if (running.directOrigin && !quiet) err(`xmpp-serve: direct TLS (XEP-0368) on ${running.directOrigin}`)
|
|
2939
2982
|
if (running.wsOrigin && !quiet) {
|
|
2940
2983
|
err(`xmpp-serve: XMPP over WebSocket on ${running.wsOrigin}` +
|
|
2941
2984
|
(settings.websocket.allowedOrigins.length ? ` for Origin ${settings.websocket.allowedOrigins.join(', ')}` : ' (no browser Origin allowed)'))
|
|
2942
2985
|
}
|
|
2943
2986
|
out(running.origin)
|
|
2987
|
+
if (running.directOrigin) out(running.directOrigin)
|
|
2944
2988
|
if (running.wsOrigin) out(running.wsOrigin)
|
|
2945
2989
|
return untilInterrupted(running, quiet)
|
|
2946
2990
|
}
|
package/bin/foafnaut.mjs
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// foafnaut: start foafnaut2525, the Lean web rendering engine, in its own window.
|
|
3
|
+
// This launcher only chooses and starts an installed macOS app. All page work is
|
|
4
|
+
// in that app. Layout: $FOAFNAUT_HOME (default ~/.foafnaut), versions/<commit>/,
|
|
5
|
+
// current -> versions/<commit>, config.json {"bleedingEdge":"/path/to/App.app"}.
|
|
6
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { basename, join, resolve } from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const HELP = `foafnaut [--latest|--bleeding-edge] [PAGE] [-- APP_ARGS]
|
|
14
|
+
|
|
15
|
+
PAGE an http(s) URL or an HTML file; none opens the default page
|
|
16
|
+
--latest install the newest published macOS build, then use it
|
|
17
|
+
--bleeding-edge use the local build (config.json "bleedingEdge", or
|
|
18
|
+
~/Applications/foafnaut.app)
|
|
19
|
+
--verbose print the choice of app, follow the trace, and log input
|
|
20
|
+
--detach do not wait for the window to close
|
|
21
|
+
--install-latest install the newest published build and stop
|
|
22
|
+
--list list installed versions
|
|
23
|
+
--which print the app that would run
|
|
24
|
+
--help this text
|
|
25
|
+
|
|
26
|
+
Also: foafnaut-latest, foafnaut-bleedingedge. Environment: FOAFNAUT_HOME, and
|
|
27
|
+
FOAFNAUT_APP, which chooses the app outright. Page data goes to the app. Its trace
|
|
28
|
+
is in ~/Library/Logs/foafnaut/trace.jsonl. The command stays open until the window
|
|
29
|
+
closes; --detach returns at once.
|
|
30
|
+
|
|
31
|
+
EXPERIMENTAL UNDER-REVIEWED CODE - USE WITH CARE - DO NOT CREATE PUBLIC SERVICES USING THIS WITHOUT CAREFUL INDEPENDENT EXPERT REVIEW.
|
|
32
|
+
`;
|
|
33
|
+
const RELEASES = 'https://api.github.com/repos/danbri/factoidal-builds/releases?per_page=60';
|
|
34
|
+
const APP = 'foafnaut.app';
|
|
35
|
+
const home = () => process.env.FOAFNAUT_HOME || join(homedir(), '.foafnaut');
|
|
36
|
+
const fail = (message) => { console.error(`foafnaut: ${message}`); process.exit(1); };
|
|
37
|
+
|
|
38
|
+
function config() {
|
|
39
|
+
const path = join(home(), 'config.json');
|
|
40
|
+
if (!existsSync(path)) return {};
|
|
41
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch (error) { fail(`${path}: ${error.message}`); }
|
|
42
|
+
}
|
|
43
|
+
const bleedingEdgeApp = () => config().bleedingEdge || join(homedir(), 'Applications', APP);
|
|
44
|
+
function versionApp(dir) {
|
|
45
|
+
const app = join(dir, 'foafnaut', APP);
|
|
46
|
+
return existsSync(app) ? app : undefined;
|
|
47
|
+
}
|
|
48
|
+
function currentApp() {
|
|
49
|
+
const current = join(home(), 'current');
|
|
50
|
+
return existsSync(current) ? versionApp(current) : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function installLatest() {
|
|
54
|
+
const response = await fetch(RELEASES, { headers: { accept: 'application/vnd.github+json', 'user-agent': 'foafnaut' } });
|
|
55
|
+
if (!response.ok) fail(`release list: HTTP ${response.status}`);
|
|
56
|
+
// The release list is not in publication order.
|
|
57
|
+
const release = (await response.json())
|
|
58
|
+
.filter((r) => /^build-[0-9a-f]{12}-foafnaut-darwin-arm64$/.test(r.tag_name) && r.published_at)
|
|
59
|
+
.sort((a, b) => b.published_at.localeCompare(a.published_at))[0];
|
|
60
|
+
if (!release) fail('no published macOS build');
|
|
61
|
+
const commit = release.tag_name.split('-')[1];
|
|
62
|
+
const dir = join(home(), 'versions', commit);
|
|
63
|
+
if (!versionApp(dir)) {
|
|
64
|
+
const asset = (name) => release.assets.find((a) => a.name === name)?.browser_download_url ?? fail(`${release.tag_name}: no ${name}`);
|
|
65
|
+
const download = async (url) => {
|
|
66
|
+
const reply = await fetch(url, { headers: { 'user-agent': 'foafnaut' } });
|
|
67
|
+
if (!reply.ok) fail(`${url}: HTTP ${reply.status}`);
|
|
68
|
+
return Buffer.from(await reply.arrayBuffer());
|
|
69
|
+
};
|
|
70
|
+
console.error(`foafnaut: downloading ${release.tag_name}`);
|
|
71
|
+
const info = JSON.parse((await download(asset('build-info.json'))).toString('utf8'));
|
|
72
|
+
const archive = await download(asset('foafnaut-darwin-arm64.tar.gz'));
|
|
73
|
+
const expected = info.artifacts?.['foafnaut-darwin-arm64.tar.gz']?.sha256;
|
|
74
|
+
const actual = createHash('sha256').update(archive).digest('hex');
|
|
75
|
+
if (!expected) fail(`${release.tag_name}: build-info.json has no archive digest`);
|
|
76
|
+
if (expected !== actual) fail(`${release.tag_name}: archive digest ${actual} is not ${expected}`);
|
|
77
|
+
const staging = `${dir}.tmp`;
|
|
78
|
+
rmSync(staging, { recursive: true, force: true });
|
|
79
|
+
mkdirSync(staging, { recursive: true });
|
|
80
|
+
writeFileSync(join(staging, 'archive.tar.gz'), archive);
|
|
81
|
+
const unpack = spawnSync('tar', ['-xzf', 'archive.tar.gz'], { cwd: staging, stdio: 'inherit' });
|
|
82
|
+
if (unpack.status !== 0) fail('tar failed');
|
|
83
|
+
rmSync(join(staging, 'archive.tar.gz'));
|
|
84
|
+
writeFileSync(join(staging, 'build-info.json'), JSON.stringify(info, null, 2));
|
|
85
|
+
if (!versionApp(staging)) {
|
|
86
|
+
rmSync(staging, { recursive: true, force: true });
|
|
87
|
+
fail(`${release.tag_name} has no ${APP}; it predates the native window. Use --bleeding-edge.`);
|
|
88
|
+
}
|
|
89
|
+
rmSync(dir, { recursive: true, force: true });
|
|
90
|
+
renameSync(staging, dir);
|
|
91
|
+
}
|
|
92
|
+
const link = join(home(), 'current');
|
|
93
|
+
rmSync(link, { force: true });
|
|
94
|
+
symlinkSync(join('versions', commit), link);
|
|
95
|
+
console.error(`foafnaut: current is ${commit}`);
|
|
96
|
+
return versionApp(dir);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function pageArgs(page) {
|
|
100
|
+
if (page === undefined) return [];
|
|
101
|
+
if (/^https?:\/\//i.test(page)) return ['--url', page];
|
|
102
|
+
const path = resolve(page);
|
|
103
|
+
if (!existsSync(path)) fail(`no such file: ${page}`);
|
|
104
|
+
return ['--html', path];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function main(argv, channel = 'current') {
|
|
108
|
+
const split = argv.indexOf('--');
|
|
109
|
+
const options = split < 0 ? argv : argv.slice(0, split);
|
|
110
|
+
const extra = split < 0 ? [] : argv.slice(split + 1);
|
|
111
|
+
let page, which = false, verbose = false, detach = false;
|
|
112
|
+
// Each of these picks the app or the action. Two of them have no one meaning.
|
|
113
|
+
const exclusive = options.filter((arg) => ['--latest', '--bleeding-edge', '--install-latest', '--list'].includes(arg));
|
|
114
|
+
if (new Set(exclusive).size > 1) fail(`${exclusive.join(' and ')} can not be used together; see --help`);
|
|
115
|
+
for (const arg of options) {
|
|
116
|
+
if (arg === '--help' || arg === '-h') { process.stdout.write(HELP); return; }
|
|
117
|
+
else if (arg === '--latest') channel = 'latest';
|
|
118
|
+
else if (arg === '--bleeding-edge') channel = 'bleeding-edge';
|
|
119
|
+
else if (arg === '--which') which = true;
|
|
120
|
+
else if (arg === '--verbose' || arg === '-v') verbose = true;
|
|
121
|
+
else if (arg === '--detach') detach = true;
|
|
122
|
+
else if (arg === '--install-latest') { await installLatest(); return; }
|
|
123
|
+
else if (arg === '--list') {
|
|
124
|
+
const versions = join(home(), 'versions');
|
|
125
|
+
const current = existsSync(join(home(), 'current')) ? basename(realpathSync(join(home(), 'current'))) : '';
|
|
126
|
+
for (const name of existsSync(versions) ? readdirSync(versions).filter((n) => !n.endsWith('.tmp')) : [])
|
|
127
|
+
console.log(`${name === current ? '*' : ' '} ${name}`);
|
|
128
|
+
console.log(` bleeding edge: ${bleedingEdgeApp()}${existsSync(bleedingEdgeApp()) ? '' : ' (not found)'}`);
|
|
129
|
+
return;
|
|
130
|
+
} else if (arg.startsWith('-')) fail(`unknown option ${arg}; see --help`);
|
|
131
|
+
else if (page !== undefined) fail('give one page');
|
|
132
|
+
else page = arg;
|
|
133
|
+
}
|
|
134
|
+
if (process.platform !== 'darwin') fail('the native window is macOS only; use the Hub page in a WebGPU browser');
|
|
135
|
+
let app = process.env.FOAFNAUT_APP;
|
|
136
|
+
if (!app && channel === 'latest') app = await installLatest();
|
|
137
|
+
if (!app && channel === 'bleeding-edge') app = bleedingEdgeApp();
|
|
138
|
+
if (!app) app = currentApp() ?? bleedingEdgeApp();
|
|
139
|
+
if (!existsSync(app)) fail(`no app at ${app}. Run foafnaut --install-latest.`);
|
|
140
|
+
if (which) { console.log(app); return; }
|
|
141
|
+
const args = ['-n', ...(detach ? [] : ['-W']), app, '--args', ...pageArgs(page), ...(verbose ? ['--trace-input'] : []), ...extra];
|
|
142
|
+
const log = join(homedir(), 'Library', 'Logs', 'foafnaut', 'trace.jsonl');
|
|
143
|
+
if (verbose) console.error(`foafnaut: app ${app}\nfoafnaut: open ${args.join(' ')}\nfoafnaut: trace ${log}`);
|
|
144
|
+
// Follow only what this run appends.
|
|
145
|
+
const follow = verbose ? spawn('tail', ['-n', '0', '-F', log], { stdio: ['ignore', 'inherit', 'ignore'] }) : undefined;
|
|
146
|
+
const run = spawnSync('open', args, { stdio: 'inherit' });
|
|
147
|
+
follow?.kill();
|
|
148
|
+
process.exit(run.status ?? 1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) await main(process.argv.slice(2));
|
package/l4-assets/l4factoidal.js
CHANGED
|
Binary file
|
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-54551baca31a-web",
|
|
6
|
+
"releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/42216947974",
|
|
7
|
+
"gitSha": "54551baca31ae999900422c62ac59622ebf4d20a",
|
|
8
|
+
"builtAt": "2026-11-06T13:04:35Z",
|
|
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": "7e47fa1911434e286cd6523c2b622e183ea6837531d444b9691a2434486f5c4e",
|
|
15
|
+
"wasmBytes": 31415350,
|
|
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/version.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@factoidal/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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",
|
|
@@ -35,7 +35,10 @@
|
|
|
35
35
|
"browser": "./browser.js",
|
|
36
36
|
"types": "./index.d.ts",
|
|
37
37
|
"bin": {
|
|
38
|
-
"factoidal": "./bin/factoidal.mjs"
|
|
38
|
+
"factoidal": "./bin/factoidal.mjs",
|
|
39
|
+
"foafnaut": "./bin/foafnaut.mjs",
|
|
40
|
+
"foafnaut-latest": "./bin/foafnaut-latest.mjs",
|
|
41
|
+
"foafnaut-bleedingedge": "./bin/foafnaut-bleedingedge.mjs"
|
|
39
42
|
},
|
|
40
43
|
"exports": {
|
|
41
44
|
".": {
|
package/xmpp/README.md
CHANGED
|
@@ -66,6 +66,7 @@ mkdir -p state && printf 'juliet:r0m30\nromeo:juli3t\n' > state/accounts
|
|
|
66
66
|
npx factoidal xmpp-serve state --domain localhost # plaintext on 5222
|
|
67
67
|
npx factoidal xmpp-serve state --cert fullchain.pem --key privkey.pem # XEP-0368 direct TLS on 5223
|
|
68
68
|
npx factoidal xmpp-serve state --cert fullchain.pem --key privkey.pem --starttls # STARTTLS required on 5222
|
|
69
|
+
npx factoidal xmpp-serve state --cert fullchain.pem --key privkey.pem --starttls --tls-port 5223 # both, one server
|
|
69
70
|
```
|
|
70
71
|
|
|
71
72
|
Without `--cert` and `--key` the listener has no TLS and offers PLAIN, so
|
|
@@ -191,3 +192,16 @@ node --test test/xmpp.test.mjs
|
|
|
191
192
|
Skips wholesale when the Lean wasm assets aren't resolvable, and skips
|
|
192
193
|
per-op when the resolved bundle predates a given op (see
|
|
193
194
|
`xmppOpsAvailable`) — the same pattern `test/l4-core.test.js` uses.
|
|
195
|
+
|
|
196
|
+
## Transport limits
|
|
197
|
+
|
|
198
|
+
The host closes a socket when a limit is reached. These are limits on bytes and time; every protocol decision stays in Lean.
|
|
199
|
+
|
|
200
|
+
| Option | CLI | Default | Limit |
|
|
201
|
+
|---|---|---|---|
|
|
202
|
+
| `maxConnections` | `--max-connections N` | 1024 | Open connections of all kinds |
|
|
203
|
+
| `loginTimeoutMs` | `--login-timeout SECONDS` | 30 s | Time to bind a resource |
|
|
204
|
+
| `maxQueuedBytes` | | 8 MiB | Bytes waiting for a client that is not reading |
|
|
205
|
+
| `maxBuffered` | | 1 MiB | Bytes of an unfinished unit |
|
|
206
|
+
|
|
207
|
+
There is no limit per address: behind a proxy, every client has the same one.
|
package/xmpp/server.mjs
CHANGED
|
@@ -37,6 +37,9 @@
|
|
|
37
37
|
// server-to-server federation, and any limit on how many bytes
|
|
38
38
|
// a client may send without completing a unit beyond `maxBuffered`,
|
|
39
39
|
// which is a transport limit this host applies by closing the socket.
|
|
40
|
+
// The other transport limits are `maxConnections`, `loginTimeoutMs` and
|
|
41
|
+
// `maxQueuedBytes`. There is no limit per address: behind a proxy, every
|
|
42
|
+
// client has the same one.
|
|
40
43
|
|
|
41
44
|
import { randomBytes } from 'node:crypto'
|
|
42
45
|
import { StringDecoder } from 'node:string_decoder'
|
|
@@ -135,6 +138,8 @@ export async function openXmppServer (options = {}) {
|
|
|
135
138
|
if (typeof options.tlsCarrier === 'boolean') config.tlsCarrier = options.tlsCarrier
|
|
136
139
|
// XEP-0045 room service (L4Factoidal/XMPP/Muc.lean); off unless named.
|
|
137
140
|
if (typeof options.mucDomain === 'string') config.mucDomain = options.mucDomain
|
|
141
|
+
// Members-only rooms: [{room, members: [bare JID]}]; no list, an open room.
|
|
142
|
+
if (Array.isArray(options.mucMembers)) config.mucMembers = options.mucMembers
|
|
138
143
|
// XEP-0369 channels (L4Factoidal/XMPP/Mix.lean), joined through MIX-PAM; off unless named.
|
|
139
144
|
if (typeof options.mixDomain === 'string') config.mixDomain = options.mixDomain
|
|
140
145
|
// GC3 prototype groups (L4Factoidal/XMPP/Gc3.lean; no published spec); off unless named.
|
|
@@ -157,6 +162,11 @@ export async function openXmppServer (options = {}) {
|
|
|
157
162
|
throw new XmppHostError('xmppOpen answered with no handle')
|
|
158
163
|
}
|
|
159
164
|
const handle = opened.handle
|
|
165
|
+
// An older engine ignores the list, and the rooms would be open: do not start.
|
|
166
|
+
if (Array.isArray(options.mucMembers) && opened.mucMembers !== options.mucMembers.length) {
|
|
167
|
+
engine.call('xmppClose', [handle])
|
|
168
|
+
throw new XmppHostError('this engine does not carry members-only rooms; rebuild the engine')
|
|
169
|
+
}
|
|
160
170
|
return {
|
|
161
171
|
engine,
|
|
162
172
|
handle,
|
|
@@ -243,10 +253,18 @@ async function readAccountsFile (path) {
|
|
|
243
253
|
* @param {boolean} [options.startTls] listen in clear and use `cert` and
|
|
244
254
|
* `key` for STARTTLS (RFC 6120 section 5), which the engine then requires
|
|
245
255
|
* before anything else; without it, `cert` and `key` mean direct TLS
|
|
256
|
+
* @param {number} [options.directTlsPort] with `startTls`, `listen` also serves
|
|
257
|
+
* XEP-0368 direct TLS on this port, in the same server: one set of rooms
|
|
246
258
|
* @param {number} [options.pollMs] spool poll interval (default 250; 0 disables)
|
|
247
259
|
* @param {number} [options.maxBuffered] residual UTF-8 bytes held by the engine
|
|
248
260
|
* after a feed before the socket is closed (default 1 MiB); the decoder
|
|
249
261
|
* may separately hold up to three bytes of an incomplete UTF-8 sequence
|
|
262
|
+
* @param {number} [options.maxConnections] open connections, of all kinds, before
|
|
263
|
+
* a new one is closed at once (default 1024)
|
|
264
|
+
* @param {number} [options.loginTimeoutMs] how long a connection may stay
|
|
265
|
+
* without a bound resource before it is closed (default 30000; 0 disables)
|
|
266
|
+
* @param {number} [options.maxQueuedBytes] bytes waiting to be written to a
|
|
267
|
+
* client that is not reading, before the socket is closed (default 8 MiB)
|
|
250
268
|
* @param {Function} [options.onError]
|
|
251
269
|
* @param {Function} [options.onLog]
|
|
252
270
|
* @returns {Promise<{server: object, xmpp: object, close: Function, sessions: Function}>}
|
|
@@ -259,6 +277,14 @@ export async function createXmppServer (options = {}) {
|
|
|
259
277
|
const dir = typeof options.dir === 'string' ? options.dir : null
|
|
260
278
|
const pollMs = typeof options.pollMs === 'number' ? options.pollMs : 250
|
|
261
279
|
const maxBuffered = typeof options.maxBuffered === 'number' ? options.maxBuffered : 1024 * 1024
|
|
280
|
+
const limit = (name, fallback) => {
|
|
281
|
+
const value = typeof options[name] === 'number' ? options[name] : fallback
|
|
282
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new XmppHostError(`${name} must be a nonnegative safe integer`)
|
|
283
|
+
return value
|
|
284
|
+
}
|
|
285
|
+
const maxConnections = limit('maxConnections', 1024)
|
|
286
|
+
const loginTimeoutMs = limit('loginTimeoutMs', 30000)
|
|
287
|
+
const maxQueuedBytes = limit('maxQueuedBytes', 8 * 1024 * 1024)
|
|
262
288
|
if (!Number.isSafeInteger(maxBuffered) || maxBuffered < 0) {
|
|
263
289
|
throw new XmppHostError('maxBuffered must be a nonnegative safe integer')
|
|
264
290
|
}
|
|
@@ -385,8 +411,39 @@ export async function createXmppServer (options = {}) {
|
|
|
385
411
|
// connection callback and therefore have no engine session yet.
|
|
386
412
|
const transports = new Set()
|
|
387
413
|
|
|
414
|
+
// Transport limits, like `maxBuffered`: bytes and time, not protocol.
|
|
415
|
+
function closeIfBacklogged (c) {
|
|
416
|
+
if (c.socket.destroyed || !(c.socket.writableLength > maxQueuedBytes)) return
|
|
417
|
+
onError(new XmppHostError(`connection exceeded ${maxQueuedBytes} queued bytes`))
|
|
418
|
+
c.socket.destroy()
|
|
419
|
+
}
|
|
420
|
+
|
|
388
421
|
function writeAll (c, lines) {
|
|
389
422
|
for (const line of lines) if (!c.socket.destroyed) c.socket.write(line)
|
|
423
|
+
closeIfBacklogged(c)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A connection gets `loginTimeoutMs` to bind a resource. */
|
|
427
|
+
function startLoginTimer (c) {
|
|
428
|
+
if (loginTimeoutMs === 0) return
|
|
429
|
+
c.loginTimer = setTimeout(() => {
|
|
430
|
+
if (c.full === null && connections.has(c)) {
|
|
431
|
+
onLog('closing a connection that did not log in in time')
|
|
432
|
+
c.socket.destroy()
|
|
433
|
+
}
|
|
434
|
+
}, loginTimeoutMs)
|
|
435
|
+
c.loginTimer.unref()
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Count a new socket, or close it at once if the server is full. */
|
|
439
|
+
function admit (socket) {
|
|
440
|
+
if (transports.size >= maxConnections) {
|
|
441
|
+
onError(new XmppHostError(`refused a connection: ${maxConnections} are open`))
|
|
442
|
+
socket.destroy()
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
transports.add(socket)
|
|
446
|
+
socket.once('close', () => transports.delete(socket))
|
|
390
447
|
}
|
|
391
448
|
|
|
392
449
|
let rosterWrites = Promise.resolve()
|
|
@@ -471,6 +528,7 @@ export async function createXmppServer (options = {}) {
|
|
|
471
528
|
function endSession (c) {
|
|
472
529
|
if (!connections.has(c)) return
|
|
473
530
|
connections.delete(c)
|
|
531
|
+
clearTimeout(c.loginTimer)
|
|
474
532
|
let answer
|
|
475
533
|
try { answer = c.session.close() } catch (error) { onError(error); answer = { send: [] } }
|
|
476
534
|
// The session left its MUC rooms (Muc.leaveAll): tell the other occupants.
|
|
@@ -482,10 +540,10 @@ export async function createXmppServer (options = {}) {
|
|
|
482
540
|
}
|
|
483
541
|
}
|
|
484
542
|
|
|
485
|
-
function onConnection (socket) {
|
|
543
|
+
function onConnection (socket, direct = false) {
|
|
486
544
|
let session
|
|
487
545
|
try {
|
|
488
|
-
session = xmpp.openSession({ tlsCarrier: useTls, startTls })
|
|
546
|
+
session = xmpp.openSession({ tlsCarrier: useTls || direct, startTls: startTls && !direct })
|
|
489
547
|
} catch (error) {
|
|
490
548
|
onError(error)
|
|
491
549
|
socket.destroy()
|
|
@@ -505,6 +563,7 @@ export async function createXmppServer (options = {}) {
|
|
|
505
563
|
}
|
|
506
564
|
}
|
|
507
565
|
connections.add(c)
|
|
566
|
+
startLoginTimer(c)
|
|
508
567
|
onLog(`connection from ${socket.remoteAddress}:${socket.remotePort}`)
|
|
509
568
|
c.onData = (chunk) => {
|
|
510
569
|
if (!connections.has(c) || c.closing || c.upgrading) return
|
|
@@ -579,11 +638,12 @@ export async function createXmppServer (options = {}) {
|
|
|
579
638
|
},
|
|
580
639
|
deliver (text) {
|
|
581
640
|
const got = wsCall('xmppWsDeliver', [handle, text], new Uint8Array(0))
|
|
582
|
-
if (got.envelope.delivered === true) { if (!socket.destroyed) socket.write(got.bytes); return true }
|
|
641
|
+
if (got.envelope.delivered === true) { if (!socket.destroyed) socket.write(got.bytes); closeIfBacklogged(c); return true }
|
|
583
642
|
return false
|
|
584
643
|
}
|
|
585
644
|
}
|
|
586
645
|
connections.add(c)
|
|
646
|
+
startLoginTimer(c)
|
|
587
647
|
socket.on('data', (chunk) => {
|
|
588
648
|
if (!connections.has(c) || c.closing) return
|
|
589
649
|
let got
|
|
@@ -623,29 +683,32 @@ export async function createXmppServer (options = {}) {
|
|
|
623
683
|
const net = await import('node:net')
|
|
624
684
|
wsServer = net.createServer(onWsConnection)
|
|
625
685
|
}
|
|
626
|
-
wsServer.on('connection',
|
|
627
|
-
transports.add(socket)
|
|
628
|
-
socket.once('close', () => transports.delete(socket))
|
|
629
|
-
})
|
|
686
|
+
wsServer.on('connection', admit)
|
|
630
687
|
}
|
|
631
688
|
|
|
632
689
|
let server
|
|
633
690
|
try {
|
|
634
691
|
if (useTls) {
|
|
635
692
|
const tls = await import('node:tls')
|
|
636
|
-
server = tls.createServer({ cert: options.cert, key: options.key }, onConnection)
|
|
693
|
+
server = tls.createServer({ cert: options.cert, key: options.key }, (socket) => onConnection(socket))
|
|
637
694
|
} else {
|
|
638
695
|
const net = await import('node:net')
|
|
639
|
-
server = net.createServer(onConnection)
|
|
696
|
+
server = net.createServer((socket) => onConnection(socket))
|
|
640
697
|
}
|
|
641
698
|
} catch (error) {
|
|
642
699
|
xmpp.close()
|
|
643
700
|
throw error
|
|
644
701
|
}
|
|
645
|
-
server.on('connection',
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
702
|
+
server.on('connection', admit)
|
|
703
|
+
|
|
704
|
+
// XEP-0368 direct TLS beside a STARTTLS listener: the engine takes its
|
|
705
|
+
// answer per session, so this differs only in what `onConnection` passes.
|
|
706
|
+
let directServer = null
|
|
707
|
+
if (startTls && options.directTlsPort !== undefined) {
|
|
708
|
+
const tls = await import('node:tls')
|
|
709
|
+
directServer = tls.createServer({ cert: options.cert, key: options.key }, (socket) => onConnection(socket, true))
|
|
710
|
+
directServer.on('connection', admit)
|
|
711
|
+
}
|
|
649
712
|
|
|
650
713
|
// The harness's mailbox: `<dir>/spool/<bare jid>/<name>` holds one
|
|
651
714
|
// stanza's bytes for that account. Polled for every bound session,
|
|
@@ -693,6 +756,7 @@ export async function createXmppServer (options = {}) {
|
|
|
693
756
|
return {
|
|
694
757
|
server,
|
|
695
758
|
wsServer,
|
|
759
|
+
directServer,
|
|
696
760
|
xmpp,
|
|
697
761
|
/** The live connections' `{bare, full}` pairs, for a host's own view. */
|
|
698
762
|
sessions () {
|
|
@@ -714,6 +778,7 @@ export async function createXmppServer (options = {}) {
|
|
|
714
778
|
try {
|
|
715
779
|
await new Promise(resolve => server.close(resolve))
|
|
716
780
|
if (wsServer !== null) await new Promise(resolve => wsServer.close(resolve))
|
|
781
|
+
if (directServer !== null) await new Promise(resolve => directServer.close(resolve))
|
|
717
782
|
await pollTask
|
|
718
783
|
await rosterWrites
|
|
719
784
|
await stateWrites
|
|
@@ -765,7 +830,20 @@ export async function listen (options = {}) {
|
|
|
765
830
|
const path = typeof options.websocket.path === 'string' ? options.websocket.path : '/xmpp-websocket'
|
|
766
831
|
wsOrigin = `${wsScheme}://${host}:${created.wsServer.address().port}${path}`
|
|
767
832
|
}
|
|
833
|
+
let directOrigin = null
|
|
834
|
+
if (created.directServer !== null) {
|
|
835
|
+
try {
|
|
836
|
+
await new Promise((resolve, reject) => {
|
|
837
|
+
created.directServer.once('error', reject)
|
|
838
|
+
created.directServer.listen(options.directTlsPort, host, resolve)
|
|
839
|
+
})
|
|
840
|
+
} catch (error) {
|
|
841
|
+
await created.close()
|
|
842
|
+
throw error
|
|
843
|
+
}
|
|
844
|
+
directOrigin = `xmpps://${host}:${created.directServer.address().port}`
|
|
845
|
+
}
|
|
768
846
|
const bound = created.server.address()
|
|
769
847
|
const scheme = options.cert !== undefined && options.key !== undefined ? 'xmpps' : 'xmpp'
|
|
770
|
-
return { ...created, port: bound.port, host, origin: `${scheme}://${host}:${bound.port}`, wsOrigin }
|
|
848
|
+
return { ...created, port: bound.port, host, origin: `${scheme}://${host}:${bound.port}`, wsOrigin, directOrigin }
|
|
771
849
|
}
|