@factoidal/core 0.31.0 → 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 CHANGED
@@ -1,5 +1,21 @@
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
+
15
+ ## 0.31.1 (2026-11-05)
16
+
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.
18
+
3
19
  ## 0.31.0 (2026-11-05)
4
20
 
5
21
  - Stack use on phones. The engine failed on iPhone Safari with "Maximum call stack size exceeded". Two faults are fixed:
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
  }
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // foafnaut with the bleeding-edge channel; see foafnaut.mjs.
3
+ import { main } from './foafnaut.mjs';
4
+ await main(process.argv.slice(2), 'bleeding-edge');
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // foafnaut with the latest channel; see foafnaut.mjs.
3
+ import { main } from './foafnaut.mjs';
4
+ await main(process.argv.slice(2), 'latest');
@@ -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));
@@ -37,7 +37,7 @@
37
37
  // bytes change.
38
38
 
39
39
  // Stamped by formal/lean4/Wasm/build-wasm.sh step 9 -- do not hand-edit.
40
- const WASM_VERSION = "14599aeba24a";
40
+ const WASM_VERSION = "7e47fa191143";
41
41
 
42
42
  import createModule from './l4factoidal.mjs';
43
43
 
Binary file
@@ -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-ace53d982f83-web",
6
- "releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/42050112010",
7
- "gitSha": "ace53d982f83e6bf0a1a749a5e800481cc092b9f",
8
- "builtAt": "2026-11-05T16:35:31Z",
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": "14599aeba24a46bf05e1c7ce8aa885df536fe1398a797034eff107e04fa4ebc3",
15
- "wasmBytes": 31371405,
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
@@ -2,4 +2,4 @@
2
2
  // needs import attributes, which not every runtime this package targets
3
3
  // reads). test/dts-drift.test.js checks that it equals package.json's
4
4
  // `version`.
5
- export const version = '0.31.0';
5
+ export const version = '0.32.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.31.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', socket => {
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', socket => {
646
- transports.add(socket)
647
- socket.once('close', () => transports.delete(socket))
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
  }