@factoidal/core 0.31.1 → 0.33.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,29 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.33.0 (2026-11-06)
6
+
7
+ - XMPP: held stanzas have caps per recipient and per server and a rate per sending account. A stanza for a name with no account is not held. Rosters have a cap on items and a rate on writes.
8
+ - XMPP: SASL closes a stream after three failed attempts, adds a slower limit per client address, and uses one SCRAM exchange for every name. `--no-address-limit` is for a listener behind a proxy.
9
+ - XMPP: accounts have a salt per name and more SCRAM iterations by default. An accounts line can hold keys in place of a password (`xmpp-serve --verifier NAME`). A malformed accounts file stops the start.
10
+ - XMPP: a stanza has at most 64 levels and 8192 elements. Rooms have reserved nicks and nick checks, and a session leaves its rooms at a clean stream end.
11
+ - XMPP: XEP-0421 occupant ids in rooms. A room seat stays until the last session of a full JID goes.
12
+ - XMPP: `xmpp-serve --starttls --tls-port N` serves STARTTLS and XEP-0368 direct TLS from one server.
13
+ - 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.
14
+ - XMPP: disco#info and disco#items results carry `from`. Without it, Profanity 0.19.0 stopped with a segmentation fault after login.
15
+ - The URL decisions of the LWS SBM11 reader and of the compositor's pod storage are now in Lean (`L4Factoidal/LWS/ContainerPath.lean`, new pure wasm op `containerPath`), not in JavaScript: the container URL, each resource URL from a raw relative path, one new file name, a URL inside a pod root, and the folder above (https://github.com/danbri/factoidal/issues/1127). Theorems `resourceIn_under`, `segmentIn_under`, `within_under`, `parentIn_under` (an accepted URL has the container's origin and the container URL, which ends with `/`, is its prefix) and `within_sibling_refused` (`https://s.example/alice-other/` is not within `https://s.example/alice/`). `lwsSbm11Reader` is now asynchronous (`await lwsSbm11Reader(url, opts)`): it loads the engine first; `opts.engine` gives one. An empty `?` or `#` in a container URL is now refused too. The LWS client's token scope (`TokenEntry.covers`) has the same path-segment boundary: a token for the root `https://s.example/alice` is no longer sent to `https://s.example/alice-other/x` (theorem `covers_sibling_refused`). The hub compositor's pod storage needs an engine with `containerPath`; with an older engine it refuses every pod operation.
16
+ - Requests to private addresses (https://github.com/danbri/factoidal/issues/1126). One Lean decision for every outbound request whose URL a remote party chooses, `L4Factoidal/Net/Egress.lean`: it refuses every address of the IANA IPv4 and IPv6 special-purpose registries (now also 100.64.0.0/10, 198.18.0.0/15, 192.0.0.0/24, the documentation ranges, 224.0.0.0/4, 240.0.0.0/4, 255.255.255.255, fec0::/10, ff00::/8, every IPv6 address outside 2000::/3, and NAT64 64:ff9b::/96 or 6to4 2002::/16 with such an IPv4 address inside) and localhost names, unless the operator opts in with `--allow-private-fetch`. The authorization server and CID retrieval use it (theorem `egress_refuses_special`). `lws-serve`: a webhook inbox at such an address answers 400 at subscription; every webhook, access-request, access-grant and controller-inbox delivery is checked again just before it is made, against the addresses the host resolves then, and the host connects to the address the engine chose (new wasm op `lwsEgress`; theorems `deliveries_pinned`, `egressOf_refuses_special`), so a name that resolves to a private address only at delivery time is refused. A refused delivery is not retried. Deliveries to one destination are limited for all subscriptions together: `--delivery-dest-limit N` (default 10 per window, theorem `admit_per_destination`). `lws-serve --allow-private-fetch` now also lets inboxes be private addresses, and its warning is printed also with `--quiet`. Hosts and tests that deliver to 127.0.0.1 must opt in.
17
+ - `webdav-serve --xmpp` accepts `--users`, `--login-limit`, `--login-window` and `--dangerously-disable-ratelimits` (https://github.com/danbri/factoidal/issues/1125). Each request over XMPP passes the JID allow list and then the same Lean login as on TCP (`Login.admit`); HTTP Basic credentials in the XEP-0332 headers log in. `--users` over XMPP needs `--xmpp-tls` or a loopback `--xmpp-host`. `--cert` and `--key` are refused with `--xmpp` (no port opens).
18
+ - The failed-login limit counts per requester (https://github.com/danbri/factoidal/issues/1128): the client address on TCP, the sender's bare JID over XMPP (`L4Factoidal/Net/LoginLimit.lean`, `requesterKey`), never an empty key; one JID's failures do not block another JID. Over XMPP, requests that wait for `chunkedBase64` chunks are limited to 4 per sender and 16 in total, and wait at most 60 seconds (`resource-constraint`).
19
+ - `xmpp-serve` limits failed SASL logins by default: 5 in 60 seconds per account name and per client address, then `temporary-auth-failure` with no password check until the window ends. `--dangerously-disable-ratelimits` turns it off and prints a warning at start. Wasm: `xmppOpen` takes `disableRateLimits` and answers `warnings`; `xmppSessionOpen` takes `remoteAddress` and `now`; `xmppClientTick` also serves the serving mode; `xmppClientFeed` request records carry `requester`; `webdavOpen` takes `login.transport: "xmpp"` and `login.jid`; `ready.login`.
20
+
21
+ ## 0.32.0 (2026-11-05)
22
+
23
+ - 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`.
24
+ - 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).
25
+ - `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.
26
+
3
27
  ## 0.31.1 (2026-11-05)
4
28
 
5
29
  - 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
@@ -445,8 +445,11 @@ DIR holds the signing keys (DIR/authz-keys.json, mode 0600), sealed with
445
445
  FACTOIDAL_LWS_KEY when it is set. --rotate-after makes a new key when the
446
446
  current one is that old; the old key stays published until its tokens expire.
447
447
  --allow-http accepts http subject identifiers; --allow-private-fetch lets the
448
- server fetch documents from loopback, link-local and private addresses. Both
449
- are off by default and are for tests.
448
+ server fetch documents from special-purpose addresses: loopback, private,
449
+ link-local, shared (100.64.0.0/10), multicast, reserved and the other ranges
450
+ of the IANA special-purpose registries, and localhost names
451
+ (https://github.com/danbri/factoidal/issues/1126). Both are off by default
452
+ and are for tests.
450
453
  CORS (https://github.com/danbri/factoidal/issues/923): by default every answer
451
454
  echoes a valid request Origin in Access-Control-Allow-Origin, so a page on
452
455
  another origin can read the metadata and keys and exchange a token; a
@@ -474,7 +477,8 @@ usage: factoidal lws-serve DIR (--authz DIR | --issuer IRI | --auth-config FILE
474
477
  [--paginate-always] [--moves all|data|off]
475
478
  [--no-linkset-put] [--unstable-search-results] [--merkle]
476
479
  [--controller-inbox URL] [--delivery-limit N]
477
- [--delivery-window SECONDS] [--redirects-max N]
480
+ [--delivery-window SECONDS] [--delivery-dest-limit N]
481
+ [--redirects-max N]
478
482
  [--storage snapshot|files]
479
483
  [--cors-origin ORIGIN]... [--no-cors]
480
484
 
@@ -569,6 +573,25 @@ The engine decides the file, its contents and every refusal
569
573
  --delivery-window SECONDS
570
574
  the length of that window (config member deliveryWindow;
571
575
  default 60)
576
+ --delivery-dest-limit N
577
+ the most deliveries one destination (host and port of the
578
+ inbox URL) gets in one window, for all subscriptions and
579
+ access records together (config member deliveryDestLimit;
580
+ default 10, 0 turns the limit off)
581
+ https://github.com/danbri/factoidal/issues/1126
582
+ --allow-private-fetch
583
+ let webhook, access-request, access-grant and controller
584
+ inboxes be special-purpose addresses (loopback, private,
585
+ link-local, shared 100.64.0.0/10, multicast, reserved and
586
+ the other IANA special-purpose ranges) or localhost names,
587
+ and let the --authz server fetch from them (config member
588
+ allowPrivateFetch; default off). Without it such an inbox
589
+ is refused with 400 when its address is known at
590
+ subscription, and every delivery is checked again just
591
+ before it is made, against the addresses resolved then;
592
+ the server connects to the address it checked. For tests
593
+ and closed networks only.
594
+ https://github.com/danbri/factoidal/issues/1126
572
595
  --redirects-max N
573
596
  the most previous identifiers of moved resources kept to
574
597
  answer 301 Moved Permanently (config member redirectsMax;
@@ -747,10 +770,12 @@ Specification: https://solidproject.org/TR/protocol`,
747
770
  'xmpp-serve': `factoidal xmpp-serve - serve XMPP (RFC 6120 and RFC 6121) over TCP, STARTTLS or direct TLS
748
771
 
749
772
  usage: factoidal xmpp-serve DIR [--domain NAME] [--port N] [--host ADDRESS]
750
- [--accounts FILE] [--cert FILE --key FILE [--starttls]]
773
+ [--accounts FILE] [--cert FILE --key FILE [--starttls [--tls-port N]]]
751
774
  [--ws-port N [--ws-path PATH] [--ws-origin ORIGIN]...]
752
- [--muc DOMAIN] [--mix DOMAIN] [--gc3 DOMAIN]
753
- [--state-key FILE]
775
+ [--muc DOMAIN [--muc-members FILE]] [--mix DOMAIN] [--gc3 DOMAIN]
776
+ [--state-key FILE] [--max-connections N]
777
+ [--login-timeout SECONDS] [--no-address-limit]
778
+ [--dangerously-disable-ratelimits]
754
779
 
755
780
  Starts a Node TCP server that runs one engine session per connection and
756
781
  answers every byte through the engine's xmppSessionFeed operation.
@@ -767,12 +792,20 @@ DIR/spool/<bare jid>/ is delivered to that account's bound session.
767
792
  (default 5222; 5223 with --cert and --key and no --starttls)
768
793
  --host ADDRESS the address to bind (default 127.0.0.1); without
769
794
  --cert and --key only a loopback address is accepted
770
- --accounts FILE the accounts file (default DIR/accounts)
795
+ --accounts FILE the accounts file (default DIR/accounts). One account per
796
+ line: name:password, or a line from --verifier, which holds
797
+ no password (SCRAM only). Any other line stops the start. A
798
+ file others can read gives a warning
799
+ --verifier NAME read a password from standard input, print the accounts line
800
+ for NAME that holds no password, and exit; --iterations N
801
+ sets the SCRAM count (default 10000)
771
802
  --cert FILE PEM certificate chain: XEP-0368 direct TLS, or STARTTLS with --starttls
772
803
  --key FILE PEM private key for --cert
773
804
  --starttls listen in clear and REQUIRE STARTTLS (RFC 6120 section 5)
774
805
  before anything else; no SASL mechanism is offered and no
775
806
  credential is accepted until the TLS handshake finishes
807
+ --tls-port N with --starttls, also serve XEP-0368 direct TLS on port N,
808
+ in the same server (the same rooms and sessions)
776
809
  --ws-port N also serve XMPP over WebSocket (RFC 7395, the "xmpp"
777
810
  subprotocol of RFC 6455) for browser clients such as
778
811
  Strophe.js; wss: with --cert and --key, else loopback only
@@ -785,10 +818,19 @@ DIR/spool/<bare jid>/ is delivered to that account's bound session.
785
818
  conference.example.com: temporary rooms, made by the first
786
819
  join and gone with the last occupant; no history, no
787
820
  configuration, no persistence (L4Factoidal/XMPP/Muc.lean)
821
+ --muc-members FILE members-only rooms: one room per line, as
822
+ "room: alice@example.com bob@example.com". Only those accounts
823
+ may join or see that room; a room not in the file is open to
824
+ every account (L4Factoidal/XMPP/Muc.lean)
788
825
  --mix DOMAIN also serve XEP-0369 MIX channels at DOMAIN, e.g. mix.example.com,
789
826
  joined through this server (XEP-0405 MIX-PAM); channels are
790
827
  made by an IQ <create/>, kept in memory only, JID-hidden; no
791
828
  PubSub nodes, no archive (L4Factoidal/XMPP/Mix.lean)
829
+ --max-connections N open connections of all kinds before a new one is closed
830
+ at once (default 1024). There is no limit per address:
831
+ behind a proxy, every client has the same one
832
+ --login-timeout SECONDS how long a connection may stay without a bound
833
+ resource before it is closed (default 30; 0 disables)
792
834
  --state-key FILE keep the server's state across restarts: rosters, messages held
793
835
  for offline accounts, MIX channels and GC3 groups, in an LWS
794
836
  storage in DIR/state, sealed (ChaCha20-Poly1305) under the key
@@ -799,6 +841,23 @@ DIR/spool/<bare jid>/ is delivered to that account's bound session.
799
841
  membership, stable participant ids, optional events. There is
800
842
  no published GC3 spec; every wire format is invented and listed
801
843
  in L4Factoidal/XMPP/Gc3.lean and danbri/factoidal issue 1236
844
+ --no-address-limit
845
+ count failed logins per account name only. For a listener
846
+ behind a proxy, where every client has the proxy's address
847
+ and a limit on it would stop them all
848
+ --dangerously-disable-ratelimits
849
+ no failed SASL login limit and no sender rate on held
850
+ messages (a warning is printed at start); any client can
851
+ then try passwords without end
852
+
853
+ Failed SASL logins are limited by default
854
+ (https://github.com/danbri/factoidal/issues/1128): after 5 failed logins in
855
+ 60 seconds for one account name (known or not) or from one client address
856
+ (an IPv6 client by its /64), on one stream or on new ones, an attempt gets
857
+ the SASL failure temporary-auth-failure, and no password is checked, also
858
+ the right one, until that window ends. A client address also has a slower
859
+ limit of 20 failed logins in an hour. A malformed SASL payload checks no
860
+ password and does not count. A stream closes after 3 failed attempts.
802
861
 
803
862
  The server makes no protocol decision. Stream negotiation, SASL PLAIN and
804
863
  SCRAM-SHA-256, resource binding, the roster, presence subscription and
@@ -873,7 +932,9 @@ usage: factoidal webdav-serve CONFIGDIR [--port N] [--host ADDRESS]
873
932
  [--login-limit N] [--login-window SECONDS]
874
933
  [--dangerously-disable-ratelimits]
875
934
  factoidal webdav-serve CONFIGDIR --xmpp JID --xmpp-password-file FILE
876
- --xmpp-allow JID[,JID...] [--guest NAME] [--xmpp-host H] [--xmpp-port P] [--xmpp-tls]
935
+ --xmpp-allow JID[,JID...] [--guest NAME] [--users FILE]
936
+ [--login-limit N] [--login-window SECONDS] [--dangerously-disable-ratelimits]
937
+ [--xmpp-host H] [--xmpp-port P] [--xmpp-tls]
877
938
 
878
939
  EMERGENCY MODE (https://github.com/danbri/factoidal/issues/1147): the server
879
940
  is READ ONLY. Every write method (PUT, DELETE, MKCOL, COPY, MOVE, PROPPATCH,
@@ -982,13 +1043,24 @@ NAT is reached this way with no inbound port.
982
1043
  --xmpp-host (127.0.0.0/8, ::1) is accepted.
983
1044
  --xmpp-ca FILE PEM certificates to trust for --xmpp-tls
984
1045
  --dangerously-allow-writable-without-login
985
- start although a local mount is writable:
986
- there is no login on the XMPP path yet
987
- (https://github.com/danbri/factoidal/issues/1125),
988
- so every allowed JID writes as the owner.
989
- Without it such a start is refused.
1046
+ with no --users: start although a local mount
1047
+ is writable, so every allowed JID writes as the
1048
+ owner. Without it such a start is refused.
1049
+ With --users it is not needed.
990
1050
  --xmpp-resource R the resource to bind (default dav)
991
1051
 
1052
+ Both checks apply to every request over XMPP
1053
+ (https://github.com/danbri/factoidal/issues/1125): the sender's bare JID must
1054
+ be in --xmpp-allow, and then the request acts as a user exactly as on TCP:
1055
+ the --guest user with no credentials, a user of --users with HTTP Basic
1056
+ credentials in the request's headers (xmpp-http-proxy passes Authorization
1057
+ unchanged). --users over XMPP needs --xmpp-tls or a loopback --xmpp-host.
1058
+ The failed-login limit over XMPP counts per user name and per bare JID of
1059
+ the sender (https://github.com/danbri/factoidal/issues/1128): one JID's
1060
+ failures do not block another JID. A request whose chunked body is not
1061
+ complete waits at most 60 seconds, and at most 4 such requests per sender
1062
+ (16 in total) wait at once; others get resource-constraint.
1063
+
992
1064
  It prints the bound JID (xmpp:user@domain/resource) on standard output.
993
1065
  XEP-0332 is Deferred, and no WebDAV client speaks it: a reader runs
994
1066
  "factoidal xmpp-http-proxy --target JID/RESOURCE" (as an allowed JID) and
@@ -1199,7 +1271,7 @@ const VALUE_OPTIONS = {
1199
1271
  compact: new Set([]),
1200
1272
  serve: new Set(['port', 'host', 'query-path', 'gsp-path', 'file', 'layout', 'tdb2', 'allow-host', 'allow-origin']),
1201
1273
  'lws-serve': new Set(['port', 'host', 'base', 'issuer', 'auth-config', 'owner', 'root-acl',
1202
- 'audience', 'page-size', 'moves', 'controller-inbox', 'delivery-limit', 'delivery-window',
1274
+ 'audience', 'page-size', 'moves', 'controller-inbox', 'delivery-limit', 'delivery-window', 'delivery-dest-limit',
1203
1275
  'redirects-max', 'storage', 'cors-origin', 'authz', 'alg', 'token-lifetime', 'rotate-after',
1204
1276
  'saml-idp', 'saml-decrypt-key', 'allow-host', 'allow-origin']),
1205
1277
  'solid-serve': new Set(['port', 'host', 'base', 'owner', 'issuer', 'auth-config', 'allow-host', 'allow-origin']),
@@ -1211,7 +1283,7 @@ const VALUE_OPTIONS = {
1211
1283
  'authz-serve': new Set(['port', 'host', 'issuer', 'resource', 'alg', 'token-lifetime', 'rotate-after',
1212
1284
  'cors-origin', 'saml-idp', 'saml-decrypt-key']),
1213
1285
  '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']),
1286
+ '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', 'verifier', 'iterations']),
1215
1287
  'webdav-serve': new Set(['port', 'host', 'users', 'guest', 'cert', 'key', 'login-limit', 'login-window', 'xmpp', 'xmpp-password-file',
1216
1288
  'xmpp-host', 'xmpp-port', 'xmpp-allow', 'xmpp-resource', 'xmpp-ca', 'allow-host', 'allow-origin']),
1217
1289
  'webdav-user': new Set(['iterations']),
@@ -2327,6 +2399,10 @@ async function commandLwsServe (positional, options) {
2327
2399
  ...(typeof options['controller-inbox'] === 'string' ? { controllerInbox: options['controller-inbox'] } : {}),
2328
2400
  ...(options['delivery-limit'] !== undefined ? { deliveryLimit: natOption('delivery-limit', options['delivery-limit']) } : {}),
2329
2401
  ...(options['delivery-window'] !== undefined ? { deliveryWindow: natOption('delivery-window', options['delivery-window']) } : {}),
2402
+ ...(options['delivery-dest-limit'] !== undefined ? { deliveryDestLimit: natOption('delivery-dest-limit', options['delivery-dest-limit']) } : {}),
2403
+ // Inboxes at special-purpose addresses (issue 1126): passed unread;
2404
+ // L4Factoidal/Net/Egress.lean decides.
2405
+ ...(options['allow-private-fetch'] === true ? { allowPrivateFetch: true } : {}),
2330
2406
  ...(options['redirects-max'] !== undefined ? { redirectsMax: natOption('redirects-max', options['redirects-max']) } : {}),
2331
2407
  ...(typeof options.storage === 'string' ? { storage: options.storage } : {}),
2332
2408
  ...corsConfig(options),
@@ -2356,11 +2432,16 @@ async function commandLwsServe (positional, options) {
2356
2432
  // The engine's start notices (a stale lock taken over; encryption at rest
2357
2433
  // on or off), printed also with --quiet.
2358
2434
  for (const n of (await running.storage()).notices || []) err(`lws-serve: ${n}`)
2435
+ // Printed also with --quiet: this option opens the local network to
2436
+ // requests that remote parties choose (issue 1126).
2437
+ if (options['allow-private-fetch'] === true) {
2438
+ err('lws-serve: WARNING: --allow-private-fetch: webhook and inbox deliveries and authorization ' +
2439
+ 'server fetches may go to loopback, private, link-local and other special-purpose addresses')
2440
+ }
2359
2441
  if (!quiet) {
2360
2442
  err(`lws-serve: listening on ${running.origin} for ${positional[0]}`)
2361
2443
  if (running.authz() !== null) err(`lws-serve: authorization server ${running.authz().issuer} (keys in ${authzDir})`)
2362
2444
  if (options['allow-http'] === true) err('lws-serve: WARNING: --allow-http: http subject identifiers are accepted')
2363
- if (options['allow-private-fetch'] === true) err('lws-serve: WARNING: --allow-private-fetch: private addresses may be fetched')
2364
2445
  if (insecure) {
2365
2446
  err('lws-serve: WARNING: --insecure-no-auth: every request is served without authorization')
2366
2447
  err('lws-serve: --insecure-no-auth: only the Host names of this listener are answered, and a ' +
@@ -2629,20 +2710,32 @@ async function webdavOverXmpp (configDir, options, quiet) {
2629
2710
  if (typeof options.xmpp !== 'string') throw new UsageError('--xmpp needs a JID')
2630
2711
  if (typeof options['xmpp-password-file'] !== 'string') throw new UsageError('--xmpp needs --xmpp-password-file FILE')
2631
2712
  if (typeof options['xmpp-allow'] !== 'string') throw new UsageError('--xmpp needs --xmpp-allow JID[,JID...]')
2632
- // No login on the XMPP path yet: refuse --users rather than ignore it
2633
- // (https://github.com/danbri/factoidal/issues/1125).
2634
- if (['users', 'cert', 'key', 'login-limit', 'login-window', 'dangerously-disable-ratelimits'].some((k) => options[k] !== undefined)) {
2635
- throw new UsageError('--users, --cert, --key and the login-limit options do not apply with --xmpp yet (https://github.com/danbri/factoidal/issues/1125)')
2713
+ // No port is open with --xmpp, so the HTTPS options do not apply. --users
2714
+ // and the login-limit options do (https://github.com/danbri/factoidal/issues/1125).
2715
+ if (['cert', 'key'].some((k) => options[k] !== undefined)) {
2716
+ throw new UsageError('--cert and --key do not apply with --xmpp: no port is open; give --xmpp-tls for the XMPP stream')
2636
2717
  }
2637
2718
  const fs = await import('node:fs/promises')
2719
+ const { resolve: resolvePath } = await import('node:path')
2638
2720
  const password = (await fs.readFile(options['xmpp-password-file'], 'utf8')).split(/\r?\n/)[0]
2639
2721
  const allow = options['xmpp-allow'].split(',').map((x) => x.trim()).filter((x) => x.length > 0)
2640
2722
  const { openWebdav, WebdavHostError } = await import('../webdav/server.mjs')
2641
2723
  const { connectHttpTarget, XmppClientError } = await import('../xmpp/client.mjs')
2642
2724
  if (options.guest !== undefined && typeof options.guest !== 'string') throw new UsageError('--guest needs a user name')
2725
+ // The login (issue 1125): the same users file and limit options as on
2726
+ // TCP, and the XMPP stream as the transport; the engine checks them all.
2727
+ const davOptions = { configDir, transport: 'xmpp', jid: options.xmpp, tls: options['xmpp-tls'] === true }
2728
+ if (typeof options['xmpp-host'] === 'string') davOptions.host = options['xmpp-host']
2729
+ if (typeof options.guest === 'string') davOptions.guest = options.guest
2730
+ if (typeof options.users === 'string') davOptions.users = resolvePath(options.users)
2731
+ if (options['login-limit'] !== undefined) davOptions.loginLimit = String(options['login-limit'])
2732
+ if (options['login-window'] !== undefined) davOptions.loginWindow = String(options['login-window'])
2733
+ if (options['dangerously-disable-ratelimits'] !== undefined) {
2734
+ davOptions.disableRateLimits = options['dangerously-disable-ratelimits'] === true
2735
+ }
2643
2736
  let dav
2644
2737
  try {
2645
- dav = await openWebdav({ configDir, ...(typeof options.guest === 'string' ? { guest: options.guest } : {}) })
2738
+ dav = await openWebdav(davOptions)
2646
2739
  } catch (error) {
2647
2740
  if (error instanceof WebdavHostError && error.unknownOp) return notWired('webdav-serve', error.message)
2648
2741
  if (error instanceof WebdavHostError || (error !== null && typeof error === 'object' && error.code === 'ENOENT')) {
@@ -2662,6 +2755,7 @@ async function webdavOverXmpp (configDir, options, quiet) {
2662
2755
  tls: options['xmpp-tls'] === true,
2663
2756
  mounts: dav.mounts,
2664
2757
  dangerouslyAllowWritableWithoutLogin: options['dangerously-allow-writable-without-login'] === true,
2758
+ login: dav.login,
2665
2759
  step: (request) => dav.step(request),
2666
2760
  onError: (error) => { if (!quiet) err(`webdav-serve: xmpp: ${error.message}`) }
2667
2761
  }
@@ -2685,7 +2779,8 @@ async function webdavOverXmpp (configDir, options, quiet) {
2685
2779
  throw error
2686
2780
  }
2687
2781
  if (!quiet) {
2688
- err(`webdav-serve: connected to XMPP as ${conn.jid}; no port is open; requests from ${allow.join(', ')}`)
2782
+ err(`webdav-serve: connected to XMPP as ${conn.jid}; no port is open; requests from ${allow.join(', ')}` +
2783
+ (dav.login ? `, login from ${options.users} (failed logins limited per user name and per bare JID)` : ', no --users: credentials get 401'))
2689
2784
  if (conn.writableWithoutLogin.length > 0) {
2690
2785
  err(`webdav-serve: WARNING: --dangerously-allow-writable-without-login: every allowed JID can write ${conn.writableWithoutLogin.join(', ')} as the storage owner, with no login`)
2691
2786
  }
@@ -2850,6 +2945,17 @@ async function commandWebdavUser (positional, options) {
2850
2945
  }
2851
2946
 
2852
2947
  async function commandXmppServe (positional, options) {
2948
+ // One accounts line with no password in it: the password comes from stdin.
2949
+ if (typeof options.verifier === 'string') {
2950
+ const { randomBytes } = await import('node:crypto')
2951
+ const { readFileSync } = await import('node:fs')
2952
+ const { loadEngine } = await import('./engine.mjs')
2953
+ const password = readFileSync(0, 'utf8').replace(/\r?\n$/, '')
2954
+ const engine = await loadEngine()
2955
+ const made = engine.call('xmppVerifier', [options.verifier, password, randomBytes(16).toString('base64'), String(options.iterations ?? 10000)])
2956
+ out(made.line)
2957
+ return 0
2958
+ }
2853
2959
  if (positional.length !== 1) throw new UsageError('xmpp-serve needs one DIR')
2854
2960
  const quiet = options.quiet === true
2855
2961
  const { listen, XmppHostError } = await import('../xmpp/server.mjs')
@@ -2867,12 +2973,27 @@ async function commandXmppServe (positional, options) {
2867
2973
  onError: (error) => { if (!quiet) err(`xmpp-serve: ${error.message}`) }
2868
2974
  }
2869
2975
  if (typeof options.domain === 'string') settings.domain = options.domain
2870
- if (typeof options.accounts === 'string') settings.accountsText = await fs.readFile(options.accounts, 'utf8')
2976
+ if (typeof options.accounts === 'string') {
2977
+ settings.accountsText = await fs.readFile(options.accounts, 'utf8')
2978
+ settings.accountsFile = options.accounts
2979
+ }
2980
+ // The failed SASL login limit (https://github.com/danbri/factoidal/issues/1128):
2981
+ // on by default; the engine decides and words the warning.
2982
+ if (options['dangerously-disable-ratelimits'] !== undefined) {
2983
+ settings.disableRateLimits = options['dangerously-disable-ratelimits'] === true
2984
+ }
2985
+ if (options['no-address-limit'] === true) settings.addressLimit = false
2871
2986
  if (useTls) {
2872
2987
  settings.cert = await fs.readFile(options.cert)
2873
2988
  settings.key = await fs.readFile(options.key)
2874
2989
  }
2875
2990
  if (startTls) settings.startTls = true
2991
+ if (options['tls-port'] !== undefined) {
2992
+ if (!startTls) throw new UsageError('--tls-port needs --starttls')
2993
+ const tlsPort = Number(options['tls-port'])
2994
+ if (!Number.isInteger(tlsPort) || tlsPort < 0 || tlsPort > 65535) throw new UsageError('--tls-port needs a port number')
2995
+ settings.directTlsPort = tlsPort
2996
+ }
2876
2997
  if (options['state-key'] !== undefined) {
2877
2998
  if (typeof options['state-key'] !== 'string') throw new UsageError('--state-key needs a FILE')
2878
2999
  // 64 hex digits or base64url of 32 bytes; the engine checks it.
@@ -2884,6 +3005,20 @@ async function commandXmppServe (positional, options) {
2884
3005
  }
2885
3006
  settings.mucDomain = options.muc
2886
3007
  }
3008
+ if (options['muc-members'] !== undefined) {
3009
+ if (typeof options['muc-members'] !== 'string') throw new UsageError('--muc-members needs a FILE')
3010
+ if (settings.mucDomain === undefined) throw new UsageError('--muc-members needs --muc')
3011
+ settings.mucMembers = []
3012
+ for (const line of (await fs.readFile(options['muc-members'], 'utf8')).split('\n')) {
3013
+ if (line.trim() === '') continue
3014
+ const [, room, list] = /^([^:\s@/]+):(.*)$/.exec(line.trim()) ?? []
3015
+ const members = (list ?? '').split(/\s+/).filter(Boolean)
3016
+ if (room === undefined || members.some((m) => !/^[^\s@/]+@[^\s@/]+$/.test(m))) {
3017
+ throw new UsageError(`--muc-members: not "room: bare@jid ...": ${line}`)
3018
+ }
3019
+ settings.mucMembers.push({ room, members })
3020
+ }
3021
+ }
2887
3022
  if (options.mix !== undefined) {
2888
3023
  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
3024
  throw new UsageError('--mix needs a domain name, e.g. mix.example.com')
@@ -2898,6 +3033,16 @@ async function commandXmppServe (positional, options) {
2898
3033
  if (options.gc3 === options.muc || options.gc3 === options.mix) throw new UsageError('--gc3 needs a domain of its own')
2899
3034
  settings.gc3Domain = options.gc3
2900
3035
  }
3036
+ if (options['max-connections'] !== undefined) {
3037
+ const n = Number(options['max-connections'])
3038
+ if (!Number.isSafeInteger(n) || n < 1) throw new UsageError('--max-connections needs a positive integer')
3039
+ settings.maxConnections = n
3040
+ }
3041
+ if (options['login-timeout'] !== undefined) {
3042
+ const n = Number(options['login-timeout'])
3043
+ if (!Number.isSafeInteger(n) || n < 0) throw new UsageError('--login-timeout needs a number of seconds')
3044
+ settings.loginTimeoutMs = n * 1000
3045
+ }
2901
3046
  if (options['ws-port'] !== undefined) {
2902
3047
  const wsPort = Number(options['ws-port'])
2903
3048
  if (!Number.isInteger(wsPort) || wsPort < 0 || wsPort > 65535) throw new UsageError('--ws-port needs a port number')
@@ -2926,6 +3071,9 @@ async function commandXmppServe (positional, options) {
2926
3071
  }
2927
3072
  throw error
2928
3073
  }
3074
+ // The engine's start warnings (--dangerously-disable-ratelimits), printed
3075
+ // also with --quiet.
3076
+ for (const w of running.xmpp.warnings || []) err(`factoidal xmpp-serve: WARNING: ${w}`)
2929
3077
  if (!quiet) {
2930
3078
  err(`xmpp-serve: listening on ${running.origin} for domain ${running.xmpp.domain}` +
2931
3079
  ` (${running.xmpp.accounts} accounts, state ${positional[0]}` +
@@ -2936,11 +3084,13 @@ async function commandXmppServe (positional, options) {
2936
3084
  if (settings.stateKey) err(`xmpp-serve: state kept across restarts in ${positional[0]}/state, sealed under the state key`)
2937
3085
  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
3086
  }
3087
+ if (running.directOrigin && !quiet) err(`xmpp-serve: direct TLS (XEP-0368) on ${running.directOrigin}`)
2939
3088
  if (running.wsOrigin && !quiet) {
2940
3089
  err(`xmpp-serve: XMPP over WebSocket on ${running.wsOrigin}` +
2941
3090
  (settings.websocket.allowedOrigins.length ? ` for Origin ${settings.websocket.allowedOrigins.join(', ')}` : ' (no browser Origin allowed)'))
2942
3091
  }
2943
3092
  out(running.origin)
3093
+ if (running.directOrigin) out(running.directOrigin)
2944
3094
  if (running.wsOrigin) out(running.wsOrigin)
2945
3095
  return untilInterrupted(running, quiet)
2946
3096
  }
@@ -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));
package/browser.js CHANGED
@@ -27,7 +27,7 @@ import { entryFromEngine } from './lib/lean-entry.mjs';
27
27
  import { browserEngine } from './lib/browser-engine.mjs';
28
28
  import { indexedDbDeltaLogStore } from './lib/delta-log-hosts.mjs';
29
29
  import { sbm11Api, registerSbm11Source, mapReader } from './lib/sbm11.mjs';
30
- import { lwsSbm11Reader } from './lib/sbm11-lws.mjs';
30
+ import { lwsSbm11Reader as lwsSbm11ReaderWith } from './lib/sbm11-lws.mjs';
31
31
 
32
32
  const l4Engine = browserEngine('l4factoidal', new URL('./l4-assets/', import.meta.url).href);
33
33
 
@@ -73,7 +73,10 @@ export const packSbm11 = sbm11.packSbm11;
73
73
  export { registerSbm11Source, mapReader };
74
74
  // A reader of an SBM11 generation held as LWS resources (issue 1010 KR3). In
75
75
  // a browser worker its default transport is a synchronous XMLHttpRequest.
76
- export { lwsSbm11Reader };
76
+ // Asynchronous: it loads the engine, whose op containerPath decides every URL.
77
+ export async function lwsSbm11Reader(url, opts = {}) {
78
+ return lwsSbm11ReaderWith(url, { ...opts, engine: opts.engine ?? await loadEngine() });
79
+ }
77
80
 
78
81
  // RDF and SPARQL
79
82
  export const parse = api.parse;