@factoidal/core 0.32.0 → 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 +12 -0
- package/bin/factoidal.mjs +127 -21
- package/browser.js +5 -2
- package/index.d.ts +20 -7
- package/index.mjs +7 -4
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/api.mjs +5 -3
- package/lib/sbm11-lws.mjs +25 -35
- package/lib/version.mjs +1 -1
- package/lws/server.mjs +82 -14
- package/package.json +1 -1
- package/webdav/server.mjs +12 -0
- package/xmpp/README.md +17 -4
- package/xmpp/client.mjs +18 -3
- package/xmpp/inpage.mjs +7 -1
- package/xmpp/server.mjs +85 -12
package/CHANGELOG.md
CHANGED
|
@@ -2,9 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
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.
|
|
5
12
|
- XMPP: `xmpp-serve --starttls --tls-port N` serves STARTTLS and XEP-0368 direct TLS from one server.
|
|
6
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.
|
|
7
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`.
|
|
8
20
|
|
|
9
21
|
## 0.32.0 (2026-11-05)
|
|
10
22
|
|
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,
|
|
449
|
-
|
|
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] [--
|
|
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;
|
|
@@ -751,7 +774,8 @@ usage: factoidal xmpp-serve DIR [--domain NAME] [--port N] [--host ADDRESS]
|
|
|
751
774
|
[--ws-port N [--ws-path PATH] [--ws-origin ORIGIN]...]
|
|
752
775
|
[--muc DOMAIN [--muc-members FILE]] [--mix DOMAIN] [--gc3 DOMAIN]
|
|
753
776
|
[--state-key FILE] [--max-connections N]
|
|
754
|
-
[--login-timeout SECONDS]
|
|
777
|
+
[--login-timeout SECONDS] [--no-address-limit]
|
|
778
|
+
[--dangerously-disable-ratelimits]
|
|
755
779
|
|
|
756
780
|
Starts a Node TCP server that runs one engine session per connection and
|
|
757
781
|
answers every byte through the engine's xmppSessionFeed operation.
|
|
@@ -768,7 +792,13 @@ DIR/spool/<bare jid>/ is delivered to that account's bound session.
|
|
|
768
792
|
(default 5222; 5223 with --cert and --key and no --starttls)
|
|
769
793
|
--host ADDRESS the address to bind (default 127.0.0.1); without
|
|
770
794
|
--cert and --key only a loopback address is accepted
|
|
771
|
-
--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)
|
|
772
802
|
--cert FILE PEM certificate chain: XEP-0368 direct TLS, or STARTTLS with --starttls
|
|
773
803
|
--key FILE PEM private key for --cert
|
|
774
804
|
--starttls listen in clear and REQUIRE STARTTLS (RFC 6120 section 5)
|
|
@@ -811,6 +841,23 @@ DIR/spool/<bare jid>/ is delivered to that account's bound session.
|
|
|
811
841
|
membership, stable participant ids, optional events. There is
|
|
812
842
|
no published GC3 spec; every wire format is invented and listed
|
|
813
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.
|
|
814
861
|
|
|
815
862
|
The server makes no protocol decision. Stream negotiation, SASL PLAIN and
|
|
816
863
|
SCRAM-SHA-256, resource binding, the roster, presence subscription and
|
|
@@ -885,7 +932,9 @@ usage: factoidal webdav-serve CONFIGDIR [--port N] [--host ADDRESS]
|
|
|
885
932
|
[--login-limit N] [--login-window SECONDS]
|
|
886
933
|
[--dangerously-disable-ratelimits]
|
|
887
934
|
factoidal webdav-serve CONFIGDIR --xmpp JID --xmpp-password-file FILE
|
|
888
|
-
--xmpp-allow JID[,JID...] [--guest NAME] [--
|
|
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]
|
|
889
938
|
|
|
890
939
|
EMERGENCY MODE (https://github.com/danbri/factoidal/issues/1147): the server
|
|
891
940
|
is READ ONLY. Every write method (PUT, DELETE, MKCOL, COPY, MOVE, PROPPATCH,
|
|
@@ -994,13 +1043,24 @@ NAT is reached this way with no inbound port.
|
|
|
994
1043
|
--xmpp-host (127.0.0.0/8, ::1) is accepted.
|
|
995
1044
|
--xmpp-ca FILE PEM certificates to trust for --xmpp-tls
|
|
996
1045
|
--dangerously-allow-writable-without-login
|
|
997
|
-
start although a local mount
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
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.
|
|
1002
1050
|
--xmpp-resource R the resource to bind (default dav)
|
|
1003
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
|
+
|
|
1004
1064
|
It prints the bound JID (xmpp:user@domain/resource) on standard output.
|
|
1005
1065
|
XEP-0332 is Deferred, and no WebDAV client speaks it: a reader runs
|
|
1006
1066
|
"factoidal xmpp-http-proxy --target JID/RESOURCE" (as an allowed JID) and
|
|
@@ -1211,7 +1271,7 @@ const VALUE_OPTIONS = {
|
|
|
1211
1271
|
compact: new Set([]),
|
|
1212
1272
|
serve: new Set(['port', 'host', 'query-path', 'gsp-path', 'file', 'layout', 'tdb2', 'allow-host', 'allow-origin']),
|
|
1213
1273
|
'lws-serve': new Set(['port', 'host', 'base', 'issuer', 'auth-config', 'owner', 'root-acl',
|
|
1214
|
-
'audience', 'page-size', 'moves', 'controller-inbox', 'delivery-limit', 'delivery-window',
|
|
1274
|
+
'audience', 'page-size', 'moves', 'controller-inbox', 'delivery-limit', 'delivery-window', 'delivery-dest-limit',
|
|
1215
1275
|
'redirects-max', 'storage', 'cors-origin', 'authz', 'alg', 'token-lifetime', 'rotate-after',
|
|
1216
1276
|
'saml-idp', 'saml-decrypt-key', 'allow-host', 'allow-origin']),
|
|
1217
1277
|
'solid-serve': new Set(['port', 'host', 'base', 'owner', 'issuer', 'auth-config', 'allow-host', 'allow-origin']),
|
|
@@ -1223,7 +1283,7 @@ const VALUE_OPTIONS = {
|
|
|
1223
1283
|
'authz-serve': new Set(['port', 'host', 'issuer', 'resource', 'alg', 'token-lifetime', 'rotate-after',
|
|
1224
1284
|
'cors-origin', 'saml-idp', 'saml-decrypt-key']),
|
|
1225
1285
|
'authz-keys': new Set([]),
|
|
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']),
|
|
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']),
|
|
1227
1287
|
'webdav-serve': new Set(['port', 'host', 'users', 'guest', 'cert', 'key', 'login-limit', 'login-window', 'xmpp', 'xmpp-password-file',
|
|
1228
1288
|
'xmpp-host', 'xmpp-port', 'xmpp-allow', 'xmpp-resource', 'xmpp-ca', 'allow-host', 'allow-origin']),
|
|
1229
1289
|
'webdav-user': new Set(['iterations']),
|
|
@@ -2339,6 +2399,10 @@ async function commandLwsServe (positional, options) {
|
|
|
2339
2399
|
...(typeof options['controller-inbox'] === 'string' ? { controllerInbox: options['controller-inbox'] } : {}),
|
|
2340
2400
|
...(options['delivery-limit'] !== undefined ? { deliveryLimit: natOption('delivery-limit', options['delivery-limit']) } : {}),
|
|
2341
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 } : {}),
|
|
2342
2406
|
...(options['redirects-max'] !== undefined ? { redirectsMax: natOption('redirects-max', options['redirects-max']) } : {}),
|
|
2343
2407
|
...(typeof options.storage === 'string' ? { storage: options.storage } : {}),
|
|
2344
2408
|
...corsConfig(options),
|
|
@@ -2368,11 +2432,16 @@ async function commandLwsServe (positional, options) {
|
|
|
2368
2432
|
// The engine's start notices (a stale lock taken over; encryption at rest
|
|
2369
2433
|
// on or off), printed also with --quiet.
|
|
2370
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
|
+
}
|
|
2371
2441
|
if (!quiet) {
|
|
2372
2442
|
err(`lws-serve: listening on ${running.origin} for ${positional[0]}`)
|
|
2373
2443
|
if (running.authz() !== null) err(`lws-serve: authorization server ${running.authz().issuer} (keys in ${authzDir})`)
|
|
2374
2444
|
if (options['allow-http'] === true) err('lws-serve: WARNING: --allow-http: http subject identifiers are accepted')
|
|
2375
|
-
if (options['allow-private-fetch'] === true) err('lws-serve: WARNING: --allow-private-fetch: private addresses may be fetched')
|
|
2376
2445
|
if (insecure) {
|
|
2377
2446
|
err('lws-serve: WARNING: --insecure-no-auth: every request is served without authorization')
|
|
2378
2447
|
err('lws-serve: --insecure-no-auth: only the Host names of this listener are answered, and a ' +
|
|
@@ -2641,20 +2710,32 @@ async function webdavOverXmpp (configDir, options, quiet) {
|
|
|
2641
2710
|
if (typeof options.xmpp !== 'string') throw new UsageError('--xmpp needs a JID')
|
|
2642
2711
|
if (typeof options['xmpp-password-file'] !== 'string') throw new UsageError('--xmpp needs --xmpp-password-file FILE')
|
|
2643
2712
|
if (typeof options['xmpp-allow'] !== 'string') throw new UsageError('--xmpp needs --xmpp-allow JID[,JID...]')
|
|
2644
|
-
// No
|
|
2645
|
-
// (https://github.com/danbri/factoidal/issues/1125).
|
|
2646
|
-
if (['
|
|
2647
|
-
throw new UsageError('--
|
|
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')
|
|
2648
2717
|
}
|
|
2649
2718
|
const fs = await import('node:fs/promises')
|
|
2719
|
+
const { resolve: resolvePath } = await import('node:path')
|
|
2650
2720
|
const password = (await fs.readFile(options['xmpp-password-file'], 'utf8')).split(/\r?\n/)[0]
|
|
2651
2721
|
const allow = options['xmpp-allow'].split(',').map((x) => x.trim()).filter((x) => x.length > 0)
|
|
2652
2722
|
const { openWebdav, WebdavHostError } = await import('../webdav/server.mjs')
|
|
2653
2723
|
const { connectHttpTarget, XmppClientError } = await import('../xmpp/client.mjs')
|
|
2654
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
|
+
}
|
|
2655
2736
|
let dav
|
|
2656
2737
|
try {
|
|
2657
|
-
dav = await openWebdav(
|
|
2738
|
+
dav = await openWebdav(davOptions)
|
|
2658
2739
|
} catch (error) {
|
|
2659
2740
|
if (error instanceof WebdavHostError && error.unknownOp) return notWired('webdav-serve', error.message)
|
|
2660
2741
|
if (error instanceof WebdavHostError || (error !== null && typeof error === 'object' && error.code === 'ENOENT')) {
|
|
@@ -2674,6 +2755,7 @@ async function webdavOverXmpp (configDir, options, quiet) {
|
|
|
2674
2755
|
tls: options['xmpp-tls'] === true,
|
|
2675
2756
|
mounts: dav.mounts,
|
|
2676
2757
|
dangerouslyAllowWritableWithoutLogin: options['dangerously-allow-writable-without-login'] === true,
|
|
2758
|
+
login: dav.login,
|
|
2677
2759
|
step: (request) => dav.step(request),
|
|
2678
2760
|
onError: (error) => { if (!quiet) err(`webdav-serve: xmpp: ${error.message}`) }
|
|
2679
2761
|
}
|
|
@@ -2697,7 +2779,8 @@ async function webdavOverXmpp (configDir, options, quiet) {
|
|
|
2697
2779
|
throw error
|
|
2698
2780
|
}
|
|
2699
2781
|
if (!quiet) {
|
|
2700
|
-
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'))
|
|
2701
2784
|
if (conn.writableWithoutLogin.length > 0) {
|
|
2702
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`)
|
|
2703
2786
|
}
|
|
@@ -2862,6 +2945,17 @@ async function commandWebdavUser (positional, options) {
|
|
|
2862
2945
|
}
|
|
2863
2946
|
|
|
2864
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
|
+
}
|
|
2865
2959
|
if (positional.length !== 1) throw new UsageError('xmpp-serve needs one DIR')
|
|
2866
2960
|
const quiet = options.quiet === true
|
|
2867
2961
|
const { listen, XmppHostError } = await import('../xmpp/server.mjs')
|
|
@@ -2879,7 +2973,16 @@ async function commandXmppServe (positional, options) {
|
|
|
2879
2973
|
onError: (error) => { if (!quiet) err(`xmpp-serve: ${error.message}`) }
|
|
2880
2974
|
}
|
|
2881
2975
|
if (typeof options.domain === 'string') settings.domain = options.domain
|
|
2882
|
-
if (typeof options.accounts === 'string')
|
|
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
|
|
2883
2986
|
if (useTls) {
|
|
2884
2987
|
settings.cert = await fs.readFile(options.cert)
|
|
2885
2988
|
settings.key = await fs.readFile(options.key)
|
|
@@ -2968,6 +3071,9 @@ async function commandXmppServe (positional, options) {
|
|
|
2968
3071
|
}
|
|
2969
3072
|
throw error
|
|
2970
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}`)
|
|
2971
3077
|
if (!quiet) {
|
|
2972
3078
|
err(`xmpp-serve: listening on ${running.origin} for domain ${running.xmpp.domain}` +
|
|
2973
3079
|
` (${running.xmpp.accounts} accounts, state ${positional[0]}` +
|
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
|
-
|
|
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;
|
package/index.d.ts
CHANGED
|
@@ -240,7 +240,7 @@ export type WriteFormat =
|
|
|
240
240
|
| 'rdfxml' | 'rdf-xml' | 'rdf' | 'jsonld' | 'json-ld' | 'rdfc' | 'canonical';
|
|
241
241
|
|
|
242
242
|
/** Results formats of query() with `output`. */
|
|
243
|
-
export type ResultsFormat = 'json' | 'xml' | 'csv' | 'tsv' | 'table' | 'ntriples';
|
|
243
|
+
export type ResultsFormat = 'json' | 'xml' | 'xml-report' | 'csv' | 'tsv' | 'table' | 'ntriples';
|
|
244
244
|
|
|
245
245
|
/** One row of formats(). */
|
|
246
246
|
export interface FormatCapability {
|
|
@@ -1570,10 +1570,14 @@ export type Sbm11Transport = (url: string, headers: Record<string, string>) =>
|
|
|
1570
1570
|
* null before transport and appear in errors().
|
|
1571
1571
|
* Browser worker default XHR accepts no caller auth/headers; provide an explicit
|
|
1572
1572
|
* trusted transport for those. Its final-URL check cannot prevent an already
|
|
1573
|
-
* sent anonymous request or ambient same-origin browser cookies.
|
|
1573
|
+
* sent anonymous request or ambient same-origin browser cookies.
|
|
1574
|
+
* Asynchronous: the engine's op containerPath (Lean, L4Factoidal.LWS.ContainerPath)
|
|
1575
|
+
* decides the container URL and every resource URL. */
|
|
1574
1576
|
export function lwsSbm11Reader(url: string, opts?: {
|
|
1575
|
-
auth?: string; headers?: Record<string, string>; transport?: Sbm11Transport; timeoutMs?: number
|
|
1576
|
-
|
|
1577
|
+
auth?: string; headers?: Record<string, string>; transport?: Sbm11Transport; timeoutMs?: number;
|
|
1578
|
+
/** a loaded engine handle (default: loadEngine()); its op containerPath decides every URL */
|
|
1579
|
+
engine?: object
|
|
1580
|
+
}): Promise<Sbm11Reader & { evidence(): Sbm11ReadEvidence[]; errors(): string[]; close(): void }>;
|
|
1577
1581
|
|
|
1578
1582
|
/** One query against a store: open with the query's plan, query, close. */
|
|
1579
1583
|
export function queryStore(
|
|
@@ -2260,13 +2264,22 @@ export interface SparqlResultsJson {
|
|
|
2260
2264
|
boolean?: boolean;
|
|
2261
2265
|
}
|
|
2262
2266
|
|
|
2267
|
+
/** The exact serialized result is checked by the Lean XML parser. This is
|
|
2268
|
+
* parser admission, not a proof of SPARQL serialization fidelity or XSLT success.
|
|
2269
|
+
* Invalid RDF/XML characters are refused, never removed or replaced.
|
|
2270
|
+
*/
|
|
2271
|
+
export type SparqlXmlReport =
|
|
2272
|
+
| { kind: 'xml'; xml: string }
|
|
2273
|
+
| { kind: 'diagnostic'; code: 'XML_ADMISSION_REFUSED'; message: string };
|
|
2274
|
+
|
|
2263
2275
|
export type OutputFormat =
|
|
2264
|
-
| 'json' | 'csv' | 'tsv' | 'xml' | 'table' | 'ntriples';
|
|
2276
|
+
| 'json' | 'csv' | 'tsv' | 'xml' | 'xml-report' | 'table' | 'ntriples';
|
|
2265
2277
|
|
|
2266
2278
|
/**
|
|
2267
2279
|
* One call, serialised answer: parse `dataString`, close the default
|
|
2268
2280
|
* graph under `entail`, run the query. Resolves to parsed SPARQL Results
|
|
2269
|
-
* JSON when output is 'json' (default),
|
|
2281
|
+
* JSON when output is 'json' (default), an XML/diagnostic object for
|
|
2282
|
+
* 'xml-report', and text otherwise. 'csv', 'tsv'
|
|
2270
2283
|
* and 'xml' are the W3C SPARQL results formats (CSV and TSV have no ASK
|
|
2271
2284
|
* encoding, so an ASK with those rejects); 'table' and 'ntriples' are
|
|
2272
2285
|
* display formats. A CONSTRUCT or DESCRIBE answer is a graph and takes
|
|
@@ -2280,7 +2293,7 @@ export function queryRaw(
|
|
|
2280
2293
|
entail?: EntailRegime;
|
|
2281
2294
|
output?: OutputFormat;
|
|
2282
2295
|
}
|
|
2283
|
-
): Promise<SparqlResultsJson | string>;
|
|
2296
|
+
): Promise<SparqlResultsJson | SparqlXmlReport | string>;
|
|
2284
2297
|
|
|
2285
2298
|
/** Package version string, e.g. '0.9.0'. */
|
|
2286
2299
|
export const version: string;
|
package/index.mjs
CHANGED
|
@@ -103,12 +103,15 @@ export { registerSbm11Source, mapReader };
|
|
|
103
103
|
// A reader of an SBM11 generation held as LWS resources (lib/sbm11-lws.mjs;
|
|
104
104
|
// https://github.com/danbri/factoidal/issues/1010 KR3). In Node the default
|
|
105
105
|
// synchronous transport is a fetch in a worker thread (lib/sync-http-node.mjs),
|
|
106
|
-
// stopped by the reader's close().
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
// stopped by the reader's close(). Asynchronous: it loads the engine, whose
|
|
107
|
+
// op containerPath decides the container URL and every resource URL
|
|
108
|
+
// (formal/lean4/L4Factoidal/LWS/ContainerPath.lean).
|
|
109
|
+
export async function lwsSbm11Reader(url, opts = {}) {
|
|
110
|
+
const engine = opts.engine ?? await loadEngine();
|
|
111
|
+
if (typeof opts.transport === 'function') return lwsSbm11ReaderWith(url, { ...opts, engine });
|
|
109
112
|
const transport = nodeSyncTransport({ timeoutMs: opts.timeoutMs });
|
|
110
113
|
try {
|
|
111
|
-
return lwsSbm11ReaderWith(url, { ...opts, transport, onClose: () => transport.close() });
|
|
114
|
+
return lwsSbm11ReaderWith(url, { ...opts, engine, transport, onClose: () => transport.close() });
|
|
112
115
|
} catch (error) {
|
|
113
116
|
transport.close();
|
|
114
117
|
throw error;
|
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-31be9833264a-web",
|
|
6
|
+
"releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/42304532396",
|
|
7
|
+
"gitSha": "31be9833264a2289526a8c9fcc1d6b67610cf746",
|
|
8
|
+
"builtAt": "2026-11-06T23:08:01Z",
|
|
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": "7f617da4ecbe7352b94f361802e12bb785f401aafbf78f92b6031c8e920011f2",
|
|
15
|
+
"wasmBytes": 32611669,
|
|
16
16
|
"claims": {
|
|
17
17
|
"source": "formal/lean4 (L4Factoidal): no sorry, no user axioms, no native_decide; W3C behaviour pinned by build-time #guard",
|
|
18
18
|
"suitesAtBuildSha": "see docs/test-results and formal/lean4/PORT_NOTES.md at gitSha"
|
package/lib/api.mjs
CHANGED
|
@@ -693,7 +693,7 @@ function buildApi(driver) {
|
|
|
693
693
|
}
|
|
694
694
|
const r = await withExtensionRounds(() => entryResult(e.datasetQueryRaw(ds.handle, sparql,
|
|
695
695
|
JSON.stringify({ entail, output })), 'query', sparql));
|
|
696
|
-
return output === 'json' && !graphForm ? JSON.parse(r.text) : r.text;
|
|
696
|
+
return (output === 'json' || output === 'xml-report') && !graphForm ? JSON.parse(r.text) : r.text;
|
|
697
697
|
}
|
|
698
698
|
const fn = sparql12 ? e.datasetQuery12 : e.datasetQuery;
|
|
699
699
|
if (typeof fn !== 'function') {
|
|
@@ -2487,7 +2487,9 @@ function buildApi(driver) {
|
|
|
2487
2487
|
* One call, serialised answer (op queryRaw): parse `dataString`, close
|
|
2488
2488
|
* the default graph under `entail`, run the query, and write the answer
|
|
2489
2489
|
* in `output`: 'json' (default; parsed SPARQL results JSON), or 'csv',
|
|
2490
|
-
* 'tsv', 'xml', 'table', 'ntriples' (text)
|
|
2490
|
+
* 'tsv', 'xml', 'table', 'ntriples' (text), or 'xml-report' (a checked
|
|
2491
|
+
* XML/diagnostic object from Lean). Ordinary 'xml' rejects an XML
|
|
2492
|
+
* admission failure; 'xml-report' returns it as data. A CONSTRUCT or DESCRIBE
|
|
2491
2493
|
* answer is a graph: 'ntriples' or 'table' only.
|
|
2492
2494
|
* @param {string} dataString
|
|
2493
2495
|
* @param {string} queryString
|
|
@@ -2511,7 +2513,7 @@ function buildApi(driver) {
|
|
|
2511
2513
|
const r = entryResult(e.queryRaw(dataString, queryString,
|
|
2512
2514
|
JSON.stringify({ dataFormat: tables.rawDataFormats[dataFormat], entail, output })),
|
|
2513
2515
|
'queryRaw', queryString);
|
|
2514
|
-
return output === 'json' ? JSON.parse(r.text) : r.text;
|
|
2516
|
+
return output === 'json' || output === 'xml-report' ? JSON.parse(r.text) : r.text;
|
|
2515
2517
|
}
|
|
2516
2518
|
|
|
2517
2519
|
// ---------------------------------------------------------------
|
package/lib/sbm11-lws.mjs
CHANGED
|
@@ -40,40 +40,23 @@
|
|
|
40
40
|
// thread has neither: pass `transport` or run the engine in a worker.
|
|
41
41
|
|
|
42
42
|
// This reader grants its transport authority only within one HTTP container.
|
|
43
|
-
// A file is a raw relative filename/path, never a URL reference.
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
// A file is a raw relative filename/path, never a URL reference. The engine
|
|
44
|
+
// decides the container URL and every resource URL (op containerPath,
|
|
45
|
+
// formal/lean4/L4Factoidal/LWS/ContainerPath.lean: containerOf, resourceIn,
|
|
46
|
+
// theorem resourceIn_under) before any header is attached; this file only
|
|
47
|
+
// turns a refusal into a TypeError.
|
|
48
|
+
function containerUrl (engine, url) {
|
|
49
|
+
const r = engine.call('containerPath', [JSON.stringify({ kind: 'container', url: String(url) })])
|
|
50
|
+
if (!r.allowed) {
|
|
51
|
+
throw new TypeError(`lwsSbm11Reader: ${r.reason}; the container must be an absolute HTTP(S) URL without userinfo, query or fragment (use opts.auth/headers)`)
|
|
50
52
|
}
|
|
51
|
-
|
|
52
|
-
return base
|
|
53
|
+
return r.url
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
function resourceUrl (base, file) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
let probe = file
|
|
60
|
-
for (;;) {
|
|
61
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(probe) || /[\\?#\x00-\x1f\x7f]/.test(probe) ||
|
|
62
|
-
probe.split('/').some(part => part === '' || part === '.' || part === '..') ||
|
|
63
|
-
/%(?:2f|5c|3f|23)/i.test(probe)) {
|
|
64
|
-
throw new TypeError('resource path must remain inside the configured container')
|
|
65
|
-
}
|
|
66
|
-
// Decode only for rejection, never for the requested filename. Each changed
|
|
67
|
-
// pass shortens the string, so nested percent encodings are also bounded.
|
|
68
|
-
const next = probe.replace(/%([a-f0-9]{2})/gi, (_match, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
69
|
-
if (next === probe) break
|
|
70
|
-
probe = next
|
|
71
|
-
}
|
|
72
|
-
const resource = new URL(file.split('/').map(encodeURIComponent).join('/'), base)
|
|
73
|
-
if (resource.origin !== base.origin || !resource.pathname.startsWith(base.pathname)) {
|
|
74
|
-
throw new TypeError('resource URL escaped the configured container')
|
|
75
|
-
}
|
|
76
|
-
return resource.href
|
|
56
|
+
function resourceUrl (engine, base, file) {
|
|
57
|
+
const r = engine.call('containerPath', [JSON.stringify({ kind: 'resource', container: base, path: file })])
|
|
58
|
+
if (!r.allowed) throw new TypeError(`resource path must remain inside the configured container: ${r.reason}`)
|
|
59
|
+
return r.url
|
|
77
60
|
}
|
|
78
61
|
|
|
79
62
|
/** A synchronous GET with XMLHttpRequest (a browser worker). */
|
|
@@ -85,7 +68,8 @@ export function xhrSyncTransport (url, headers) {
|
|
|
85
68
|
x.send()
|
|
86
69
|
// XHR follows redirects internally. This rejects returned bytes, not the
|
|
87
70
|
// already-sent request. Caller headers require an explicit trusted transport.
|
|
88
|
-
|
|
71
|
+
// `url` is the engine's serialization, so any other response URL is a redirect.
|
|
72
|
+
if (x.responseURL !== url) throw new Error('LWS reader: redirected XHR resource refused')
|
|
89
73
|
return {
|
|
90
74
|
status: x.status,
|
|
91
75
|
header: (name) => x.getResponseHeader(name),
|
|
@@ -110,14 +94,20 @@ function unsatisfiedTotal (value) {
|
|
|
110
94
|
*
|
|
111
95
|
* @param {string} url absolute HTTP(S), no userinfo/query/fragment; container that holds CURRENT and the generation
|
|
112
96
|
* (or the generation container itself)
|
|
113
|
-
* @param {object}
|
|
97
|
+
* @param {object} opts
|
|
98
|
+
* @param {object} opts.engine a loaded engine handle (`call(op, args)`); its op
|
|
99
|
+
* containerPath decides the container URL and every resource URL
|
|
114
100
|
* @param {Function} [opts.transport] trusted synchronous GET; must constrain redirect/header forwarding
|
|
115
101
|
* @param {string} [opts.auth] a value for the Authorization header, such as
|
|
116
102
|
* `Bearer <access token>` (an LWS access token)
|
|
117
103
|
* @param {object} [opts.headers] further request headers; browser use requires explicit trusted transport
|
|
118
104
|
*/
|
|
119
105
|
export function lwsSbm11Reader (url, opts = {}) {
|
|
120
|
-
const
|
|
106
|
+
const engine = opts.engine
|
|
107
|
+
if (!engine || typeof engine.call !== 'function') {
|
|
108
|
+
throw new TypeError('lwsSbm11Reader: opts.engine (a loaded engine handle) decides the resource URLs')
|
|
109
|
+
}
|
|
110
|
+
const base = containerUrl(engine, url)
|
|
121
111
|
const extra = { ...(opts.headers || {}) }
|
|
122
112
|
if (opts.auth) extra.authorization = String(opts.auth)
|
|
123
113
|
let transport = opts.transport
|
|
@@ -137,7 +127,7 @@ export function lwsSbm11Reader (url, opts = {}) {
|
|
|
137
127
|
let e = files.get(file)
|
|
138
128
|
if (!e) {
|
|
139
129
|
let url
|
|
140
|
-
try { url = resourceUrl(base, file) } catch (error) {
|
|
130
|
+
try { url = resourceUrl(engine, base, file) } catch (error) {
|
|
141
131
|
fail(file, error.message)
|
|
142
132
|
return null
|
|
143
133
|
}
|
package/lib/version.mjs
CHANGED
package/lws/server.mjs
CHANGED
|
@@ -24,6 +24,16 @@
|
|
|
24
24
|
// lwsFetched [handle, documentJson] -> { ok, fetch? | installed? | refused? }
|
|
25
25
|
// lwsDelivered [handle, reportJson] -> { ok, changed, retry? }
|
|
26
26
|
// lwsFlush [handle, { now? }] -> { ok, changed, deliveries?, wake? }
|
|
27
|
+
// lwsEgress [handle, { url, addresses }] -> { ok, host, address } | { ok, refused }
|
|
28
|
+
//
|
|
29
|
+
// REQUESTS TO PRIVATE ADDRESSES (https://github.com/danbri/factoidal/issues/1126)
|
|
30
|
+
// Just before each delivery this host resolves the name the delivery names
|
|
31
|
+
// (`resolve`, every address), passes the addresses to `lwsEgress`, and
|
|
32
|
+
// connects to the address the answer names, with no second resolution
|
|
33
|
+
// (`postTo`). A refused delivery is not made; it is reported to
|
|
34
|
+
// `lwsDelivered` with `refused: true`. Which addresses are refused (the
|
|
35
|
+
// IANA special-purpose ranges) and the opt-in `allowPrivateFetch` are
|
|
36
|
+
// decided by `L4Factoidal/Net/Egress.lean` and `Notifications.egressOf`.
|
|
27
37
|
//
|
|
28
38
|
// RATE LIMIT AND INBOX CHECK (https://github.com/danbri/factoidal/issues/915)
|
|
29
39
|
// An answer may carry `wake: SECONDS`: deliveries wait for a rate-limit
|
|
@@ -87,7 +97,7 @@ import { loadEngine } from '../bin/engine.mjs'
|
|
|
87
97
|
import { readSnapshot, persistIfChanged, storageKeyText, storageModeText, loadFiles, persistFilesIfChanged, readStepBodies, acquireDirLock, atRestNotice } from './persist.mjs'
|
|
88
98
|
|
|
89
99
|
/** The ops this host needs from the engine. */
|
|
90
|
-
export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched', 'lwsDelivered',
|
|
100
|
+
export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched', 'lwsDelivered', 'lwsEgress',
|
|
91
101
|
'lwsLoad', 'lwsPersist', 'lwsRead', 'lwsDirLock', 'lwsAtRestNotice']
|
|
92
102
|
|
|
93
103
|
/** How long this host waits for the answer to one delivery request before
|
|
@@ -128,6 +138,59 @@ async function resolveHost (host) {
|
|
|
128
138
|
}
|
|
129
139
|
}
|
|
130
140
|
|
|
141
|
+
/** The most response bytes this host reads from an inbox; the rest is
|
|
142
|
+
* dropped (the engine reads only the status). */
|
|
143
|
+
const MAX_DELIVERY_ANSWER_BYTES = 65536
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* POST one delivery to the address the engine chose (`lwsEgress`). Pure
|
|
147
|
+
* I/O: the connection goes to `address` only (the lookup answers it, so the
|
|
148
|
+
* name is not resolved again), TLS still checks the certificate against the
|
|
149
|
+
* URL's host name, redirects are not followed. The status, or 0 when no
|
|
150
|
+
* answer came in time.
|
|
151
|
+
*/
|
|
152
|
+
async function postTo (url, address, headers, body) {
|
|
153
|
+
let target
|
|
154
|
+
try {
|
|
155
|
+
target = new URL(url)
|
|
156
|
+
} catch (_error) {
|
|
157
|
+
return 0
|
|
158
|
+
}
|
|
159
|
+
const module = target.protocol === 'https:' ? await import('node:https') : await import('node:http')
|
|
160
|
+
const family = address.indexOf(':') >= 0 ? 6 : 4
|
|
161
|
+
const fields = {}
|
|
162
|
+
for (const pair of Array.isArray(headers) ? headers : []) {
|
|
163
|
+
if (Array.isArray(pair) && pair.length === 2) fields[String(pair[0])] = String(pair[1])
|
|
164
|
+
}
|
|
165
|
+
const bytes = Buffer.from(typeof body === 'string' ? body : '', 'utf8')
|
|
166
|
+
fields['content-length'] = String(bytes.length)
|
|
167
|
+
return new Promise((done) => {
|
|
168
|
+
let finished = false
|
|
169
|
+
const finish = (status) => { if (!finished) { finished = true; done(status) } }
|
|
170
|
+
const request = module.request(target, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: fields,
|
|
173
|
+
lookup: (_host, options, callback) => {
|
|
174
|
+
if (options && options.all) callback(null, [{ address, family }])
|
|
175
|
+
else callback(null, address, family)
|
|
176
|
+
},
|
|
177
|
+
timeout: DELIVERY_TIMEOUT_MS
|
|
178
|
+
}, (response) => {
|
|
179
|
+
let size = 0
|
|
180
|
+
response.on('data', (chunk) => {
|
|
181
|
+
size += chunk.length
|
|
182
|
+
if (size > MAX_DELIVERY_ANSWER_BYTES) response.destroy()
|
|
183
|
+
})
|
|
184
|
+
response.on('end', () => finish(response.statusCode))
|
|
185
|
+
response.on('close', () => finish(response.statusCode))
|
|
186
|
+
response.on('error', () => finish(response.statusCode))
|
|
187
|
+
})
|
|
188
|
+
request.on('timeout', () => request.destroy(new Error('timeout')))
|
|
189
|
+
request.on('error', () => finish(0))
|
|
190
|
+
request.end(bytes)
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
131
194
|
/** The addresses of this machine's network interfaces. Pure I/O. */
|
|
132
195
|
async function interfaceAddresses () {
|
|
133
196
|
try {
|
|
@@ -323,6 +386,11 @@ export async function openLwsStorage (options = {}) {
|
|
|
323
386
|
// engine checks and decides.
|
|
324
387
|
if (typeof options.deliveryLimit === 'number') config.deliveryLimit = options.deliveryLimit
|
|
325
388
|
if (typeof options.deliveryWindow === 'number') config.deliveryWindow = options.deliveryWindow
|
|
389
|
+
// The limit per destination and the opt-in for private and other
|
|
390
|
+
// special-purpose delivery targets (issue 1126): passed unread; the
|
|
391
|
+
// engine decides (L4Factoidal/Net/Egress.lean, Notifications.admitDest).
|
|
392
|
+
if (typeof options.deliveryDestLimit === 'number') config.deliveryDestLimit = options.deliveryDestLimit
|
|
393
|
+
if (options.allowPrivateFetch === true) config.allowPrivateFetch = true
|
|
326
394
|
if (typeof options.redirectsMax === 'number') config.redirectsMax = options.redirectsMax
|
|
327
395
|
if (typeof options.controllerInbox === 'string') config.controllerInbox = options.controllerInbox
|
|
328
396
|
// CORS (https://github.com/danbri/factoidal/issues/923): `cors` is false or
|
|
@@ -440,22 +508,22 @@ export async function openLwsStorage (options = {}) {
|
|
|
440
508
|
function deliver (delivery) {
|
|
441
509
|
const job = (async () => {
|
|
442
510
|
let status = 0
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
}
|
|
454
|
-
status =
|
|
511
|
+
// Resolve now, let the engine choose the address (issue 1126), and
|
|
512
|
+
// connect to that address only.
|
|
513
|
+
const addresses = typeof delivery.resolve === 'string' ? await resolveHost(delivery.resolve) : []
|
|
514
|
+
if (closed) return
|
|
515
|
+
const decided = engine.call('lwsEgress', [handle, JSON.stringify({ url: delivery.url, addresses })])
|
|
516
|
+
const refused = decided !== null && typeof decided === 'object' && typeof decided.refused === 'string'
|
|
517
|
+
? decided.refused
|
|
518
|
+
: null
|
|
519
|
+
if (refused !== null) {
|
|
520
|
+
console.error(`lws-serve: delivery to ${delivery.url} not made: ${refused}`)
|
|
521
|
+
} else {
|
|
522
|
+
status = await postTo(delivery.url, String(decided.address), delivery.headers, delivery.body)
|
|
455
523
|
}
|
|
456
524
|
if (closed) return
|
|
457
525
|
const told = engine.call('lwsDelivered', [handle, JSON.stringify({
|
|
458
|
-
id: delivery.id, status, ...clockNow()
|
|
526
|
+
id: delivery.id, status, ...(refused !== null ? { refused: true } : {}), ...clockNow()
|
|
459
527
|
})])
|
|
460
528
|
persist(told)
|
|
461
529
|
const retry = told !== null && typeof told === 'object' ? told.retry : null
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@factoidal/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "EXPERIMENTAL UNDER-REVIEWED CODE: do not run public services on it without careful independent expert review. RDF parsing and writing (Turtle, TriG, N-Triples, N-Quads, RDF/XML, JSON-LD), SPARQL 1.1 and 1.2, RDFS and OWL reasoning, SHACL and ShEx validation, CSVW and RML mapping, RDFC-1.0, and SPARQL, LWS and Solid servers, for Node, Deno and browsers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sparql",
|
package/webdav/server.mjs
CHANGED
|
@@ -186,6 +186,13 @@ export async function openWebdav (options = {}) {
|
|
|
186
186
|
}
|
|
187
187
|
// The guest user (issue 1147), passed unread; the engine checks it.
|
|
188
188
|
if (typeof options.guest === 'string') login.guest = options.guest
|
|
189
|
+
// HTTP over XMPP (issue 1125): the transport, the account and, when the
|
|
190
|
+
// operator gave one, the XMPP server's address; the engine decides.
|
|
191
|
+
if (options.transport === 'xmpp') {
|
|
192
|
+
login.transport = 'xmpp'
|
|
193
|
+
if (typeof options.jid === 'string') login.jid = options.jid
|
|
194
|
+
if (typeof options.host !== 'string') delete login.host
|
|
195
|
+
}
|
|
189
196
|
const config = { listing, entropy: randomBase64(32), now: nowSeconds(), login }
|
|
190
197
|
// The Host and Origin guard of the HTTP listener (issue 1124): the bound
|
|
191
198
|
// address and the operator's names and origins, passed unread
|
|
@@ -257,6 +264,9 @@ export async function openWebdav (options = {}) {
|
|
|
257
264
|
randomBase64: randomBase64(16),
|
|
258
265
|
remoteAddress: typeof request.remoteAddress === 'string' ? request.remoteAddress : ''
|
|
259
266
|
}
|
|
267
|
+
// Over XMPP: the requester the XMPP client handle named (the sender's
|
|
268
|
+
// bare JID), passed unread (https://github.com/danbri/factoidal/issues/1128).
|
|
269
|
+
if (request.requester !== undefined && request.requester !== null) record.requester = request.requester
|
|
260
270
|
let got = blobCall(engine, 'webdavStep', [handle, JSON.stringify(record)], request.body || new Uint8Array(0))
|
|
261
271
|
for (let hop = 0; hop < MAX_HOPS; hop += 1) {
|
|
262
272
|
const env = got.envelope
|
|
@@ -294,6 +304,8 @@ export async function openWebdav (options = {}) {
|
|
|
294
304
|
engine,
|
|
295
305
|
mounts: ready.mounts || [],
|
|
296
306
|
warnings: ready.warnings || [],
|
|
307
|
+
/** The engine's `ready.login`: a users file is loaded. */
|
|
308
|
+
login: ready.login === true,
|
|
297
309
|
/** The start notices of the local mounts (a stale lock taken over,
|
|
298
310
|
* encryption at rest on or off), for standard error. */
|
|
299
311
|
storageNotices,
|
package/xmpp/README.md
CHANGED
|
@@ -100,7 +100,8 @@ process per connection behind socat) serves; the wasm session ops are
|
|
|
100
100
|
| `xmppClose` | `[server]` | `{ ok, sessions }` |
|
|
101
101
|
|
|
102
102
|
The state directory is laid out as the native carrier's, so the two
|
|
103
|
-
hosts can share one: `DIR/accounts` (one `user:password` per line
|
|
103
|
+
hosts can share one: `DIR/accounts` (one `user:password` per line, or a line with no
|
|
104
|
+
password from `factoidal xmpp-serve --verifier NAME`, for SCRAM only),
|
|
104
105
|
`DIR/roster/<bare jid>.tsv` (persisted on every `rostersSaved`, loaded
|
|
105
106
|
on start), and `DIR/spool/<bare jid>/<file>` — a stanza written there
|
|
106
107
|
by any process is delivered to that account's bound session and
|
|
@@ -130,8 +131,9 @@ RFC 6120 section 5 STARTTLS with `startTls: true` (op `xmppSessionTlsUp`;
|
|
|
130
131
|
the engine decides, the host does the handshake; see the theorems in
|
|
131
132
|
`formal/lean4/L4Factoidal/XMPP/Server.lean`).
|
|
132
133
|
Not: server-to-server federation, MUC/MIX/GC3 rooms, PRECIS.
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
Each name has its own SCRAM salt, from a seed the host gives the engine
|
|
135
|
+
(`saltSeed`; derived from the state key when there is one). The native
|
|
136
|
+
harness and a fixed `saltB64` keep one salt for all.
|
|
135
137
|
|
|
136
138
|
The shared engine currently reduces routed destinations to bare JIDs and
|
|
137
139
|
the Node host delivers to every bound resource. It does not implement full
|
|
@@ -204,4 +206,15 @@ The host closes a socket when a limit is reached. These are limits on bytes and
|
|
|
204
206
|
| `maxQueuedBytes` | | 8 MiB | Bytes waiting for a client that is not reading |
|
|
205
207
|
| `maxBuffered` | | 1 MiB | Bytes of an unfinished unit |
|
|
206
208
|
|
|
207
|
-
There is no limit per address: behind a proxy, every client has the same one.
|
|
209
|
+
There is no connection limit per address: behind a proxy, every client has the same one.
|
|
210
|
+
|
|
211
|
+
## Limits the engine applies
|
|
212
|
+
|
|
213
|
+
| Option | CLI | Default | Limit |
|
|
214
|
+
|---|---|---|---|
|
|
215
|
+
| `heldLimits.perAccount`, `.perAccountBytes` | | 256 stanzas, 4 MiB | Held for one offline account |
|
|
216
|
+
| `heldLimits.total`, `.totalBytes` | | 10,000 stanzas, 64 MiB | Held on the server |
|
|
217
|
+
| `heldLimits.senderPerWindow`, `.senderWindow` | | 60 in 60 s | Stanzas one account may have held; roster writes of one account |
|
|
218
|
+
| `addressLimit: false` | `--no-address-limit` | on | Failed logins per client address (5 in 60 s, 20 in an hour). Turn it off behind a proxy, where every client has one address |
|
|
219
|
+
|
|
220
|
+
Fixed in the engine: 5 failed logins in 60 s per account name, 3 failed attempts on a stream, 1,000 items in a roster, 64 levels and 8,192 elements in a stanza, and room nicks of at most 64 bytes.
|
package/xmpp/client.mjs
CHANGED
|
@@ -196,7 +196,9 @@ async function openStream (engine, config, options, onFeed, onClosed) {
|
|
|
196
196
|
* @param {string} [options.resource]
|
|
197
197
|
* @param {object[]} [options.mounts] the WebDAV handle's mounts, passed unread (the engine refuses writable local mounts without the opt-in)
|
|
198
198
|
* @param {boolean} [options.dangerouslyAllowWritableWithoutLogin]
|
|
199
|
-
* @param {
|
|
199
|
+
* @param {boolean} [options.login] the WebDAV handle has a users file (its `ready.login`, passed unread)
|
|
200
|
+
* @param {number} [options.tickMs] how often the clock is given to the engine (default 1000)
|
|
201
|
+
* @param {Function} options.step `async ({method, target, headers, body, requester}) => ({status, headers, body})`
|
|
200
202
|
* @param {object} [options.engine]
|
|
201
203
|
* @param {Function} [options.onError]
|
|
202
204
|
* @param {number} [options.maxBuffered] residual bytes the engine may hold before the socket is closed (default 16 MiB)
|
|
@@ -211,6 +213,7 @@ export async function connectHttpTarget (options = {}) {
|
|
|
211
213
|
config.allow = Array.isArray(options.allow) ? options.allow : []
|
|
212
214
|
if (Array.isArray(options.mounts)) config.mounts = options.mounts
|
|
213
215
|
if (options.dangerouslyAllowWritableWithoutLogin === true) config.dangerouslyAllowWritableWithoutLogin = true
|
|
216
|
+
if (options.login === true) config.login = true
|
|
214
217
|
|
|
215
218
|
let stream = null
|
|
216
219
|
/** One request the engine named: carried to `step`, answered. */
|
|
@@ -221,7 +224,10 @@ export async function connectHttpTarget (options = {}) {
|
|
|
221
224
|
method: request.method,
|
|
222
225
|
target: request.target,
|
|
223
226
|
headers: request.headers || [],
|
|
224
|
-
body: new Uint8Array(Buffer.from(request.bodyBase64 || '', 'base64'))
|
|
227
|
+
body: new Uint8Array(Buffer.from(request.bodyBase64 || '', 'base64')),
|
|
228
|
+
// The sender's bare JID as the engine named it (issue 1128),
|
|
229
|
+
// passed unread to the WebDAV handle's login.
|
|
230
|
+
requester: request.requester
|
|
225
231
|
})
|
|
226
232
|
answer = {
|
|
227
233
|
status: response.status,
|
|
@@ -243,11 +249,20 @@ export async function connectHttpTarget (options = {}) {
|
|
|
243
249
|
for (const r of answer.requests || []) carry(r)
|
|
244
250
|
})
|
|
245
251
|
const jid = await stream.bound
|
|
252
|
+
// The clock for the time limit of requests that wait for chunks
|
|
253
|
+
// (https://github.com/danbri/factoidal/issues/1128); the engine decides.
|
|
254
|
+
const timer = setInterval(() => {
|
|
255
|
+
if (!stream.isOpen()) return
|
|
256
|
+
try {
|
|
257
|
+
stream.write(call(engine, 'xmppClientTick', [stream.handle, String(Date.now())]).send)
|
|
258
|
+
} catch (_error) { /* handle gone */ }
|
|
259
|
+
}, typeof options.tickMs === 'number' ? options.tickMs : 1000)
|
|
260
|
+
stream.closed.then(() => clearInterval(timer))
|
|
246
261
|
return {
|
|
247
262
|
jid,
|
|
248
263
|
writableWithoutLogin: stream.opened.writableWithoutLogin || [],
|
|
249
264
|
closed: stream.closed,
|
|
250
|
-
close: () => stream.close()
|
|
265
|
+
close: () => { clearInterval(timer); return stream.close() }
|
|
251
266
|
}
|
|
252
267
|
}
|
|
253
268
|
|
package/xmpp/inpage.mjs
CHANGED
|
@@ -53,6 +53,8 @@ export async function openInPageXmppServer (options = {}) {
|
|
|
53
53
|
for (const k of ['domain', 'mucDomain', 'mixDomain', 'gc3Domain', 'saltB64']) {
|
|
54
54
|
if (typeof options[k] === 'string') config[k] = options[k]
|
|
55
55
|
}
|
|
56
|
+
if (typeof config.saltB64 !== 'string') config.saltSeed = randomToken()
|
|
57
|
+
config.occupantSeed = randomToken()
|
|
56
58
|
if (Array.isArray(options.accounts)) config.accounts = options.accounts
|
|
57
59
|
if (typeof options.iterationCount === 'number') config.iterationCount = options.iterationCount
|
|
58
60
|
const opened = engine.call('xmppOpen', [JSON.stringify(config)])
|
|
@@ -106,7 +108,11 @@ export async function openInPageXmppServer (options = {}) {
|
|
|
106
108
|
send (message) {
|
|
107
109
|
if (!open) throw new Error('the in-page XMPP connection is closed')
|
|
108
110
|
let answer
|
|
109
|
-
try {
|
|
111
|
+
try {
|
|
112
|
+
// The rate limits read the host's clock.
|
|
113
|
+
engine.call('xmppClock', [handle, String(Math.floor(Date.now() / 1000))])
|
|
114
|
+
answer = engine.call('xmppMsgFeed', [session, String(message)])
|
|
115
|
+
} catch (error) { onError(error); end(); return }
|
|
110
116
|
emit(answer.send || [])
|
|
111
117
|
router.apply(c, answer)
|
|
112
118
|
if (answer.close === true) end()
|
package/xmpp/server.mjs
CHANGED
|
@@ -38,12 +38,12 @@
|
|
|
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
40
|
// The other transport limits are `maxConnections`, `loginTimeoutMs` and
|
|
41
|
-
// `maxQueuedBytes`. There is no limit per address: behind a
|
|
42
|
-
// client has the same one.
|
|
41
|
+
// `maxQueuedBytes`. There is no connection limit per address: behind a
|
|
42
|
+
// proxy, every client has the same one.
|
|
43
43
|
|
|
44
|
-
import { randomBytes } from 'node:crypto'
|
|
44
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
45
45
|
import { StringDecoder } from 'node:string_decoder'
|
|
46
|
-
import { mkdir, readFile, writeFile, readdir, unlink, rename } from 'node:fs/promises'
|
|
46
|
+
import { mkdir, readFile, writeFile, readdir, unlink, rename, stat } from 'node:fs/promises'
|
|
47
47
|
import { join } from 'node:path'
|
|
48
48
|
import { loadEngine } from '../bin/engine.mjs'
|
|
49
49
|
import { createXmppRouter } from './router.mjs'
|
|
@@ -52,7 +52,7 @@ import { createXmppRouter } from './router.mjs'
|
|
|
52
52
|
export const XMPP_SERVER_OPS = [
|
|
53
53
|
'xmppOpen', 'xmppClose',
|
|
54
54
|
'xmppSessionOpen', 'xmppSessionFeed', 'xmppSessionDeliver', 'xmppSessionClose',
|
|
55
|
-
'xmppSessionTlsUp', 'xmppRosterGet', 'xmppRosterSet', 'xmppWsOpen', 'xmppStateLoad'
|
|
55
|
+
'xmppSessionTlsUp', 'xmppRosterGet', 'xmppRosterSet', 'xmppWsOpen', 'xmppStateLoad', 'xmppClock'
|
|
56
56
|
]
|
|
57
57
|
|
|
58
58
|
/** An error raised by this host, as opposed to one the engine reported.
|
|
@@ -119,10 +119,19 @@ function randomToken (bytes = 18) {
|
|
|
119
119
|
* @param {object} options
|
|
120
120
|
* @param {string} [options.domain] the domain the server is authoritative for (default `localhost`)
|
|
121
121
|
* @param {Array<{username: string, password: string}>} [options.accounts]
|
|
122
|
-
* @param {string} [options.accountsText]
|
|
123
|
-
*
|
|
122
|
+
* @param {string} [options.accountsText] the accounts file's text, one account per line:
|
|
123
|
+
* `name:password`, or the keys `factoidal xmpp-serve --verifier NAME` writes. A line that is
|
|
124
|
+
* neither stops the start (`Server.accountsOfText`)
|
|
125
|
+
* @param {string} [options.saltB64] one SCRAM salt for every name, base64; absent, each name has its own
|
|
126
|
+
* @param {string} [options.saltSeed] the seed of the salts; absent, a random one for this process
|
|
127
|
+
* @param {string} [options.occupantSeed] the key of the rooms' occupant ids; absent, a random one
|
|
124
128
|
* @param {number} [options.iterationCount] the SCRAM iteration count
|
|
125
129
|
* @param {boolean} [options.tlsCarrier] whether the carrier terminates TLS
|
|
130
|
+
* @param {boolean} [options.disableRateLimits] no failed SASL login limit and no sender rate on
|
|
131
|
+
* held stanzas (`--dangerously-disable-ratelimits`)
|
|
132
|
+
* @param {object} [options.heldLimits] caps of the stanzas held for offline accounts, each a
|
|
133
|
+
* number: `perAccount`, `perAccountBytes`, `total`, `totalBytes`, `senderPerWindow`,
|
|
134
|
+
* `senderWindow` (seconds); what is absent keeps the engine's default
|
|
126
135
|
* @param {object} [options.engine] an engine handle to reuse
|
|
127
136
|
* @returns {Promise<{engine: object, handle: string, domain: string,
|
|
128
137
|
* openSession: Function, roster: Function, setRoster: Function, close: Function}>}
|
|
@@ -133,13 +142,20 @@ export async function openXmppServer (options = {}) {
|
|
|
133
142
|
if (typeof options.domain === 'string') config.domain = options.domain
|
|
134
143
|
if (Array.isArray(options.accounts)) config.accounts = options.accounts
|
|
135
144
|
if (typeof options.accountsText === 'string') config.accountsText = options.accountsText
|
|
145
|
+
// One salt per name, from a seed the engine cannot make; a fixed `saltB64`
|
|
146
|
+
// (a transcript that must replay) is one salt for all.
|
|
136
147
|
if (typeof options.saltB64 === 'string') config.saltB64 = options.saltB64
|
|
148
|
+
else config.saltSeed = typeof options.saltSeed === 'string' ? options.saltSeed : randomToken(32)
|
|
149
|
+
// The key of the XEP-0421 occupant ids of the rooms.
|
|
150
|
+
config.occupantSeed = typeof options.occupantSeed === 'string' ? options.occupantSeed : randomToken(32)
|
|
137
151
|
if (typeof options.iterationCount === 'number') config.iterationCount = options.iterationCount
|
|
138
152
|
if (typeof options.tlsCarrier === 'boolean') config.tlsCarrier = options.tlsCarrier
|
|
139
153
|
// XEP-0045 room service (L4Factoidal/XMPP/Muc.lean); off unless named.
|
|
140
154
|
if (typeof options.mucDomain === 'string') config.mucDomain = options.mucDomain
|
|
141
155
|
// Members-only rooms: [{room, members: [bare JID]}]; no list, an open room.
|
|
142
156
|
if (Array.isArray(options.mucMembers)) config.mucMembers = options.mucMembers
|
|
157
|
+
// Nicks reserved for a JID: [{nick, jid}]. Each account's name is reserved for it anyway.
|
|
158
|
+
if (Array.isArray(options.mucReservedNicks)) config.mucReservedNicks = options.mucReservedNicks
|
|
143
159
|
// XEP-0369 channels (L4Factoidal/XMPP/Mix.lean), joined through MIX-PAM; off unless named.
|
|
144
160
|
if (typeof options.mixDomain === 'string') config.mixDomain = options.mixDomain
|
|
145
161
|
// GC3 prototype groups (L4Factoidal/XMPP/Gc3.lean; no published spec); off unless named.
|
|
@@ -147,6 +163,10 @@ export async function openXmppServer (options = {}) {
|
|
|
147
163
|
// The host keeps the state across restarts (a state key and a DIR): only
|
|
148
164
|
// then does the engine produce `store`/`unstore`.
|
|
149
165
|
if (options.persist === true) config.persist = true
|
|
166
|
+
// The failed SASL login limit is on unless the operator turns it off
|
|
167
|
+
// (https://github.com/danbri/factoidal/issues/1128); the engine decides.
|
|
168
|
+
if (options.disableRateLimits === true) config.disableRateLimits = true
|
|
169
|
+
if (options.heldLimits && typeof options.heldLimits === 'object') config.heldLimits = options.heldLimits
|
|
150
170
|
let opened
|
|
151
171
|
try {
|
|
152
172
|
opened = engine.call('xmppOpen', [JSON.stringify(config)])
|
|
@@ -162,6 +182,19 @@ export async function openXmppServer (options = {}) {
|
|
|
162
182
|
throw new XmppHostError('xmppOpen answered with no handle')
|
|
163
183
|
}
|
|
164
184
|
const handle = opened.handle
|
|
185
|
+
// The rate limits read the host's clock: tell the engine when the second changes.
|
|
186
|
+
let lastSecond = 0
|
|
187
|
+
const tick = () => {
|
|
188
|
+
const now = Math.floor(Date.now() / 1000)
|
|
189
|
+
if (now === lastSecond) return
|
|
190
|
+
lastSecond = now
|
|
191
|
+
try {
|
|
192
|
+
engine.call('xmppClock', [handle, String(now)])
|
|
193
|
+
} catch (error) {
|
|
194
|
+
// An older engine has no such op, and no limit that reads it.
|
|
195
|
+
if (!isUnknownOp(error)) throw error
|
|
196
|
+
}
|
|
197
|
+
}
|
|
165
198
|
// An older engine ignores the list, and the rooms would be open: do not start.
|
|
166
199
|
if (Array.isArray(options.mucMembers) && opened.mucMembers !== options.mucMembers.length) {
|
|
167
200
|
engine.call('xmppClose', [handle])
|
|
@@ -172,15 +205,22 @@ export async function openXmppServer (options = {}) {
|
|
|
172
205
|
handle,
|
|
173
206
|
domain: typeof opened.domain === 'string' ? opened.domain : (config.domain || 'localhost'),
|
|
174
207
|
accounts: typeof opened.accounts === 'number' ? opened.accounts : undefined,
|
|
208
|
+
/** The engine's start warnings (--dangerously-disable-ratelimits). */
|
|
209
|
+
warnings: Array.isArray(opened.warnings) ? opened.warnings : [],
|
|
210
|
+
tick,
|
|
175
211
|
/**
|
|
176
212
|
* One client connection.
|
|
177
|
-
*
|
|
213
|
+
* `remoteAddress` (the socket's) and the clock go to the engine for the
|
|
214
|
+
* failed SASL login limit (https://github.com/danbri/factoidal/issues/1128).
|
|
215
|
+
* @param {{streamId?: string, nonce?: string, tlsCarrier?: boolean, startTls?: boolean, maxUnits?: number, remoteAddress?: string}} [sessionOptions]
|
|
178
216
|
*/
|
|
179
217
|
openSession (sessionOptions = {}) {
|
|
180
218
|
const record = {
|
|
181
219
|
streamId: typeof sessionOptions.streamId === 'string' ? sessionOptions.streamId : randomToken(),
|
|
182
|
-
nonce: typeof sessionOptions.nonce === 'string' ? sessionOptions.nonce : randomToken()
|
|
220
|
+
nonce: typeof sessionOptions.nonce === 'string' ? sessionOptions.nonce : randomToken(),
|
|
221
|
+
now: Math.floor(Date.now() / 1000)
|
|
183
222
|
}
|
|
223
|
+
if (typeof sessionOptions.remoteAddress === 'string') record.remoteAddress = sessionOptions.remoteAddress
|
|
184
224
|
if (typeof sessionOptions.tlsCarrier === 'boolean') record.tlsCarrier = sessionOptions.tlsCarrier
|
|
185
225
|
if (typeof sessionOptions.startTls === 'boolean') record.startTls = sessionOptions.startTls
|
|
186
226
|
if (typeof sessionOptions.maxUnits === 'number') record.maxUnits = sessionOptions.maxUnits
|
|
@@ -192,6 +232,7 @@ export async function openXmppServer (options = {}) {
|
|
|
192
232
|
handle: session,
|
|
193
233
|
/** Feed received text; returns the engine's answer document. */
|
|
194
234
|
feed (text) {
|
|
235
|
+
tick()
|
|
195
236
|
return engine.call('xmppSessionFeed', [session, text])
|
|
196
237
|
},
|
|
197
238
|
/** The TLS handshake a feed asked for (`startTls: true`) finished. */
|
|
@@ -238,6 +279,14 @@ async function readAccountsFile (path) {
|
|
|
238
279
|
}
|
|
239
280
|
}
|
|
240
281
|
|
|
282
|
+
/** A warning when the accounts file can be read by anyone but its owner. */
|
|
283
|
+
async function accountsFileWarnings (path) {
|
|
284
|
+
if (process.platform === 'win32') return []
|
|
285
|
+
const { mode } = await stat(path)
|
|
286
|
+
if ((mode & 0o077) === 0) return []
|
|
287
|
+
return [`${path} can be read by other users of this machine: chmod 600 it`]
|
|
288
|
+
}
|
|
289
|
+
|
|
241
290
|
/**
|
|
242
291
|
* A `net.Server` (or `tls.Server`, when `cert` and `key` are given —
|
|
243
292
|
* XEP-0368 direct TLS) that runs one engine session per connection.
|
|
@@ -245,6 +294,7 @@ async function readAccountsFile (path) {
|
|
|
245
294
|
* The server is returned unlistened: the caller chooses the port.
|
|
246
295
|
*
|
|
247
296
|
* @param {object} options as `openXmppServer`, plus:
|
|
297
|
+
* @param {string} [options.accountsFile] the path `accountsText` came from, to check its mode
|
|
248
298
|
* @param {string} [options.dir] state directory: `accounts` is read from it when
|
|
249
299
|
* no accounts are given; rosters are persisted under `roster/`; `spool/` is
|
|
250
300
|
* polled for stanzas written by other processes
|
|
@@ -265,6 +315,8 @@ async function readAccountsFile (path) {
|
|
|
265
315
|
* without a bound resource before it is closed (default 30000; 0 disables)
|
|
266
316
|
* @param {number} [options.maxQueuedBytes] bytes waiting to be written to a
|
|
267
317
|
* client that is not reading, before the socket is closed (default 8 MiB)
|
|
318
|
+
* @param {boolean} [options.addressLimit] false: count failed logins per account name only, for a
|
|
319
|
+
* listener behind a proxy, where every client has one address (default true)
|
|
268
320
|
* @param {Function} [options.onError]
|
|
269
321
|
* @param {Function} [options.onLog]
|
|
270
322
|
* @returns {Promise<{server: object, xmpp: object, close: Function, sessions: Function}>}
|
|
@@ -293,9 +345,19 @@ export async function createXmppServer (options = {}) {
|
|
|
293
345
|
}
|
|
294
346
|
|
|
295
347
|
const serverOptions = { ...options }
|
|
348
|
+
let accountsFile = typeof options.accountsFile === 'string' ? options.accountsFile : null
|
|
296
349
|
if (dir !== null && !Array.isArray(options.accounts) && typeof options.accountsText !== 'string') {
|
|
297
350
|
const text = await readAccountsFile(join(dir, 'accounts'))
|
|
298
|
-
if (text !== null) serverOptions.accountsText = text
|
|
351
|
+
if (text !== null) { serverOptions.accountsText = text; accountsFile = join(dir, 'accounts') }
|
|
352
|
+
}
|
|
353
|
+
// Salts that survive a restart when the state does: a client that keeps a
|
|
354
|
+
// salted password goes on working, and a name's salt does not change.
|
|
355
|
+
if (typeof options.saltSeed !== 'string' && typeof options.stateKey === 'string') {
|
|
356
|
+
serverOptions.saltSeed = createHash('sha256').update('xmpp salt seed\0' + options.stateKey).digest('base64url')
|
|
357
|
+
}
|
|
358
|
+
// Occupant ids that survive a restart, for the same reason.
|
|
359
|
+
if (typeof options.occupantSeed !== 'string' && typeof options.stateKey === 'string') {
|
|
360
|
+
serverOptions.occupantSeed = createHash('sha256').update('xmpp occupant seed\0' + options.stateKey).digest('base64url')
|
|
299
361
|
}
|
|
300
362
|
const haveKey = options.cert !== undefined && options.key !== undefined
|
|
301
363
|
if (options.startTls === true && !haveKey) {
|
|
@@ -306,6 +368,7 @@ export async function createXmppServer (options = {}) {
|
|
|
306
368
|
if (typeof serverOptions.tlsCarrier !== 'boolean') serverOptions.tlsCarrier = useTls
|
|
307
369
|
if (dir !== null && options.stateKey !== undefined) serverOptions.persist = true
|
|
308
370
|
const xmpp = await openXmppServer(serverOptions)
|
|
371
|
+
if (accountsFile !== null) xmpp.warnings.push(...await accountsFileWarnings(accountsFile))
|
|
309
372
|
|
|
310
373
|
// Rosters the previous run persisted.
|
|
311
374
|
try {
|
|
@@ -363,6 +426,10 @@ export async function createXmppServer (options = {}) {
|
|
|
363
426
|
if (got.status !== 200) throw new XmppHostError(`state: ${path} answered ${got.status}`)
|
|
364
427
|
const loaded = xmpp.engine.call('xmppStateLoad', [xmpp.handle, path, new TextDecoder().decode(got.body)])
|
|
365
428
|
if (loaded === null || typeof loaded !== 'object') throw new XmppHostError(`state: ${path} was refused`)
|
|
429
|
+
if (loaded.stale === true) {
|
|
430
|
+
const gone = await state.step({ method: 'DELETE', target: path, headers: [], body: new Uint8Array(0) })
|
|
431
|
+
if (gone.status >= 300 && gone.status !== 404) throw new XmppHostError(`state: DELETE ${path} answered ${gone.status}`)
|
|
432
|
+
}
|
|
366
433
|
}
|
|
367
434
|
}
|
|
368
435
|
} catch (error) {
|
|
@@ -411,6 +478,11 @@ export async function createXmppServer (options = {}) {
|
|
|
411
478
|
// connection callback and therefore have no engine session yet.
|
|
412
479
|
const transports = new Set()
|
|
413
480
|
|
|
481
|
+
// The client's address for the login limit. Behind a proxy every client has
|
|
482
|
+
// the proxy's address, and a limit on it would stop them all: `addressLimit: false`.
|
|
483
|
+
const addressOf = (socket) =>
|
|
484
|
+
options.addressLimit !== false && typeof socket.remoteAddress === 'string' ? socket.remoteAddress : ''
|
|
485
|
+
|
|
414
486
|
// Transport limits, like `maxBuffered`: bytes and time, not protocol.
|
|
415
487
|
function closeIfBacklogged (c) {
|
|
416
488
|
if (c.socket.destroyed || !(c.socket.writableLength > maxQueuedBytes)) return
|
|
@@ -543,7 +615,7 @@ export async function createXmppServer (options = {}) {
|
|
|
543
615
|
function onConnection (socket, direct = false) {
|
|
544
616
|
let session
|
|
545
617
|
try {
|
|
546
|
-
session = xmpp.openSession({ tlsCarrier: useTls || direct, startTls: startTls && !direct })
|
|
618
|
+
session = xmpp.openSession({ tlsCarrier: useTls || direct, startTls: startTls && !direct, remoteAddress: addressOf(socket) })
|
|
547
619
|
} catch (error) {
|
|
548
620
|
onError(error)
|
|
549
621
|
socket.destroy()
|
|
@@ -611,7 +683,7 @@ export async function createXmppServer (options = {}) {
|
|
|
611
683
|
let handle
|
|
612
684
|
try {
|
|
613
685
|
const opened = xmpp.engine.call('xmppWsOpen', [xmpp.handle, JSON.stringify({
|
|
614
|
-
streamId: randomToken(), nonce: randomToken(),
|
|
686
|
+
streamId: randomToken(), nonce: randomToken(), remoteAddress: addressOf(socket),
|
|
615
687
|
path: typeof ws.path === 'string' ? ws.path : '/xmpp-websocket',
|
|
616
688
|
allowedOrigins: Array.isArray(ws.allowedOrigins) ? ws.allowedOrigins : [],
|
|
617
689
|
allowNoOrigin: ws.allowNoOrigin === true,
|
|
@@ -648,6 +720,7 @@ export async function createXmppServer (options = {}) {
|
|
|
648
720
|
if (!connections.has(c) || c.closing) return
|
|
649
721
|
let got
|
|
650
722
|
try {
|
|
723
|
+
xmpp.tick()
|
|
651
724
|
got = wsCall('xmppWsFeed', [handle], new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength))
|
|
652
725
|
} catch (error) {
|
|
653
726
|
onError(error)
|