@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 +24 -0
- package/bin/factoidal.mjs +173 -23
- package/bin/foafnaut-bleedingedge.mjs +4 -0
- package/bin/foafnaut-latest.mjs +4 -0
- package/bin/foafnaut.mjs +151 -0
- 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 +5 -2
- package/webdav/server.mjs +12 -0
- package/xmpp/README.md +30 -3
- package/xmpp/client.mjs +18 -3
- package/xmpp/inpage.mjs +7 -1
- package/xmpp/server.mjs +174 -23
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,
|
|
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;
|
|
@@ -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] [--
|
|
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
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
|
2633
|
-
// (https://github.com/danbri/factoidal/issues/1125).
|
|
2634
|
-
if (['
|
|
2635
|
-
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')
|
|
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(
|
|
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')
|
|
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
|
}
|
package/bin/foafnaut.mjs
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// foafnaut: start foafnaut2525, the Lean web rendering engine, in its own window.
|
|
3
|
+
// This launcher only chooses and starts an installed macOS app. All page work is
|
|
4
|
+
// in that app. Layout: $FOAFNAUT_HOME (default ~/.foafnaut), versions/<commit>/,
|
|
5
|
+
// current -> versions/<commit>, config.json {"bleedingEdge":"/path/to/App.app"}.
|
|
6
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { basename, join, resolve } from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const HELP = `foafnaut [--latest|--bleeding-edge] [PAGE] [-- APP_ARGS]
|
|
14
|
+
|
|
15
|
+
PAGE an http(s) URL or an HTML file; none opens the default page
|
|
16
|
+
--latest install the newest published macOS build, then use it
|
|
17
|
+
--bleeding-edge use the local build (config.json "bleedingEdge", or
|
|
18
|
+
~/Applications/foafnaut.app)
|
|
19
|
+
--verbose print the choice of app, follow the trace, and log input
|
|
20
|
+
--detach do not wait for the window to close
|
|
21
|
+
--install-latest install the newest published build and stop
|
|
22
|
+
--list list installed versions
|
|
23
|
+
--which print the app that would run
|
|
24
|
+
--help this text
|
|
25
|
+
|
|
26
|
+
Also: foafnaut-latest, foafnaut-bleedingedge. Environment: FOAFNAUT_HOME, and
|
|
27
|
+
FOAFNAUT_APP, which chooses the app outright. Page data goes to the app. Its trace
|
|
28
|
+
is in ~/Library/Logs/foafnaut/trace.jsonl. The command stays open until the window
|
|
29
|
+
closes; --detach returns at once.
|
|
30
|
+
|
|
31
|
+
EXPERIMENTAL UNDER-REVIEWED CODE - USE WITH CARE - DO NOT CREATE PUBLIC SERVICES USING THIS WITHOUT CAREFUL INDEPENDENT EXPERT REVIEW.
|
|
32
|
+
`;
|
|
33
|
+
const RELEASES = 'https://api.github.com/repos/danbri/factoidal-builds/releases?per_page=60';
|
|
34
|
+
const APP = 'foafnaut.app';
|
|
35
|
+
const home = () => process.env.FOAFNAUT_HOME || join(homedir(), '.foafnaut');
|
|
36
|
+
const fail = (message) => { console.error(`foafnaut: ${message}`); process.exit(1); };
|
|
37
|
+
|
|
38
|
+
function config() {
|
|
39
|
+
const path = join(home(), 'config.json');
|
|
40
|
+
if (!existsSync(path)) return {};
|
|
41
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch (error) { fail(`${path}: ${error.message}`); }
|
|
42
|
+
}
|
|
43
|
+
const bleedingEdgeApp = () => config().bleedingEdge || join(homedir(), 'Applications', APP);
|
|
44
|
+
function versionApp(dir) {
|
|
45
|
+
const app = join(dir, 'foafnaut', APP);
|
|
46
|
+
return existsSync(app) ? app : undefined;
|
|
47
|
+
}
|
|
48
|
+
function currentApp() {
|
|
49
|
+
const current = join(home(), 'current');
|
|
50
|
+
return existsSync(current) ? versionApp(current) : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function installLatest() {
|
|
54
|
+
const response = await fetch(RELEASES, { headers: { accept: 'application/vnd.github+json', 'user-agent': 'foafnaut' } });
|
|
55
|
+
if (!response.ok) fail(`release list: HTTP ${response.status}`);
|
|
56
|
+
// The release list is not in publication order.
|
|
57
|
+
const release = (await response.json())
|
|
58
|
+
.filter((r) => /^build-[0-9a-f]{12}-foafnaut-darwin-arm64$/.test(r.tag_name) && r.published_at)
|
|
59
|
+
.sort((a, b) => b.published_at.localeCompare(a.published_at))[0];
|
|
60
|
+
if (!release) fail('no published macOS build');
|
|
61
|
+
const commit = release.tag_name.split('-')[1];
|
|
62
|
+
const dir = join(home(), 'versions', commit);
|
|
63
|
+
if (!versionApp(dir)) {
|
|
64
|
+
const asset = (name) => release.assets.find((a) => a.name === name)?.browser_download_url ?? fail(`${release.tag_name}: no ${name}`);
|
|
65
|
+
const download = async (url) => {
|
|
66
|
+
const reply = await fetch(url, { headers: { 'user-agent': 'foafnaut' } });
|
|
67
|
+
if (!reply.ok) fail(`${url}: HTTP ${reply.status}`);
|
|
68
|
+
return Buffer.from(await reply.arrayBuffer());
|
|
69
|
+
};
|
|
70
|
+
console.error(`foafnaut: downloading ${release.tag_name}`);
|
|
71
|
+
const info = JSON.parse((await download(asset('build-info.json'))).toString('utf8'));
|
|
72
|
+
const archive = await download(asset('foafnaut-darwin-arm64.tar.gz'));
|
|
73
|
+
const expected = info.artifacts?.['foafnaut-darwin-arm64.tar.gz']?.sha256;
|
|
74
|
+
const actual = createHash('sha256').update(archive).digest('hex');
|
|
75
|
+
if (!expected) fail(`${release.tag_name}: build-info.json has no archive digest`);
|
|
76
|
+
if (expected !== actual) fail(`${release.tag_name}: archive digest ${actual} is not ${expected}`);
|
|
77
|
+
const staging = `${dir}.tmp`;
|
|
78
|
+
rmSync(staging, { recursive: true, force: true });
|
|
79
|
+
mkdirSync(staging, { recursive: true });
|
|
80
|
+
writeFileSync(join(staging, 'archive.tar.gz'), archive);
|
|
81
|
+
const unpack = spawnSync('tar', ['-xzf', 'archive.tar.gz'], { cwd: staging, stdio: 'inherit' });
|
|
82
|
+
if (unpack.status !== 0) fail('tar failed');
|
|
83
|
+
rmSync(join(staging, 'archive.tar.gz'));
|
|
84
|
+
writeFileSync(join(staging, 'build-info.json'), JSON.stringify(info, null, 2));
|
|
85
|
+
if (!versionApp(staging)) {
|
|
86
|
+
rmSync(staging, { recursive: true, force: true });
|
|
87
|
+
fail(`${release.tag_name} has no ${APP}; it predates the native window. Use --bleeding-edge.`);
|
|
88
|
+
}
|
|
89
|
+
rmSync(dir, { recursive: true, force: true });
|
|
90
|
+
renameSync(staging, dir);
|
|
91
|
+
}
|
|
92
|
+
const link = join(home(), 'current');
|
|
93
|
+
rmSync(link, { force: true });
|
|
94
|
+
symlinkSync(join('versions', commit), link);
|
|
95
|
+
console.error(`foafnaut: current is ${commit}`);
|
|
96
|
+
return versionApp(dir);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function pageArgs(page) {
|
|
100
|
+
if (page === undefined) return [];
|
|
101
|
+
if (/^https?:\/\//i.test(page)) return ['--url', page];
|
|
102
|
+
const path = resolve(page);
|
|
103
|
+
if (!existsSync(path)) fail(`no such file: ${page}`);
|
|
104
|
+
return ['--html', path];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function main(argv, channel = 'current') {
|
|
108
|
+
const split = argv.indexOf('--');
|
|
109
|
+
const options = split < 0 ? argv : argv.slice(0, split);
|
|
110
|
+
const extra = split < 0 ? [] : argv.slice(split + 1);
|
|
111
|
+
let page, which = false, verbose = false, detach = false;
|
|
112
|
+
// Each of these picks the app or the action. Two of them have no one meaning.
|
|
113
|
+
const exclusive = options.filter((arg) => ['--latest', '--bleeding-edge', '--install-latest', '--list'].includes(arg));
|
|
114
|
+
if (new Set(exclusive).size > 1) fail(`${exclusive.join(' and ')} can not be used together; see --help`);
|
|
115
|
+
for (const arg of options) {
|
|
116
|
+
if (arg === '--help' || arg === '-h') { process.stdout.write(HELP); return; }
|
|
117
|
+
else if (arg === '--latest') channel = 'latest';
|
|
118
|
+
else if (arg === '--bleeding-edge') channel = 'bleeding-edge';
|
|
119
|
+
else if (arg === '--which') which = true;
|
|
120
|
+
else if (arg === '--verbose' || arg === '-v') verbose = true;
|
|
121
|
+
else if (arg === '--detach') detach = true;
|
|
122
|
+
else if (arg === '--install-latest') { await installLatest(); return; }
|
|
123
|
+
else if (arg === '--list') {
|
|
124
|
+
const versions = join(home(), 'versions');
|
|
125
|
+
const current = existsSync(join(home(), 'current')) ? basename(realpathSync(join(home(), 'current'))) : '';
|
|
126
|
+
for (const name of existsSync(versions) ? readdirSync(versions).filter((n) => !n.endsWith('.tmp')) : [])
|
|
127
|
+
console.log(`${name === current ? '*' : ' '} ${name}`);
|
|
128
|
+
console.log(` bleeding edge: ${bleedingEdgeApp()}${existsSync(bleedingEdgeApp()) ? '' : ' (not found)'}`);
|
|
129
|
+
return;
|
|
130
|
+
} else if (arg.startsWith('-')) fail(`unknown option ${arg}; see --help`);
|
|
131
|
+
else if (page !== undefined) fail('give one page');
|
|
132
|
+
else page = arg;
|
|
133
|
+
}
|
|
134
|
+
if (process.platform !== 'darwin') fail('the native window is macOS only; use the Hub page in a WebGPU browser');
|
|
135
|
+
let app = process.env.FOAFNAUT_APP;
|
|
136
|
+
if (!app && channel === 'latest') app = await installLatest();
|
|
137
|
+
if (!app && channel === 'bleeding-edge') app = bleedingEdgeApp();
|
|
138
|
+
if (!app) app = currentApp() ?? bleedingEdgeApp();
|
|
139
|
+
if (!existsSync(app)) fail(`no app at ${app}. Run foafnaut --install-latest.`);
|
|
140
|
+
if (which) { console.log(app); return; }
|
|
141
|
+
const args = ['-n', ...(detach ? [] : ['-W']), app, '--args', ...pageArgs(page), ...(verbose ? ['--trace-input'] : []), ...extra];
|
|
142
|
+
const log = join(homedir(), 'Library', 'Logs', 'foafnaut', 'trace.jsonl');
|
|
143
|
+
if (verbose) console.error(`foafnaut: app ${app}\nfoafnaut: open ${args.join(' ')}\nfoafnaut: trace ${log}`);
|
|
144
|
+
// Follow only what this run appends.
|
|
145
|
+
const follow = verbose ? spawn('tail', ['-n', '0', '-F', log], { stdio: ['ignore', 'inherit', 'ignore'] }) : undefined;
|
|
146
|
+
const run = spawnSync('open', args, { stdio: 'inherit' });
|
|
147
|
+
follow?.kill();
|
|
148
|
+
process.exit(run.status ?? 1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) await main(process.argv.slice(2));
|
package/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;
|