@factoidal/core 0.19.0 → 0.21.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/bin/factoidal.mjs CHANGED
@@ -97,7 +97,7 @@ const REVIEW_WARNING = 'EXPERIMENTAL UNREVIEWED CODE - USE WITH CARE - DO NOT CR
97
97
 
98
98
  /** The commands that serve, speak a protocol, or handle keys and tokens. */
99
99
  const WARNED_COMMANDS = new Set(['serve', 'lws-serve', 'solid-serve', 'solid-client', 'lws-client',
100
- 'authz-serve', 'authz-keys', 'xmpp-serve', 'webdav-serve'])
100
+ 'authz-serve', 'authz-keys', 'xmpp-serve', 'webdav-serve', 'webdav-user', 'xmpp-http-proxy', 'lws-keygen'])
101
101
 
102
102
  const USAGE = `factoidal - the Factoidal persisted store, from Node or Deno
103
103
 
@@ -116,6 +116,9 @@ commands:
116
116
  Service Description over HTTP
117
117
  lws-serve DIR serve Linked Web Storage 1.0 over HTTP
118
118
  solid-serve DIR serve the Solid Protocol over HTTP
119
+ lws-keygen print a new storage key for encryption at rest
120
+ (FACTOIDAL_LWS_KEY of lws-serve, solid-serve and
121
+ webdav-serve local mounts)
119
122
  solid-client OP URL run one Solid client operation
120
123
  lws-client OP URL run one Linked Web Storage client operation
121
124
  authz-serve DIR serve an OAuth 2.0 authorization server for LWS
@@ -124,6 +127,10 @@ commands:
124
127
  xmpp-serve DIR serve XMPP (RFC 6120/6121) over TCP or direct TLS
125
128
  webdav-serve CONFIGDIR serve local and remote LWS storages to file
126
129
  browsers over WebDAV (RFC 4918)
130
+ webdav-user FILE NAME add or replace a user in a webdav-serve users
131
+ file (the password is read from standard input)
132
+ xmpp-http-proxy a local HTTP listener (127.0.0.1) whose requests
133
+ go over XMPP (XEP-0332) to one target JID
127
134
 
128
135
  global options:
129
136
  -h, --help print this text, or a command's own help
@@ -337,6 +344,24 @@ options:
337
344
  --no-worker serve on this thread instead of a worker thread with a
338
345
  raised call stack (large collections need the worker)
339
346
  --quiet print the origin only
347
+ --allow-host NAME
348
+ also answer requests whose Host field names NAME (a reverse
349
+ proxy or a Tailscale name); give it once per name, with no
350
+ port. Without it the listener answers only localhost,
351
+ 127.0.0.1, [::1], the --host address and the addresses of
352
+ its interfaces, and gives 421 Misdirected Request to any
353
+ other Host (DNS rebinding)
354
+ --allow-origin ORIGIN
355
+ also accept a request that changes state (any method but
356
+ GET, HEAD and OPTIONS) from this origin, written
357
+ scheme://host[:port]; give it once per origin. Without it
358
+ such a request with an Origin field of another origin gets
359
+ 403 (a cross-site form POST)
360
+ https://github.com/danbri/factoidal/issues/1124
361
+
362
+ serve has no authentication: anyone who reaches the port can query and
363
+ update the store. The Host and Origin checks above stop a web page in the
364
+ user's browser from doing so (formal/lean4/L4Factoidal/HTTP/LocalGuard.lean).
340
365
 
341
366
  An update, or a Graph Store PUT, POST or DELETE, is appended to the
342
367
  activated generation's delta log as one batch before the response is sent,
@@ -364,7 +389,7 @@ usage: factoidal authz-serve DIR --resource IRI[,IRI...] [--issuer IRI]
364
389
  [--token-lifetime SECONDS] [--rotate-after SECONDS]
365
390
  [--allow-http] [--allow-private-fetch]
366
391
  [--cors-origin ORIGIN]... [--no-cors]
367
- [--saml-idp FILE]...
392
+ [--saml-idp FILE]... [--saml-decrypt-key FILE]...
368
393
 
369
394
  An authorization server as a component (https://github.com/danbri/factoidal/issues/916),
370
395
  for lws-serve and any resource server that reads RFC 8414 metadata:
@@ -382,8 +407,15 @@ JSON: {"entityId": "<the saml:Issuer value>", "jwk": <its RSA public JWK>}.
382
407
  An assertion must carry one enveloped XML Signature (exclusive
383
408
  canonicalization, rsa-sha256) that verifies under that key, an audience that
384
409
  names the issuer of this server, and bearer subject confirmation; its NameID
385
- is the subject and its Recipient the client. Encrypted assertions are
386
- refused. Without --saml-idp the token type is refused and not advertised.
410
+ is the subject and its Recipient the client. Without --saml-idp the token
411
+ type is refused and not advertised.
412
+
413
+ --saml-decrypt-key FILE (repeatable) is one private JWK (RSA, EC P-256, OKP
414
+ X25519 or oct) that decrypts saml:EncryptedAssertion and saml:EncryptedID
415
+ (XML Encryption 1.1: RSA-OAEP, ECDH-ES with AES Key Wrap, or AES Key Wrap;
416
+ content AES-GCM). AES runs through WebCrypto. The decrypted assertion must
417
+ be signed as above. Without it, encrypted assertions are refused
418
+ (https://github.com/danbri/factoidal/issues/1073).
387
419
 
388
420
  --resource names the resource IRIs (for lws-serve: BASE.well-known/lws-storage)
389
421
  tokens may be issued for; any other is refused with invalid_target.
@@ -454,6 +486,17 @@ new random nonce for every write, and authenticated when the storage is
454
486
  opened. A wrong key, a changed or truncated file, a clear-text file with the
455
487
  variable set, or a sealed file without it stops the start with a message.
456
488
  It protects the file on disk only (formal/lean4/L4Factoidal/LWS/SnapshotSeal.lean).
489
+ Make a key with \`factoidal lws-keygen\`; keep a copy, a lost key is lost data.
490
+ At start the server says on standard error whether the storage is sealed
491
+ (and the key id) or written in clear text.
492
+
493
+ One process writes DIR (https://github.com/danbri/factoidal/issues/1135):
494
+ the server holds the lock file DIR/lws.lock while it runs and removes it at
495
+ a clean exit. A start refuses a DIR whose lock names a process that runs
496
+ (another lws-serve, solid-serve or webdav-serve local mount). A lock left by
497
+ a process that does not run (after a crash) is taken over with a warning.
498
+ The engine decides the file, its contents and every refusal
499
+ (formal/lean4/L4Factoidal/LWS/DirLock.lean).
457
500
 
458
501
  --port N the port to bind; 0 takes an ephemeral one (default 3000)
459
502
  --host ADDRESS the address to bind (default 127.0.0.1)
@@ -520,9 +563,13 @@ It protects the file on disk only (formal/lean4/L4Factoidal/LWS/SnapshotSeal.lea
520
563
  --cors-origin ORIGIN
521
564
  answer CORS (Fetch Standard) only for this origin, written
522
565
  scheme://host[:port] (for example https://danbri.github.io);
523
- give it once per origin. Default: echo any valid request
524
- Origin, as the Solid Protocol section 8.1 requires of Solid
525
- servers. Never Access-Control-Allow-Credentials: the token
566
+ give it once per origin. Default under authorization: echo
567
+ any valid request Origin, as the Solid Protocol section 8.1
568
+ requires of Solid servers. Default with --insecure-no-auth:
569
+ no CORS fields, because every request is served there and an
570
+ echo would let any web page read and write the storage
571
+ (https://github.com/danbri/factoidal/issues/1124). Never
572
+ Access-Control-Allow-Credentials: the token
526
573
  travels in Authorization. A preflight (OPTIONS with
527
574
  Access-Control-Request-Method) is answered 204 before any
528
575
  authorization decision.
@@ -539,8 +586,8 @@ is required):
539
586
  BASE.well-known/lws-token, the JWK Set at
540
587
  BASE.well-known/lws-jwks, signing keys in DIR (as
541
588
  authz-serve; --alg, --token-lifetime, --rotate-after,
542
- --allow-http, --allow-private-fetch and --saml-idp as
543
- there). The
589
+ --allow-http, --allow-private-fetch, --saml-idp and
590
+ --saml-decrypt-key as there). The
544
591
  storage trusts it, and any --issuer too. Each request
545
592
  is the authorization server's when the engine says so
546
593
  (L4Factoidal/Authz/Mount.lean); every other is the
@@ -555,7 +602,25 @@ is required):
555
602
  "audience":IRI?, "leeway":SECONDS?,
556
603
  "refreshInterval":SECONDS?}
557
604
  --insecure-no-auth development only: no authorization at all; every
558
- request is served. Never the default.
605
+ request is served. Never the default. The storage
606
+ then answers only its own host names and refuses
607
+ writes from other origins (see --allow-host and
608
+ --allow-origin), and sends no CORS fields unless
609
+ --cors-origin names an origin.
610
+ --allow-host NAME with --insecure-no-auth:
611
+ also answer requests whose Host field names NAME (a reverse
612
+ proxy or a Tailscale name); give it once per name, with no
613
+ port. Without it the listener answers only localhost,
614
+ 127.0.0.1, [::1], the --host address and the addresses of
615
+ its interfaces, and gives 421 Misdirected Request to any
616
+ other Host (DNS rebinding)
617
+ --allow-origin ORIGIN with --insecure-no-auth:
618
+ also accept a request that changes state (any method but
619
+ GET, HEAD and OPTIONS) from this origin, written
620
+ scheme://host[:port]; give it once per origin. Without it
621
+ such a request with an Origin field of another origin gets
622
+ 403 (a cross-site form POST)
623
+ https://github.com/danbri/factoidal/issues/1124
559
624
  --owner WEBID the storage owner: a new storage gets a root ACL
560
625
  that gives this agent Read, Write and Control
561
626
  --root-acl FILE a Turtle root ACL for a new storage instead
@@ -591,7 +656,9 @@ handle and written to DIR/lws-state.json after every request that
591
656
  changed it (temporary file, fsync, rename), and read back on the next
592
657
  start. The engine decides the snapshot format and refuses a snapshot
593
658
  path with a dot segment or other traversal form
594
- (formal/lean4/L4Factoidal/LWS/Snapshot.lean).
659
+ (formal/lean4/L4Factoidal/LWS/Snapshot.lean). A DIR that holds a Linked
660
+ Web Storage storage (written by lws-serve) is refused: only lws-serve
661
+ serves it, through LWS authorization (Wasm/Ops/Solid.lean, solidRefusesLws).
595
662
 
596
663
  Encryption at rest (off by default): when the environment variable
597
664
  FACTOIDAL_LWS_KEY holds a 32-byte key (64 hex digits, or 43 characters of
@@ -600,6 +667,17 @@ new random nonce for every write, and authenticated when the storage is
600
667
  opened. A wrong key, a changed or truncated file, a clear-text file with the
601
668
  variable set, or a sealed file without it stops the start with a message.
602
669
  It protects the file on disk only (formal/lean4/L4Factoidal/LWS/SnapshotSeal.lean).
670
+ Make a key with \`factoidal lws-keygen\`; keep a copy, a lost key is lost data.
671
+ At start the server says on standard error whether the storage is sealed
672
+ (and the key id) or written in clear text.
673
+
674
+ One process writes DIR (https://github.com/danbri/factoidal/issues/1135):
675
+ the server holds the lock file DIR/lws.lock while it runs and removes it at
676
+ a clean exit. A start refuses a DIR whose lock names a process that runs
677
+ (another lws-serve, solid-serve or webdav-serve local mount). A lock left by
678
+ a process that does not run (after a crash) is taken over with a warning.
679
+ The engine decides the file, its contents and every refusal
680
+ (formal/lean4/L4Factoidal/LWS/DirLock.lean).
603
681
 
604
682
  --port N the port to bind; 0 takes an ephemeral one (default 3000)
605
683
  --host ADDRESS the address to bind (default 127.0.0.1)
@@ -619,7 +697,22 @@ It protects the file on disk only (formal/lean4/L4Factoidal/LWS/SnapshotSeal.lea
619
697
  "jwks":{...}?}], "leeway"?, "iatWindow"?,
620
698
  "refreshInterval"?, "checkProfileIssuer"?}
621
699
  --insecure-no-auth development only: no authentication; Web Access
622
- Control is not enforced
700
+ Control is not enforced. The server then answers only its
701
+ own host names and refuses writes from other origins.
702
+ --allow-host NAME with --insecure-no-auth:
703
+ also answer requests whose Host field names NAME (a reverse
704
+ proxy or a Tailscale name); give it once per name, with no
705
+ port. Without it the listener answers only localhost,
706
+ 127.0.0.1, [::1], the --host address and the addresses of
707
+ its interfaces, and gives 421 Misdirected Request to any
708
+ other Host (DNS rebinding)
709
+ --allow-origin ORIGIN with --insecure-no-auth:
710
+ also accept a request that changes state (any method but
711
+ GET, HEAD and OPTIONS) from this origin, written
712
+ scheme://host[:port]; give it once per origin. Without it
713
+ such a request with an Origin field of another origin gets
714
+ 403 (a cross-site form POST)
715
+ https://github.com/danbri/factoidal/issues/1124
623
716
 
624
717
  The server makes no protocol decision. Storage discovery, containment
625
718
  triples, slash semantics, the Allow and Accept-* headers, the PUT/POST/
@@ -666,23 +759,96 @@ MUC/MIX/GC3 rooms.
666
759
  Specifications: https://www.rfc-editor.org/rfc/rfc6120 and
667
760
  https://www.rfc-editor.org/rfc/rfc6121`,
668
761
 
762
+ 'xmpp-http-proxy': `factoidal xmpp-http-proxy - reach an HTTP server over XMPP (XEP-0332) from 127.0.0.1
763
+
764
+ usage: factoidal xmpp-http-proxy --xmpp JID --xmpp-password-file FILE
765
+ --target JID/RESOURCE [--port N] [--host 127.0.0.1]
766
+ [--xmpp-host H] [--xmpp-port P] [--xmpp-tls] [--xmpp-ca FILE]
767
+ [--xmpp-resource R] [--timeout SECONDS]
768
+
769
+ Connects OUT to an XMPP server as JID (SASL SCRAM-SHA-256), binds a resource,
770
+ and opens an HTTP listener on 127.0.0.1. Every HTTP request to that listener
771
+ is sent as a XEP-0332 request to --target, and the answer is written back.
772
+ So a file browser, rclone, curl or "factoidal lws-client" uses
773
+ http://127.0.0.1:PORT/ to reach "factoidal webdav-serve CONFIGDIR --xmpp"
774
+ behind NAT. JID must be in that server's --xmpp-allow list, or every
775
+ request gets 403.
776
+
777
+ --xmpp JID the account (user@domain[/resource])
778
+ --xmpp-password-file FILE the account's password (first line)
779
+ --target JID/RESOURCE the full JID of the server (as webdav-serve
780
+ --xmpp prints it). Requests go to this JID and
781
+ to no other: this is not an open proxy.
782
+ --port N the port to listen on (default 8091; 0 takes an
783
+ ephemeral one)
784
+ --host ADDRESS the address to listen on: 127.0.0.1 only. Any
785
+ other address is refused (exit 1).
786
+ --xmpp-host H the XMPP server's address (default: the JID's
787
+ domain; no DNS SRV lookup)
788
+ --xmpp-port P default 5222, or 5223 with --xmpp-tls
789
+ --xmpp-tls direct TLS (XEP-0368). Without it, only a
790
+ loopback --xmpp-host (127.0.0.0/8, ::1) is
791
+ accepted.
792
+ --xmpp-ca FILE PEM certificates to trust for --xmpp-tls
793
+ --xmpp-resource R the resource to bind (default proxy)
794
+ --timeout SECONDS how long a request waits for its answer
795
+ (default 60, 1 to 3600); then 504
796
+
797
+ It prints the listener's origin (http://127.0.0.1:PORT/) on standard output.
798
+
799
+ The listener has no login of its own. Every process on this computer that
800
+ can open a connection to 127.0.0.1 acts as JID, and a web page in a browser
801
+ on this computer can send requests to it (the Host and Origin check of
802
+ https://github.com/danbri/factoidal/issues/1124 is not in this command yet).
803
+ Authorization headers pass to the target unchanged; the proxy adds no
804
+ credential. Proxy-Authorization and the other hop-by-hop headers (RFC 9110
805
+ section 7.6.1) are not forwarded. A request body above 16 MiB gets 413, more
806
+ than 16 waiting requests 503, an absolute-form request target (a forward
807
+ proxy request) 400, CONNECT 405.
808
+
809
+ Every decision is in formal/lean4/L4Factoidal/XMPP/HttpRequester.lean and
810
+ formal/lean4/Wasm/Ops/XmppClient.lean. Design:
811
+ docs/designissues/2026-10-28-http-over-xmpp.md
812
+ (https://github.com/danbri/factoidal/issues/1107).`,
813
+
669
814
  'webdav-serve': `factoidal webdav-serve - serve LWS storages to file browsers over WebDAV
670
815
 
671
816
  usage: factoidal webdav-serve CONFIGDIR [--port N] [--host ADDRESS]
672
-
673
- Starts a WebDAV server (RFC 4918, classes 1 and 2) that macOS Finder,
817
+ [--users FILE] [--guest NAME] [--cert FILE --key FILE]
818
+ [--allow-host NAME]... [--allow-origin ORIGIN]...
819
+ [--login-limit N] [--login-window SECONDS]
820
+ [--dangerously-disable-ratelimits]
821
+ factoidal webdav-serve CONFIGDIR --xmpp JID --xmpp-password-file FILE
822
+ --xmpp-allow JID[,JID...] [--guest NAME] [--xmpp-host H] [--xmpp-port P] [--xmpp-tls]
823
+
824
+ EMERGENCY MODE (https://github.com/danbri/factoidal/issues/1147): the server
825
+ is READ ONLY. Every write method (PUT, DELETE, MKCOL, COPY, MOVE, PROPPATCH,
826
+ LOCK, UNLOCK) answers 403 on every mount; "writable:" in a mount file is
827
+ ignored. Every request acts as one named user: a request with no credentials
828
+ acts as the --guest user (with no --guest it gets 401); a request with
829
+ credentials must log in through --users (with no --users it gets 401). Each
830
+ mount file names its users ("- users: alice, bob", required); any other user
831
+ gets 404 for that mount, and the root collection does not list it. Every
832
+ "kind: local" mount is refused at start: use kind: lws with an agent key.
833
+ "auth: bearer" is refused unless the mount file says
834
+ "allow-external-token: true".
835
+
836
+ Starts a WebDAV server (RFC 4918, class 1) that macOS Finder,
674
837
  Windows Explorer, GNOME Files and KDE Dolphin can connect to. Its root
675
838
  collection holds one folder per mount; a mount is one Markdown file
676
839
  CONFIGDIR/NAME.md (README.md is not a mount):
677
840
 
678
841
  # Display name
679
- - kind: local | lws
842
+ - kind: lws (local: refused, issue 1147)
843
+ - users: alice, bob (required: the users who may use it)
680
844
  - path: ./alice-storage (local: an lws-serve storage directory)
681
845
  - url: https://storage.example/alice/ (lws: the storage root)
682
846
  - auth: none | did-key | bearer
683
847
  - key-file: ./alice.jwk (did-key: a P-256 private JWK; made when absent)
684
848
  - token-file: ./alice.token (bearer: an access token)
685
- - writable: true | false (default true for local, false for lws)
849
+ - allow-external-token: true (bearer: required; a credential from
850
+ outside this installation)
851
+ - writable: true | false (read, and ignored: read only, issue 1147)
686
852
  - storage: snapshot | files (local: default what the directory holds)
687
853
  - base: IRI (local: the storage root's IRI, default http://localhost/)
688
854
 
@@ -690,12 +856,89 @@ Only the first "# " heading and the first "- key: value" list are read; the
690
856
  words in brackets above are notes, not part of a value. A malformed file
691
857
  stops the start with its name and line number.
692
858
 
693
- A local mount opens the directory with the operations lws-serve uses, as
694
- the storage's owner (do not serve the same directory with lws-serve at the
695
- same time). An lws mount reaches the storage through the LWS client.
859
+ A local mount opened the directory with no authorization, as the storage's
860
+ owner; it is refused at start until the server can tell that a storage has
861
+ no access control (https://github.com/danbri/factoidal/issues/1147). An lws
862
+ mount reaches the storage through the LWS client, with the mount's key or
863
+ token: the LWS storage decides each request with its own access control.
696
864
 
697
865
  --port N the port to bind (default 8090; 0 takes an ephemeral one)
698
866
  --host ADDRESS the address to bind (default 127.0.0.1)
867
+ --users FILE every request needs a user name and password (HTTP Basic,
868
+ RFC 7617) from FILE, made with "factoidal webdav-user";
869
+ without TLS only on a loopback address (127.0.0.1, ::1),
870
+ because Basic sends the password in clear text
871
+ --guest NAME a request with no credentials acts as the user NAME (list
872
+ it in the "users:" of the mounts it may read). NAME may
873
+ not be a user of --users. Without --guest such a request
874
+ gets 401 (https://github.com/danbri/factoidal/issues/1147)
875
+ --cert FILE a PEM certificate chain: serve HTTPS (TLS)
876
+ --key FILE the PEM private key of --cert
877
+ --login-limit N with --users: failed logins allowed per user name and per
878
+ client address in one window (default 5, 1 to 1000)
879
+ --login-window SECONDS
880
+ with --users: the window of --login-limit (default 60,
881
+ 1 to 86400)
882
+ --dangerously-disable-ratelimits
883
+ with --users: no failed-login limit (a warning is printed
884
+ at start); any client can then try passwords without end
885
+ --allow-host NAME
886
+ also answer requests whose Host field names NAME (a reverse
887
+ proxy or a Tailscale name); give it once per name, with no
888
+ port. Without it the listener answers only localhost,
889
+ 127.0.0.1, [::1], the --host address and the addresses of
890
+ its interfaces, and gives 421 Misdirected Request to any
891
+ other Host (DNS rebinding)
892
+ --allow-origin ORIGIN
893
+ also accept a request that changes state (any method but
894
+ GET, HEAD and OPTIONS) from this origin, written
895
+ scheme://host[:port]; give it once per origin. Without it
896
+ such a request with an Origin field of another origin gets
897
+ 403 (a cross-site form POST)
898
+ https://github.com/danbri/factoidal/issues/1124
899
+
900
+ Without --users the listener has no login: only the --guest user is
901
+ served, and anyone who can reach the port reads the mounts that list it.
902
+ Keep it on 127.0.0.1, or give --users and --cert/--key. A users file holds PBKDF2-HMAC-SHA256 password hashes, one
903
+ user per line, NAME:pbkdf2-sha256:ITERATIONS:SALT_B64:HASH_B64; a malformed
904
+ line stops the start with the file name and line number. Expert review of the
905
+ login: https://github.com/danbri/factoidal/issues/1117
906
+
907
+ Failed logins are limited by default
908
+ (https://github.com/danbri/factoidal/issues/1119): after 5 failed logins in 60
909
+ seconds for one user name (known or not) or from one client address (an IPv6
910
+ client by its /64), a request with credentials gets 429 Too Many Requests
911
+ with Retry-After, and no password is checked, also the right one, until that
912
+ window ends. A request with no credentials is a 401 and does not count.
913
+
914
+ HTTP over XMPP (XEP-0332; https://github.com/danbri/factoidal/issues/1107):
915
+ with --xmpp the server opens NO port. It connects OUT to an XMPP server as
916
+ JID (SASL SCRAM-SHA-256), binds a resource, and answers XEP-0332 requests
917
+ from the bare JIDs in --xmpp-allow (others get "forbidden"). A server behind
918
+ NAT is reached this way with no inbound port.
919
+
920
+ --xmpp JID the account (user@domain[/resource])
921
+ --xmpp-password-file FILE the account's password (first line)
922
+ --xmpp-allow JID[,JID] bare JIDs that may send requests (required)
923
+ --xmpp-host H the XMPP server's address (default: the JID's
924
+ domain; no DNS SRV lookup)
925
+ --xmpp-port P default 5222, or 5223 with --xmpp-tls
926
+ --xmpp-tls direct TLS (XEP-0368); STARTTLS is not
927
+ implemented. Without it, only a loopback
928
+ --xmpp-host (127.0.0.0/8, ::1) is accepted.
929
+ --xmpp-ca FILE PEM certificates to trust for --xmpp-tls
930
+ --dangerously-allow-writable-without-login
931
+ start although a local mount is writable:
932
+ there is no login on the XMPP path yet
933
+ (https://github.com/danbri/factoidal/issues/1125),
934
+ so every allowed JID writes as the owner.
935
+ Without it such a start is refused.
936
+ --xmpp-resource R the resource to bind (default dav)
937
+
938
+ It prints the bound JID (xmpp:user@domain/resource) on standard output.
939
+ XEP-0332 is Deferred, and no WebDAV client speaks it: a reader runs
940
+ "factoidal xmpp-http-proxy --target JID/RESOURCE" (as an allowed JID) and
941
+ points the file browser at the http://127.0.0.1:PORT/ that it prints.
699
942
 
700
943
  Connect: macOS Finder, Go > Connect to Server, http://127.0.0.1:8090/ ;
701
944
  Windows, Map network drive, http://127.0.0.1:8090/ ; GNOME Files,
@@ -707,6 +950,47 @@ module built before the WebDAV operations landed makes this command exit 3
707
950
  and say so. Design: docs/designissues/2026-10-27-webdav.md
708
951
  (https://github.com/danbri/factoidal/issues/1066).`,
709
952
 
953
+ 'lws-keygen': `factoidal lws-keygen - print a new storage key for encryption at rest
954
+
955
+ usage: factoidal lws-keygen [--json]
956
+
957
+ Prints a new 32-byte storage key as 64 hex digits on standard output, and its
958
+ key id (the first 8 bytes of SHA-256 of the key) on standard error. Set the
959
+ environment variable FACTOIDAL_LWS_KEY to it before lws-serve DIR,
960
+ solid-serve DIR or webdav-serve with a local mount, on an empty storage
961
+ directory:
962
+
963
+ export FACTOIDAL_LWS_KEY="$(factoidal lws-keygen)"
964
+
965
+ Keep a copy of the key in a safe place. Without the key the storage cannot be
966
+ read: there is no recovery. Changing the key of an existing storage is not
967
+ possible (https://github.com/danbri/factoidal/issues/1136).
968
+ --json prints {"key": ..., "keyId": ...}.
969
+
970
+ The random bytes come from the host (WebCrypto getRandomValues); the engine
971
+ makes the key text and the key id (formal/lean4/L4Factoidal/LWS/DirLock.lean,
972
+ newStorageKey).`,
973
+
974
+ 'webdav-user': `factoidal webdav-user - add or replace a user in a webdav-serve users file
975
+
976
+ usage: factoidal webdav-user FILE NAME [--iterations N]
977
+
978
+ Reads the password from standard input (on a terminal it asks, and does not
979
+ show what you type; else it reads all of standard input, without one final
980
+ line end). Makes a 16-byte salt from the host's random source, derives
981
+ PBKDF2-HMAC-SHA256 with WebCrypto, and writes the line
982
+ NAME:pbkdf2-sha256:ITERATIONS:SALT_B64:HASH_B64 in FILE: in place of NAME's
983
+ line, or at the end. FILE is made when it does not exist, and is always
984
+ written with mode 0600 (a new file renamed over the old one).
985
+
986
+ --iterations N the PBKDF2 iteration count (default 600000, the OWASP
987
+ Password Storage Cheat Sheet figure of 2023 for
988
+ PBKDF2-HMAC-SHA256; at least 100000)
989
+
990
+ Every decision (the name rules, the password rules, the count, the line) is
991
+ in formal/lean4/L4Factoidal/WebDAV/Login.lean. Expert review:
992
+ https://github.com/danbri/factoidal/issues/1117 (https://github.com/danbri/factoidal/issues/1108).`,
993
+
710
994
  'solid-client': `factoidal solid-client - run one Solid client operation
711
995
 
712
996
  usage: factoidal solid-client <get|put|post|delete|discover> URL [options]
@@ -753,14 +1037,24 @@ operations:
753
1037
  list CONTAINER every page, following rel="next"
754
1038
  typeIndex URL | search URL --query JSON
755
1039
  subscribe URL --inbox URL [--topic URL]
1040
+ --endpoint URL typeIndex, search, subscribe: the service endpoint;
1041
+ the storage description is not read (for subscribe,
1042
+ URL is the default topic)
756
1043
  unsubscribe URL | revokeAccess URL
757
1044
  requestAccess URL | grantAccess URL
758
1045
  --action A[,A] --assignee URI [--target URL[,URL]]
759
- [--inbox URL]
1046
+ [--inbox URL]; or --policies JSON, an array of
1047
+ {actions, assignee, targets, constraint?}, one
1048
+ member per policy of the document
760
1049
 
761
1050
  preconditions (update, patch, delete, updateLinkset, move):
762
1051
  --if-match TAG | --if-match-current | --if-none-match-star |
763
1052
  --if-unmodified-since DATE | --no-precondition
1053
+ --if-none-match TAG[,TAG] If-None-Match with entity tags (RFC 9110
1054
+ §13.1.2), alone or with --if-match TAG,
1055
+ --if-match-current or --if-unmodified-since;
1056
+ for read, the condition of the GET (304 is
1057
+ notModified)
764
1058
  (default: --if-match-current for update, patch, updateLinkset and move)
765
1059
  --retry-412 N read the tag again and resend, at most N times
766
1060
 
@@ -770,6 +1064,14 @@ authorization:
770
1064
  urn:ietf:params:oauth:token-type:saml2
771
1065
  --access-token TOKEN a token to present to the storage of URL
772
1066
  (with --storage IRI and --root URL)
1067
+ --legacy-challenge follow a 401 with only the challenge
1068
+ Bearer as_uri="AS", realm="STORAGE" of the
1069
+ drafts before 2026-10-19 (off by default: it
1070
+ skips two discovery checks of lws10-core)
1071
+ --require-https send no request to a URL that is not https
1072
+ (the operation, discovery, metadata, the
1073
+ authorization server, the token endpoint);
1074
+ such an operation fails with insecureTransport
773
1075
 
774
1076
  Draft: https://w3c.github.io/lws-protocol/lws10-core/`
775
1077
  }
@@ -827,7 +1129,7 @@ function parseArguments (args, valueOptions) {
827
1129
  class UsageError extends Error {}
828
1130
 
829
1131
  /** Options that may be given more than once; their value is the list. */
830
- const REPEATABLE_OPTIONS = new Set(['cors-origin', 'saml-idp'])
1132
+ const REPEATABLE_OPTIONS = new Set(['cors-origin', 'saml-idp', 'saml-decrypt-key', 'allow-host', 'allow-origin'])
831
1133
 
832
1134
  const VALUE_OPTIONS = {
833
1135
  version: new Set([]),
@@ -838,22 +1140,27 @@ const VALUE_OPTIONS = {
838
1140
  activate: new Set([]),
839
1141
  update: new Set(['update', 'file']),
840
1142
  compact: new Set([]),
841
- serve: new Set(['port', 'host', 'query-path', 'gsp-path', 'file', 'layout', 'tdb2']),
1143
+ serve: new Set(['port', 'host', 'query-path', 'gsp-path', 'file', 'layout', 'tdb2', 'allow-host', 'allow-origin']),
842
1144
  'lws-serve': new Set(['port', 'host', 'base', 'issuer', 'auth-config', 'owner', 'root-acl',
843
1145
  'audience', 'page-size', 'moves', 'controller-inbox', 'delivery-limit', 'delivery-window',
844
1146
  'redirects-max', 'storage', 'cors-origin', 'authz', 'alg', 'token-lifetime', 'rotate-after',
845
- 'saml-idp']),
846
- 'solid-serve': new Set(['port', 'host', 'base', 'owner', 'issuer', 'auth-config']),
1147
+ 'saml-idp', 'saml-decrypt-key', 'allow-host', 'allow-origin']),
1148
+ 'solid-serve': new Set(['port', 'host', 'base', 'owner', 'issuer', 'auth-config', 'allow-host', 'allow-origin']),
847
1149
  'solid-client': new Set(['body', 'file', 'content-type', 'interpret']),
848
1150
  'lws-client': new Set(['body', 'file', 'content-type', 'slug', 'type', 'link', 'if-match',
849
1151
  'if-unmodified-since', 'retry-412', 'destination', 'query', 'topic', 'action', 'assignee',
850
1152
  'target', 'subject-token-file', 'subject-token-type', 'access-token', 'storage', 'root',
851
- 'inbox']),
1153
+ 'inbox', 'endpoint', 'if-none-match', 'policies']),
852
1154
  'authz-serve': new Set(['port', 'host', 'issuer', 'resource', 'alg', 'token-lifetime', 'rotate-after',
853
- 'cors-origin', 'saml-idp']),
1155
+ 'cors-origin', 'saml-idp', 'saml-decrypt-key']),
854
1156
  'authz-keys': new Set([]),
855
1157
  'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key']),
856
- 'webdav-serve': new Set(['port', 'host'])
1158
+ 'webdav-serve': new Set(['port', 'host', 'users', 'guest', 'cert', 'key', 'login-limit', 'login-window', 'xmpp', 'xmpp-password-file',
1159
+ 'xmpp-host', 'xmpp-port', 'xmpp-allow', 'xmpp-resource', 'xmpp-ca', 'allow-host', 'allow-origin']),
1160
+ 'webdav-user': new Set(['iterations']),
1161
+ 'xmpp-http-proxy': new Set(['port', 'host', 'xmpp', 'xmpp-password-file', 'target', 'xmpp-host', 'xmpp-port',
1162
+ 'xmpp-resource', 'xmpp-ca', 'timeout']),
1163
+ 'lws-keygen': new Set([])
857
1164
  }
858
1165
 
859
1166
  // ------------------------------------------------------------- commands
@@ -1765,14 +2072,35 @@ function corsConfig (options) {
1765
2072
  return {}
1766
2073
  }
1767
2074
 
2075
+ // --allow-host NAME and --allow-origin ORIGIN (repeatable), as the config
2076
+ // members `allowHosts` and `allowOrigins` of the Host and Origin guard of a
2077
+ // listener without authentication. Passed unread; the engine checks each
2078
+ // value and decides (L4Factoidal/HTTP/LocalGuard.lean,
2079
+ // https://github.com/danbri/factoidal/issues/1124).
2080
+ function localGuardConfig (options) {
2081
+ const out = {}
2082
+ if (Array.isArray(options['allow-host'])) out.allowHosts = options['allow-host']
2083
+ if (Array.isArray(options['allow-origin'])) out.allowOrigins = options['allow-origin']
2084
+ return out
2085
+ }
2086
+
2087
+ function hasLocalGuardOptions (options) {
2088
+ return options['allow-host'] !== undefined || options['allow-origin'] !== undefined
2089
+ }
2090
+
1768
2091
  // --saml-idp FILE (repeatable): a SAML 2.0 identity provider whose signed
1769
2092
  // assertions the authorization server accepts, as the config member
1770
2093
  // `samlIdps`. FILE is JSON {"entityId": ID, "jwk": RSA public JWK}; passed
1771
2094
  // unread, the engine checks it (Wasm/Ops/Authz.lean, issue 926).
2095
+ // --saml-decrypt-key FILE (repeatable): one private JWK, as the config member
2096
+ // `samlKeys`; passed unread, the engine checks it (samlKeysOf?, issue 1073).
1772
2097
  function samlConfig (options) {
2098
+ const out = {}
1773
2099
  const files = options['saml-idp']
1774
- if (!Array.isArray(files)) return {}
1775
- return { samlIdps: files.map((f) => readJsonFile(f)) }
2100
+ if (Array.isArray(files)) out.samlIdps = files.map((f) => readJsonFile(f))
2101
+ const keys = options['saml-decrypt-key']
2102
+ if (Array.isArray(keys)) out.samlKeys = keys.map((f) => readJsonFile(f))
2103
+ return out
1776
2104
  }
1777
2105
 
1778
2106
  function serveHost (options) {
@@ -1855,7 +2183,8 @@ async function commandServe (positional, options) {
1855
2183
  host: serveHost(options),
1856
2184
  ...(tdb2 ? { tdb2: true } : {}),
1857
2185
  ...(typeof options['query-path'] === 'string' ? { queryPath: options['query-path'] } : {}),
1858
- ...(typeof options['gsp-path'] === 'string' ? { gspPath: options['gsp-path'] } : {})
2186
+ ...(typeof options['gsp-path'] === 'string' ? { gspPath: options['gsp-path'] } : {}),
2187
+ ...localGuardConfig(options)
1859
2188
  }
1860
2189
  const onWorker = options['no-worker'] !== true && workerRouteAvailable()
1861
2190
  let running
@@ -1898,6 +2227,9 @@ async function commandLwsServe (positional, options) {
1898
2227
  if (insecure && (issuerOption !== null || authFile !== null || authzDir !== null)) {
1899
2228
  throw new UsageError('--insecure-no-auth cannot be combined with --authz, --issuer or --auth-config')
1900
2229
  }
2230
+ if (!insecure && hasLocalGuardOptions(options)) {
2231
+ throw new UsageError('--allow-host and --allow-origin go with --insecure-no-auth; under authorization the token decides')
2232
+ }
1901
2233
  // --authz DIR: an authorization server on the same port, its issuer the
1902
2234
  // storage root (L4Factoidal/Authz/Mount.lean). Its options are passed
1903
2235
  // unread; the engine checks them (Wasm/Ops/Authz.lean).
@@ -1941,7 +2273,7 @@ async function commandLwsServe (positional, options) {
1941
2273
  ...(options['redirects-max'] !== undefined ? { redirectsMax: natOption('redirects-max', options['redirects-max']) } : {}),
1942
2274
  ...(typeof options.storage === 'string' ? { storage: options.storage } : {}),
1943
2275
  ...corsConfig(options),
1944
- ...(insecure ? { insecureNoAuth: true } : { auth }),
2276
+ ...(insecure ? { insecureNoAuth: true, ...localGuardConfig(options) } : { auth }),
1945
2277
  ...(authz !== undefined ? { authz } : {}),
1946
2278
  ...(typeof options.owner === 'string' ? { owner: options.owner } : {}),
1947
2279
  ...(typeof options['root-acl'] === 'string'
@@ -1964,12 +2296,25 @@ async function commandLwsServe (positional, options) {
1964
2296
  }
1965
2297
  throw error
1966
2298
  }
2299
+ // The engine's start notices (a stale lock taken over; encryption at rest
2300
+ // on or off), printed also with --quiet.
2301
+ for (const n of (await running.storage()).notices || []) err(`lws-serve: ${n}`)
1967
2302
  if (!quiet) {
1968
2303
  err(`lws-serve: listening on ${running.origin} for ${positional[0]}`)
1969
2304
  if (running.authz() !== null) err(`lws-serve: authorization server ${running.authz().issuer} (keys in ${authzDir})`)
1970
2305
  if (options['allow-http'] === true) err('lws-serve: WARNING: --allow-http: http subject identifiers are accepted')
1971
2306
  if (options['allow-private-fetch'] === true) err('lws-serve: WARNING: --allow-private-fetch: private addresses may be fetched')
1972
- if (insecure) err('lws-serve: WARNING: --insecure-no-auth: every request is served without authorization')
2307
+ if (insecure) {
2308
+ err('lws-serve: WARNING: --insecure-no-auth: every request is served without authorization')
2309
+ err('lws-serve: --insecure-no-auth: only the Host names of this listener are answered, and a ' +
2310
+ 'request that changes state from another origin is refused (issue 1124; add names with ' +
2311
+ '--allow-host, origins with --allow-origin)')
2312
+ if (options['cors-origin'] === undefined) {
2313
+ err('lws-serve: --insecure-no-auth: no CORS fields are sent, because with no authorization ' +
2314
+ 'an echoed Origin would let any web page read and write this storage; give --cors-origin ' +
2315
+ 'ORIGIN for each page that may use it (https://github.com/danbri/factoidal/issues/1124)')
2316
+ }
2317
+ }
1973
2318
  }
1974
2319
  out(running.origin)
1975
2320
  return untilInterrupted(running, quiet)
@@ -2018,6 +2363,39 @@ async function commandAuthzServe (positional, options) {
2018
2363
  return untilInterrupted(running, quiet)
2019
2364
  }
2020
2365
 
2366
+ // lws-keygen (https://github.com/danbri/factoidal/issues/1135): 32 random
2367
+ // bytes from the host's random source go to the engine (`lwsStorageKeyNew`),
2368
+ // which gives the key text and its key id. The key goes to standard output
2369
+ // only; nothing is written to a file.
2370
+ async function commandLwsKeygen (positional, options) {
2371
+ if (positional.length !== 0) throw new UsageError('lws-keygen takes no argument')
2372
+ const random = new Uint8Array(32)
2373
+ globalThis.crypto.getRandomValues(random)
2374
+ const engine = await loadEngine()
2375
+ let res
2376
+ try {
2377
+ res = engine.call('lwsStorageKeyNew', [JSON.stringify({ random: Buffer.from(random).toString('base64') })])
2378
+ } catch (error) {
2379
+ const message = error && error.message ? String(error.message) : String(error)
2380
+ if (message.indexOf('unknown op') >= 0) return notWired('lws-keygen', message)
2381
+ throw error
2382
+ }
2383
+ if (res.ok === false) {
2384
+ err(`factoidal lws-keygen: ${res.error}`)
2385
+ return EXIT_FAILURE
2386
+ }
2387
+ if (options.json === true) {
2388
+ out(JSON.stringify({ key: res.key, keyId: res.keyId }))
2389
+ } else {
2390
+ out(res.key)
2391
+ if (options.quiet !== true) {
2392
+ err(`lws-keygen: key id ${res.keyId}. Keep a copy of the key in a safe place: without it the storage cannot be read.`)
2393
+ err('lws-keygen: start the server with FACTOIDAL_LWS_KEY set to it, on an empty storage directory.')
2394
+ }
2395
+ }
2396
+ return EXIT_OK
2397
+ }
2398
+
2021
2399
  async function commandAuthzKeys (positional, options) {
2022
2400
  if (positional.length !== 1) throw new UsageError('authz-keys needs one DIR')
2023
2401
  const { openAuthorizationServer, readKeyFile } = await import('../authz/server.mjs')
@@ -2064,6 +2442,9 @@ async function commandSolidServe (positional, options) {
2064
2442
  if (insecure && (issuerOption !== null || authFile !== null)) {
2065
2443
  throw new UsageError('--insecure-no-auth cannot be combined with --issuer or --auth-config')
2066
2444
  }
2445
+ if (!insecure && hasLocalGuardOptions(options)) {
2446
+ throw new UsageError('--allow-host and --allow-origin go with --insecure-no-auth; under authentication the token decides')
2447
+ }
2067
2448
  let auth
2068
2449
  if (authFile !== null) {
2069
2450
  auth = readJsonFile(authFile)
@@ -2077,7 +2458,7 @@ async function commandSolidServe (positional, options) {
2077
2458
  port: servePort(options, 3000),
2078
2459
  host: serveHost(options),
2079
2460
  ...(typeof options.base === 'string' ? { baseIri: options.base } : {}),
2080
- ...(insecure ? { insecureNoAuth: true } : { auth }),
2461
+ ...(insecure ? { insecureNoAuth: true, ...localGuardConfig(options) } : { auth }),
2081
2462
  ...(typeof options.owner === 'string' ? { owner: options.owner } : {}),
2082
2463
  realClock: true,
2083
2464
  dir: positional[0]
@@ -2096,9 +2477,19 @@ async function commandSolidServe (positional, options) {
2096
2477
  }
2097
2478
  throw error
2098
2479
  }
2480
+ // The engine's start notices (a stale lock taken over; encryption at rest
2481
+ // on or off), printed also with --quiet.
2482
+ for (const n of (await running.storage()).notices || []) err(`solid-serve: ${n}`)
2099
2483
  if (!quiet) {
2100
2484
  err(`solid-serve: listening on ${running.origin} for ${positional[0]}`)
2101
- if (insecure) err('solid-serve: WARNING: --insecure-no-auth: every request is served without authentication')
2485
+ if (insecure) {
2486
+ err('solid-serve: WARNING: --insecure-no-auth: every request is served without authentication')
2487
+ err('solid-serve: --insecure-no-auth: only the Host names of this listener are answered, and a ' +
2488
+ 'request that changes state from another origin is refused (issue 1124; add names with ' +
2489
+ '--allow-host, origins with --allow-origin). CORS still echoes the request Origin (Solid ' +
2490
+ 'Protocol section 8.1), so a page on another origin can read this storage ' +
2491
+ '(https://github.com/danbri/factoidal/issues/1124)')
2492
+ }
2102
2493
  }
2103
2494
  out(running.origin)
2104
2495
  return untilInterrupted(running, quiet)
@@ -2110,10 +2501,41 @@ async function commandSolidServe (positional, options) {
2110
2501
  async function commandWebdavServe (positional, options) {
2111
2502
  if (positional.length !== 1) throw new UsageError('webdav-serve needs one CONFIGDIR')
2112
2503
  const quiet = options.quiet === true
2504
+ if (options.xmpp !== undefined) return webdavOverXmpp(positional[0], options, quiet)
2113
2505
  const { listen, WebdavHostError } = await import('../webdav/server.mjs')
2506
+ const fs = await import('node:fs/promises')
2507
+ const { resolve: resolvePath } = await import('node:path')
2508
+ const useTls = options.cert !== undefined || options.key !== undefined
2509
+ if (useTls && (typeof options.cert !== 'string' || typeof options.key !== 'string')) {
2510
+ throw new UsageError('--cert and --key go together')
2511
+ }
2512
+ const settings = { configDir: positional[0], port: servePort(options, 8090), host: serveHost(options) }
2513
+ // The users file is named relative to the working directory here, and
2514
+ // given to the engine as an absolute path.
2515
+ if (typeof options.users === 'string') settings.users = resolvePath(options.users)
2516
+ // The guest user (https://github.com/danbri/factoidal/issues/1147): the
2517
+ // name goes to the engine unread; it checks it.
2518
+ if (options.guest !== undefined) {
2519
+ if (typeof options.guest !== 'string') throw new UsageError('--guest needs a user name')
2520
+ settings.guest = options.guest
2521
+ }
2522
+ // The Host and Origin guard (https://github.com/danbri/factoidal/issues/1124):
2523
+ // the values go to the engine unread; it checks them.
2524
+ Object.assign(settings, localGuardConfig(options))
2525
+ // The failed-login limit (https://github.com/danbri/factoidal/issues/1119):
2526
+ // the values go to the engine unread; it checks them.
2527
+ if (options['login-limit'] !== undefined) settings.loginLimit = String(options['login-limit'])
2528
+ if (options['login-window'] !== undefined) settings.loginWindow = String(options['login-window'])
2529
+ if (options['dangerously-disable-ratelimits'] !== undefined) {
2530
+ settings.disableRateLimits = options['dangerously-disable-ratelimits'] === true
2531
+ }
2114
2532
  let running
2115
2533
  try {
2116
- running = await listen({ configDir: positional[0], port: servePort(options, 8090), host: serveHost(options) })
2534
+ if (useTls) {
2535
+ settings.cert = await fs.readFile(options.cert)
2536
+ settings.key = await fs.readFile(options.key)
2537
+ }
2538
+ running = await listen(settings)
2117
2539
  } catch (error) {
2118
2540
  if (error instanceof WebdavHostError && error.unknownOp) return notWired('webdav-serve', error.message)
2119
2541
  if (error instanceof WebdavHostError || (error !== null && typeof error === 'object' && error.code === 'ENOENT')) {
@@ -2122,16 +2544,254 @@ async function commandWebdavServe (positional, options) {
2122
2544
  }
2123
2545
  throw error
2124
2546
  }
2547
+ // The engine's start warnings (--dangerously-disable-ratelimits), printed
2548
+ // also with --quiet.
2549
+ for (const w of running.warnings || []) err(`factoidal webdav-serve: WARNING: ${w}`)
2550
+ // The start notices of the local mounts (a stale lock taken over;
2551
+ // encryption at rest on or off), printed also with --quiet.
2552
+ for (const n of running.storageNotices || []) err(`webdav-serve: ${n}`)
2125
2553
  if (!quiet) {
2126
- err(`webdav-serve: listening on ${running.origin} for ${positional[0]}`)
2554
+ err(`webdav-serve: listening on ${running.origin} for ${positional[0]}` +
2555
+ (settings.users !== undefined ? `, login from ${options.users}` : ', no --users: credentials get 401') +
2556
+ (settings.guest !== undefined ? `, guest ${settings.guest}` : ', no --guest') +
2557
+ (useTls ? ', TLS' : ''))
2127
2558
  for (const m of running.mounts) {
2128
- err(`webdav-serve: ${running.origin}/${encodeURIComponent(m.name)}/ - ${m.title} (${m.kind}${m.writable ? '' : ', read-only'}${m.agent ? ', as ' + m.agent : ''})`)
2559
+ err(`webdav-serve: ${running.origin}/${encodeURIComponent(m.name)}/ - ${m.title} (${m.kind}${m.writable ? '' : ', read-only'}${m.agent ? ', as ' + m.agent : ''}${Array.isArray(m.users) ? ', users: ' + m.users.join(', ') : ''})`)
2129
2560
  }
2130
2561
  }
2131
2562
  out(running.origin)
2132
2563
  return untilInterrupted(running, quiet)
2133
2564
  }
2134
2565
 
2566
+ // webdav-serve CONFIGDIR --xmpp JID (https://github.com/danbri/factoidal/issues/1107,
2567
+ // step 1): the WebDAV handle of the HTTP listener, reached through an
2568
+ // outgoing XMPP client connection (XEP-0332). No port is opened. The host
2569
+ // moves bytes; the stream, SASL, binding, access and every response stanza
2570
+ // are decided in L4Factoidal/XMPP/ (ClientStream, HttpOverXmpp).
2571
+ async function webdavOverXmpp (configDir, options, quiet) {
2572
+ if (typeof options.xmpp !== 'string') throw new UsageError('--xmpp needs a JID')
2573
+ if (typeof options['xmpp-password-file'] !== 'string') throw new UsageError('--xmpp needs --xmpp-password-file FILE')
2574
+ if (typeof options['xmpp-allow'] !== 'string') throw new UsageError('--xmpp needs --xmpp-allow JID[,JID...]')
2575
+ // No login on the XMPP path yet: refuse --users rather than ignore it
2576
+ // (https://github.com/danbri/factoidal/issues/1125).
2577
+ if (['users', 'cert', 'key', 'login-limit', 'login-window', 'dangerously-disable-ratelimits'].some((k) => options[k] !== undefined)) {
2578
+ throw new UsageError('--users, --cert, --key and the login-limit options do not apply with --xmpp yet (https://github.com/danbri/factoidal/issues/1125)')
2579
+ }
2580
+ const fs = await import('node:fs/promises')
2581
+ const password = (await fs.readFile(options['xmpp-password-file'], 'utf8')).split(/\r?\n/)[0]
2582
+ const allow = options['xmpp-allow'].split(',').map((x) => x.trim()).filter((x) => x.length > 0)
2583
+ const { openWebdav, WebdavHostError } = await import('../webdav/server.mjs')
2584
+ const { connectHttpTarget, XmppClientError } = await import('../xmpp/client.mjs')
2585
+ if (options.guest !== undefined && typeof options.guest !== 'string') throw new UsageError('--guest needs a user name')
2586
+ let dav
2587
+ try {
2588
+ dav = await openWebdav({ configDir, ...(typeof options.guest === 'string' ? { guest: options.guest } : {}) })
2589
+ } catch (error) {
2590
+ if (error instanceof WebdavHostError && error.unknownOp) return notWired('webdav-serve', error.message)
2591
+ if (error instanceof WebdavHostError || (error !== null && typeof error === 'object' && error.code === 'ENOENT')) {
2592
+ err(`factoidal webdav-serve: ${error.message}`)
2593
+ return EXIT_FAILURE
2594
+ }
2595
+ throw error
2596
+ }
2597
+ for (const w of dav.warnings || []) err(`factoidal webdav-serve: WARNING: ${w}`)
2598
+ for (const n of dav.storageNotices || []) err(`webdav-serve: ${n}`)
2599
+ const settings = {
2600
+ engine: dav.engine,
2601
+ jid: options.xmpp,
2602
+ password,
2603
+ allow,
2604
+ resource: typeof options['xmpp-resource'] === 'string' ? options['xmpp-resource'] : 'dav',
2605
+ tls: options['xmpp-tls'] === true,
2606
+ mounts: dav.mounts,
2607
+ dangerouslyAllowWritableWithoutLogin: options['dangerously-allow-writable-without-login'] === true,
2608
+ step: (request) => dav.step(request),
2609
+ onError: (error) => { if (!quiet) err(`webdav-serve: xmpp: ${error.message}`) }
2610
+ }
2611
+ if (typeof options['xmpp-host'] === 'string') settings.host = options['xmpp-host']
2612
+ if (typeof options['xmpp-ca'] === 'string') settings.tlsOptions = { ca: await fs.readFile(options['xmpp-ca']) }
2613
+ if (options['xmpp-port'] !== undefined) {
2614
+ const port = Number(options['xmpp-port'])
2615
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new UsageError('--xmpp-port needs a port number')
2616
+ settings.port = port
2617
+ }
2618
+ let conn
2619
+ try {
2620
+ conn = await connectHttpTarget(settings)
2621
+ } catch (error) {
2622
+ dav.close()
2623
+ if (error instanceof XmppClientError && error.unknownOp) return notWired('webdav-serve', error.message)
2624
+ if (error instanceof XmppClientError || (error !== null && typeof error === 'object' && typeof error.code === 'string')) {
2625
+ err(`factoidal webdav-serve: xmpp: ${error.message}`)
2626
+ return EXIT_FAILURE
2627
+ }
2628
+ throw error
2629
+ }
2630
+ if (!quiet) {
2631
+ err(`webdav-serve: connected to XMPP as ${conn.jid}; no port is open; requests from ${allow.join(', ')}`)
2632
+ if (conn.writableWithoutLogin.length > 0) {
2633
+ err(`webdav-serve: WARNING: --dangerously-allow-writable-without-login: every allowed JID can write ${conn.writableWithoutLogin.join(', ')} as the storage owner, with no login`)
2634
+ }
2635
+ for (const m of dav.mounts) {
2636
+ err(`webdav-serve: /${encodeURIComponent(m.name)}/ - ${m.title} (${m.kind}${m.writable ? '' : ', read-only'}${m.agent ? ', as ' + m.agent : ''})`)
2637
+ }
2638
+ }
2639
+ out(`xmpp:${conn.jid}`)
2640
+ const running = { close: async () => { await conn.close(); dav.close() } }
2641
+ const ended = conn.closed.then((reason) => {
2642
+ if (!quiet && reason) err(`webdav-serve: xmpp: connection closed: ${reason}`)
2643
+ dav.close()
2644
+ return EXIT_FAILURE
2645
+ })
2646
+ return Promise.race([untilInterrupted(running, quiet), ended])
2647
+ }
2648
+
2649
+ // xmpp-http-proxy (https://github.com/danbri/factoidal/issues/1107, step 2):
2650
+ // a loopback HTTP listener whose requests go over XMPP (XEP-0332) to one
2651
+ // target JID. The host moves bytes; the target, the listener address, the
2652
+ // headers forwarded, the limits, timeouts and every status are decided in
2653
+ // L4Factoidal/XMPP/HttpRequester.lean and Wasm/Ops/XmppClient.lean.
2654
+ async function commandXmppHttpProxy (positional, options) {
2655
+ if (positional.length !== 0) throw new UsageError('xmpp-http-proxy takes no positional argument')
2656
+ const quiet = options.quiet === true
2657
+ if (typeof options.xmpp !== 'string') throw new UsageError('xmpp-http-proxy needs --xmpp JID')
2658
+ if (typeof options['xmpp-password-file'] !== 'string') throw new UsageError('xmpp-http-proxy needs --xmpp-password-file FILE')
2659
+ if (typeof options.target !== 'string') throw new UsageError('xmpp-http-proxy needs --target JID/RESOURCE')
2660
+ const fs = await import('node:fs/promises')
2661
+ const password = (await fs.readFile(options['xmpp-password-file'], 'utf8')).split(/\r?\n/)[0]
2662
+ const { startHttpProxy } = await import('../xmpp/proxy.mjs')
2663
+ const { XmppClientError } = await import('../xmpp/client.mjs')
2664
+ const settings = {
2665
+ jid: options.xmpp,
2666
+ password,
2667
+ target: options.target,
2668
+ resource: typeof options['xmpp-resource'] === 'string' ? options['xmpp-resource'] : 'proxy',
2669
+ tls: options['xmpp-tls'] === true,
2670
+ // The address goes to the engine unread; it accepts 127.0.0.1 only.
2671
+ listenHost: typeof options.host === 'string' ? options.host : '127.0.0.1',
2672
+ listenPort: servePort(options, 8091),
2673
+ onError: (error) => { if (!quiet) err(`xmpp-http-proxy: ${error.message}`) }
2674
+ }
2675
+ if (options.timeout !== undefined) {
2676
+ const t = Number(options.timeout)
2677
+ if (!Number.isInteger(t) || t < 0) throw new UsageError('--timeout needs a whole number of seconds')
2678
+ settings.timeoutSeconds = t
2679
+ }
2680
+ if (typeof options['xmpp-host'] === 'string') settings.host = options['xmpp-host']
2681
+ if (typeof options['xmpp-ca'] === 'string') settings.tlsOptions = { ca: await fs.readFile(options['xmpp-ca']) }
2682
+ if (options['xmpp-port'] !== undefined) {
2683
+ const port = Number(options['xmpp-port'])
2684
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new UsageError('--xmpp-port needs a port number')
2685
+ settings.port = port
2686
+ }
2687
+ let running
2688
+ try {
2689
+ running = await startHttpProxy(settings)
2690
+ } catch (error) {
2691
+ if (error instanceof XmppClientError && error.unknownOp) return notWired('xmpp-http-proxy', error.message)
2692
+ if (error instanceof XmppClientError || (error !== null && typeof error === 'object' && typeof error.code === 'string')) {
2693
+ err(`factoidal xmpp-http-proxy: ${error.message}`)
2694
+ return EXIT_FAILURE
2695
+ }
2696
+ throw error
2697
+ }
2698
+ if (!quiet) {
2699
+ err(`xmpp-http-proxy: connected to XMPP as ${running.jid}; requests to ${running.origin} go to ${running.target} only`)
2700
+ err('xmpp-http-proxy: the listener has no login: every local process (and any web page in a local browser) acts as ' +
2701
+ `${running.jid} (https://github.com/danbri/factoidal/issues/1124)`)
2702
+ }
2703
+ out(running.origin)
2704
+ const ended = running.closed.then((reason) => {
2705
+ if (!quiet && reason) err(`xmpp-http-proxy: xmpp: connection closed: ${reason}`)
2706
+ return EXIT_FAILURE
2707
+ })
2708
+ return Promise.race([untilInterrupted(running, quiet), ended])
2709
+ }
2710
+
2711
+ /** A password from standard input: on a terminal, asked for with no echo;
2712
+ * else all of standard input. The bytes go to the engine unread. Pure I/O. */
2713
+ async function readPasswordInput () {
2714
+ const stdin = process.stdin
2715
+ if (!stdin.isTTY) {
2716
+ const chunks = []
2717
+ for await (const chunk of stdin) chunks.push(chunk)
2718
+ return new Uint8Array(Buffer.concat(chunks))
2719
+ }
2720
+ process.stderr.write('Password: ')
2721
+ stdin.setRawMode(true)
2722
+ stdin.resume()
2723
+ const typed = []
2724
+ try {
2725
+ for await (const chunk of stdin) {
2726
+ for (const b of chunk) {
2727
+ if (b === 0x0d || b === 0x0a || b === 0x04) return new Uint8Array(typed)
2728
+ if (b === 0x03) throw new UsageError('interrupted')
2729
+ if (b === 0x7f || b === 0x08) typed.pop()
2730
+ else typed.push(b)
2731
+ }
2732
+ }
2733
+ return new Uint8Array(typed)
2734
+ } finally {
2735
+ stdin.setRawMode(false)
2736
+ stdin.pause()
2737
+ err('')
2738
+ }
2739
+ }
2740
+
2741
+ // webdav-user FILE NAME (https://github.com/danbri/factoidal/issues/1108):
2742
+ // the engine checks the name, the password and the file, names the PBKDF2
2743
+ // derivation, and gives the new text; this host reads the password, runs
2744
+ // the derivation with WebCrypto (lib/cipher-host.mjs) and writes the file.
2745
+ async function commandWebdavUser (positional, options) {
2746
+ if (positional.length !== 2) throw new UsageError('webdav-user needs FILE and NAME')
2747
+ const [file, name] = positional
2748
+ const fs = await import('node:fs')
2749
+ const { dirname, basename, join } = await import('node:path')
2750
+ const { runWithCipher } = await import('../lib/cipher-host.mjs')
2751
+ let text = null
2752
+ try {
2753
+ text = fs.readFileSync(file, 'utf8')
2754
+ } catch (error) {
2755
+ if (error === null || typeof error !== 'object' || error.code !== 'ENOENT') throw error
2756
+ }
2757
+ const input = await readPasswordInput()
2758
+ const salt = new Uint8Array(16)
2759
+ globalThis.crypto.getRandomValues(salt)
2760
+ const engine = await loadEngine()
2761
+ const args = {
2762
+ name,
2763
+ file,
2764
+ text,
2765
+ passwordBase64: Buffer.from(input).toString('base64'),
2766
+ saltBase64: Buffer.from(salt).toString('base64')
2767
+ }
2768
+ if (options.iterations !== undefined) args.iterations = String(options.iterations)
2769
+ let res
2770
+ try {
2771
+ res = await runWithCipher((a) => engine.call('webdavUser', [JSON.stringify(a)]), args)
2772
+ } catch (error) {
2773
+ const message = error && error.message ? String(error.message) : String(error)
2774
+ if (message.indexOf('unknown op') >= 0) return notWired('webdav-user', message)
2775
+ throw error
2776
+ }
2777
+ if (res.ok === false) {
2778
+ err(`factoidal webdav-user: ${res.error}`)
2779
+ return EXIT_FAILURE
2780
+ }
2781
+ // A new file in the same directory, mode 0600, flushed, renamed over FILE.
2782
+ const temp = join(dirname(file), `.${basename(file)}.${process.pid}.tmp`)
2783
+ const fd = fs.openSync(temp, 'wx', 0o600)
2784
+ try {
2785
+ fs.writeSync(fd, res.text)
2786
+ fs.fsyncSync(fd)
2787
+ } finally {
2788
+ fs.closeSync(fd)
2789
+ }
2790
+ fs.renameSync(temp, file)
2791
+ err(`webdav-user: ${res.replaced ? 'replaced' : 'added'} ${name} in ${file}`)
2792
+ return EXIT_OK
2793
+ }
2794
+
2135
2795
  async function commandXmppServe (positional, options) {
2136
2796
  if (positional.length !== 1) throw new UsageError('xmpp-serve needs one DIR')
2137
2797
  const quiet = options.quiet === true
@@ -2256,6 +2916,11 @@ async function commandLwsClient (positional, options) {
2256
2916
  op.precondition = 'ifUnmodifiedSince'; op.tag = options['if-unmodified-since']
2257
2917
  }
2258
2918
  if (options['no-precondition'] === true) op.precondition = 'none'
2919
+ if (typeof options['if-none-match'] === 'string') {
2920
+ // The value goes to the engine whole; it reads the entity-tag list.
2921
+ if (kind === 'read') op.ifNoneMatch = options['if-none-match']
2922
+ else op.ifNoneMatchTags = [options['if-none-match']]
2923
+ }
2259
2924
  if (typeof options['retry-412'] === 'string') op.retry412 = Number(options['retry-412'])
2260
2925
  if (typeof options.destination === 'string') op.destination = options.destination
2261
2926
  if (typeof options.query === 'string') op.query = JSON.parse(options.query)
@@ -2264,7 +2929,11 @@ async function commandLwsClient (positional, options) {
2264
2929
  if (typeof options.action === 'string') op.actions = list(options.action)
2265
2930
  if (typeof options.assignee === 'string') op.assignee = options.assignee
2266
2931
  if (typeof options.target === 'string') op.targets = list(options.target)
2932
+ if (typeof options.policies === 'string') op.policies = JSON.parse(options.policies)
2933
+ if (typeof options.endpoint === 'string') op.endpoint = options.endpoint
2267
2934
  const clientOptions = {}
2935
+ if (options['legacy-challenge'] === true) clientOptions.legacyChallenge = true
2936
+ if (options['require-https'] === true) clientOptions.requireHttps = true
2268
2937
  if (typeof options['subject-token-file'] === 'string') {
2269
2938
  clientOptions.subjectToken = new TextDecoder('utf-8', { fatal: true })
2270
2939
  .decode(readWhole(options['subject-token-file'])).trim()
@@ -2313,7 +2982,10 @@ const COMMANDS = {
2313
2982
  'authz-serve': commandAuthzServe,
2314
2983
  'authz-keys': commandAuthzKeys,
2315
2984
  'xmpp-serve': commandXmppServe,
2316
- 'webdav-serve': commandWebdavServe
2985
+ 'webdav-serve': commandWebdavServe,
2986
+ 'webdav-user': commandWebdavUser,
2987
+ 'xmpp-http-proxy': commandXmppHttpProxy,
2988
+ 'lws-keygen': commandLwsKeygen
2317
2989
  }
2318
2990
 
2319
2991
  // ----------------------------------------------------------------- main