@factoidal/core 0.11.0 → 0.13.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 +565 -0
- package/README.md +76 -4
- package/api.d.ts +2 -3
- package/api.mjs +2 -2
- package/authz/server.mjs +331 -0
- package/bin/factoidal.mjs +510 -8
- package/browser.d.ts +3 -2
- package/browser.js +1 -2
- package/fn.js +8 -12
- package/index.d.ts +100 -35
- package/index.mjs +1 -2
- 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 +96 -205
- package/lib/dataset.mjs +40 -45
- package/lib/delta-log-hosts.mjs +9 -8
- package/lib/errors.mjs +97 -0
- package/lib/lean-entry.mjs +15 -10
- package/lib/store.mjs +4 -5
- package/lib/version.mjs +1 -1
- package/lws/README.md +70 -3
- package/lws/client.mjs +319 -0
- package/lws/persist.mjs +253 -6
- package/lws/server.mjs +294 -10
- package/package.json +10 -1
- package/rdfjs.mjs +4 -2
- package/solid/server/index.mjs +7 -1
- package/xmpp/README.md +82 -4
- package/xmpp/server.mjs +549 -0
package/bin/factoidal.mjs
CHANGED
|
@@ -101,6 +101,11 @@ commands:
|
|
|
101
101
|
lws-serve DIR serve Linked Web Storage 1.0 over HTTP
|
|
102
102
|
solid-serve DIR serve the Solid Protocol over HTTP
|
|
103
103
|
solid-client OP URL run one Solid client operation
|
|
104
|
+
lws-client OP URL run one Linked Web Storage client operation
|
|
105
|
+
authz-serve DIR serve an OAuth 2.0 authorization server for LWS
|
|
106
|
+
(RFC 8414 metadata, JWK Set, RFC 8693 token exchange)
|
|
107
|
+
authz-keys DIR print the public JWK Set of an authorization server
|
|
108
|
+
xmpp-serve DIR serve XMPP (RFC 6120/6121) over TCP or direct TLS
|
|
104
109
|
|
|
105
110
|
global options:
|
|
106
111
|
-h, --help print this text, or a command's own help
|
|
@@ -328,11 +333,61 @@ Specifications: https://www.w3.org/TR/sparql11-protocol/
|
|
|
328
333
|
https://www.w3.org/TR/sparql11-http-rdf-update/
|
|
329
334
|
https://www.w3.org/TR/sparql11-service-description/`,
|
|
330
335
|
|
|
336
|
+
'authz-serve': `factoidal authz-serve - serve an OAuth 2.0 authorization server
|
|
337
|
+
|
|
338
|
+
usage: factoidal authz-serve DIR --resource IRI[,IRI...] [--issuer IRI]
|
|
339
|
+
[--port N] [--host ADDRESS] [--alg ES256|EdDSA]
|
|
340
|
+
[--token-lifetime SECONDS] [--rotate-after SECONDS]
|
|
341
|
+
[--allow-http] [--allow-private-fetch]
|
|
342
|
+
[--cors-origin ORIGIN]... [--no-cors]
|
|
343
|
+
|
|
344
|
+
An authorization server as a component (https://github.com/danbri/factoidal/issues/916),
|
|
345
|
+
for lws-serve and any resource server that reads RFC 8414 metadata:
|
|
346
|
+
GET /.well-known/lws-configuration (also /.well-known/oauth-authorization-server)
|
|
347
|
+
GET /jwks the JWK Set (public keys only)
|
|
348
|
+
POST /token RFC 8693 token exchange for an RFC 9068
|
|
349
|
+
access token whose aud is the resource
|
|
350
|
+
Subject tokens: OpenID Connect ID tokens (lws10-authn-openid) and
|
|
351
|
+
self-issued JWTs for https and did:key subjects (lws10-authn-ssi-cid). SAML
|
|
352
|
+
2.0 is not accepted (https://github.com/danbri/factoidal/issues/926).
|
|
353
|
+
|
|
354
|
+
--resource names the resource IRIs (for lws-serve: BASE.well-known/lws-storage)
|
|
355
|
+
tokens may be issued for; any other is refused with invalid_target.
|
|
356
|
+
--issuer defaults to the bound origin. Give lws-serve --issuer the same IRI.
|
|
357
|
+
DIR holds the signing keys (DIR/authz-keys.json, mode 0600), sealed with
|
|
358
|
+
FACTOIDAL_LWS_KEY when it is set. --rotate-after makes a new key when the
|
|
359
|
+
current one is that old; the old key stays published until its tokens expire.
|
|
360
|
+
--allow-http accepts http subject identifiers; --allow-private-fetch lets the
|
|
361
|
+
server fetch documents from loopback, link-local and private addresses. Both
|
|
362
|
+
are off by default and are for tests.
|
|
363
|
+
CORS (https://github.com/danbri/factoidal/issues/923): by default every answer
|
|
364
|
+
echoes a valid request Origin in Access-Control-Allow-Origin, so a page on
|
|
365
|
+
another origin can read the metadata and keys and exchange a token; a
|
|
366
|
+
preflight is answered 204 first. --cors-origin ORIGIN (repeatable) answers
|
|
367
|
+
only those origins; --no-cors sends no CORS fields.
|
|
368
|
+
|
|
369
|
+
Every decision is made in formal/lean4/L4Factoidal/Authz/; this command only
|
|
370
|
+
carries sockets, the clock, random bytes, DNS answers and files.`,
|
|
371
|
+
|
|
372
|
+
'authz-keys': `factoidal authz-keys - print the JWK Set of an authorization server
|
|
373
|
+
|
|
374
|
+
usage: factoidal authz-keys DIR [--private]
|
|
375
|
+
|
|
376
|
+
Prints the published JWK Set of the key file in DIR. --private prints the
|
|
377
|
+
private keys (with d): for a conformance harness that mints test tokens
|
|
378
|
+
(tools/lws-touchstone.sh --authz factoidal). Never publish that output.`,
|
|
379
|
+
|
|
331
380
|
'lws-serve': `factoidal lws-serve - serve Linked Web Storage 1.0 over HTTP
|
|
332
381
|
|
|
333
382
|
usage: factoidal lws-serve DIR (--issuer IRI | --auth-config FILE | --insecure-no-auth)
|
|
334
383
|
[--owner WEBID] [--root-acl FILE] [--audience IRI]
|
|
335
|
-
[--port N] [--host ADDRESS] [--base IRI]
|
|
384
|
+
[--port N] [--host ADDRESS] [--base IRI] [--page-size N]
|
|
385
|
+
[--paginate-always] [--moves all|data|off]
|
|
386
|
+
[--no-linkset-put] [--unstable-search-results] [--merkle]
|
|
387
|
+
[--controller-inbox URL] [--delivery-limit N]
|
|
388
|
+
[--delivery-window SECONDS] [--redirects-max N]
|
|
389
|
+
[--storage snapshot|files]
|
|
390
|
+
[--cors-origin ORIGIN]... [--no-cors]
|
|
336
391
|
|
|
337
392
|
Starts a Node HTTP server that answers every request through the engine's
|
|
338
393
|
lwsStep operation.
|
|
@@ -344,10 +399,100 @@ start. The engine decides the snapshot format and refuses a snapshot
|
|
|
344
399
|
path with a dot segment or other traversal form
|
|
345
400
|
(formal/lean4/L4Factoidal/LWS/Snapshot.lean).
|
|
346
401
|
|
|
402
|
+
With --storage files (off by default) a write costs the same whatever the
|
|
403
|
+
size of the storage: DIR holds one file per resource body (DIR/bodies/),
|
|
404
|
+
a journal of metadata changes (DIR/lws-journal) and a metadata file
|
|
405
|
+
(DIR/lws-files.json) that the journal is folded into from time to time.
|
|
406
|
+
A journal record is valid only when it is complete and its SHA-256
|
|
407
|
+
matches; a torn last record is ignored at start. An existing
|
|
408
|
+
DIR/lws-state.json is migrated at the first start and renamed to
|
|
409
|
+
lws-state.json.migrated. The engine decides every file name and every
|
|
410
|
+
byte (formal/lean4/L4Factoidal/LWS/FileLayout.lean); the storage is still
|
|
411
|
+
held in memory while the server runs
|
|
412
|
+
(docs/designissues/2026-10-22-lws-storage-on-disk.md).
|
|
413
|
+
|
|
414
|
+
Encryption at rest (off by default): when the environment variable
|
|
415
|
+
FACTOIDAL_LWS_KEY holds a 32-byte key (64 hex digits, or 43 characters of
|
|
416
|
+
base64url), the snapshot is sealed with ChaCha20-Poly1305 (HACL*) under a
|
|
417
|
+
new random nonce for every write, and authenticated when the storage is
|
|
418
|
+
opened. A wrong key, a changed or truncated file, a clear-text file with the
|
|
419
|
+
variable set, or a sealed file without it stops the start with a message.
|
|
420
|
+
It protects the file on disk only (formal/lean4/L4Factoidal/LWS/SnapshotSeal.lean).
|
|
421
|
+
|
|
347
422
|
--port N the port to bind; 0 takes an ephemeral one (default 3000)
|
|
348
423
|
--host ADDRESS the address to bind (default 127.0.0.1)
|
|
349
424
|
--base IRI the absolute IRI the storage root maps to
|
|
350
425
|
(default: the bound origin)
|
|
426
|
+
--page-size N the engine's container page size (config member pageSize;
|
|
427
|
+
default 100, 0 turns pagination off)
|
|
428
|
+
--paginate-always
|
|
429
|
+
present every container listing as paginated, also one
|
|
430
|
+
that fits on one page (config member paginateAlways;
|
|
431
|
+
default off): rel="first" and rel="last", no rel="next"
|
|
432
|
+
--moves all|data|off
|
|
433
|
+
which resources a client may move by changing their up
|
|
434
|
+
link (config member moves; default all): data resources
|
|
435
|
+
and containers, data resources only (the MoveResource
|
|
436
|
+
capability's appliesTo is [DataResource]), or none (no
|
|
437
|
+
MoveResource capability; a change of up answers 409)
|
|
438
|
+
--no-linkset-put
|
|
439
|
+
a linkset resource does not accept PUT (config member
|
|
440
|
+
linksetPut false): its Allow leaves PUT out and a PUT
|
|
441
|
+
answers 405 Method Not Allowed; PATCH still applies
|
|
442
|
+
--unstable-search-results
|
|
443
|
+
ANTICIPATORY AND UNSTABLE, off by default: a QUERY answer
|
|
444
|
+
of the type search names a results resource with
|
|
445
|
+
Content-Location (RFC 10008 section 2.3). lws10-index
|
|
446
|
+
defines no such resource; this is likely to change.
|
|
447
|
+
https://github.com/danbri/factoidal/issues/922
|
|
448
|
+
--merkle off by default: keep a SHA-256 digest of every resource
|
|
449
|
+
and a Merkle root of every container, and show them: an
|
|
450
|
+
RFC 9530 Repr-Digest field on GET of a resource, a
|
|
451
|
+
Factoidal-Merkle-Root field and listing member on a
|
|
452
|
+
container, and the storage root's digest in the storage
|
|
453
|
+
description (roots to the storage controller only under
|
|
454
|
+
authorization). Entity tags do not change. The hashes are
|
|
455
|
+
rebuilt when the storage is opened, not stored.
|
|
456
|
+
https://github.com/danbri/factoidal/issues/919
|
|
457
|
+
--controller-inbox URL
|
|
458
|
+
the storage controller's inbox (config member
|
|
459
|
+
controllerInbox; default none): each new access request
|
|
460
|
+
is announced there with a Create notification. The LWS
|
|
461
|
+
draft names no such inbox; this is a server choice.
|
|
462
|
+
https://github.com/danbri/factoidal/issues/920
|
|
463
|
+
--delivery-limit N
|
|
464
|
+
the most webhook deliveries one subscription gets in one
|
|
465
|
+
window (config member deliveryLimit; default 10, 0 turns
|
|
466
|
+
the limit off); the changes over it wait and go in one
|
|
467
|
+
batched notification when the window ends
|
|
468
|
+
https://github.com/danbri/factoidal/issues/915
|
|
469
|
+
--delivery-window SECONDS
|
|
470
|
+
the length of that window (config member deliveryWindow;
|
|
471
|
+
default 60)
|
|
472
|
+
--redirects-max N
|
|
473
|
+
the most previous identifiers of moved resources kept to
|
|
474
|
+
answer 301 Moved Permanently (config member redirectsMax;
|
|
475
|
+
default 256)
|
|
476
|
+
https://github.com/danbri/factoidal/issues/914
|
|
477
|
+
--storage snapshot|files
|
|
478
|
+
how DIR keeps the storage (config member storage;
|
|
479
|
+
default the environment variable FACTOIDAL_LWS_STORAGE,
|
|
480
|
+
else snapshot): one snapshot file written whole after
|
|
481
|
+
each change, or one file per body with a journal, so a
|
|
482
|
+
write costs the same whatever the storage size
|
|
483
|
+
https://github.com/danbri/factoidal/issues/918
|
|
484
|
+
--cors-origin ORIGIN
|
|
485
|
+
answer CORS (Fetch Standard) only for this origin, written
|
|
486
|
+
scheme://host[:port] (for example https://danbri.github.io);
|
|
487
|
+
give it once per origin. Default: echo any valid request
|
|
488
|
+
Origin, as the Solid Protocol section 8.1 requires of Solid
|
|
489
|
+
servers. Never Access-Control-Allow-Credentials: the token
|
|
490
|
+
travels in Authorization. A preflight (OPTIONS with
|
|
491
|
+
Access-Control-Request-Method) is answered 204 before any
|
|
492
|
+
authorization decision.
|
|
493
|
+
https://github.com/danbri/factoidal/issues/923
|
|
494
|
+
--no-cors send no CORS fields; a page on another origin cannot read
|
|
495
|
+
any answer
|
|
351
496
|
|
|
352
497
|
Authorization (LWS editor's draft, section Authorization; one of the three
|
|
353
498
|
is required):
|
|
@@ -365,7 +510,8 @@ is required):
|
|
|
365
510
|
that gives this agent Read, Write and Control
|
|
366
511
|
--root-acl FILE a Turtle root ACL for a new storage instead
|
|
367
512
|
--audience IRI the aud value tokens must carry (default: the
|
|
368
|
-
storage
|
|
513
|
+
storage IRI, BASE.well-known/lws-storage, which
|
|
514
|
+
every lws#storage link names)
|
|
369
515
|
|
|
370
516
|
Requests carry an access token as "Authorization: Bearer TOKEN" (RFC 6750).
|
|
371
517
|
The engine verifies its JWS signature (ES256, RS256 or EdDSA, with the
|
|
@@ -397,6 +543,14 @@ start. The engine decides the snapshot format and refuses a snapshot
|
|
|
397
543
|
path with a dot segment or other traversal form
|
|
398
544
|
(formal/lean4/L4Factoidal/LWS/Snapshot.lean).
|
|
399
545
|
|
|
546
|
+
Encryption at rest (off by default): when the environment variable
|
|
547
|
+
FACTOIDAL_LWS_KEY holds a 32-byte key (64 hex digits, or 43 characters of
|
|
548
|
+
base64url), the snapshot is sealed with ChaCha20-Poly1305 (HACL*) under a
|
|
549
|
+
new random nonce for every write, and authenticated when the storage is
|
|
550
|
+
opened. A wrong key, a changed or truncated file, a clear-text file with the
|
|
551
|
+
variable set, or a sealed file without it stops the start with a message.
|
|
552
|
+
It protects the file on disk only (formal/lean4/L4Factoidal/LWS/SnapshotSeal.lean).
|
|
553
|
+
|
|
400
554
|
--port N the port to bind; 0 takes an ephemeral one (default 3000)
|
|
401
555
|
--host ADDRESS the address to bind (default 127.0.0.1)
|
|
402
556
|
--base IRI the absolute IRI the storage root maps to
|
|
@@ -426,6 +580,42 @@ those operations landed makes this command exit 3 and say so.
|
|
|
426
580
|
|
|
427
581
|
Specification: https://solidproject.org/TR/protocol`,
|
|
428
582
|
|
|
583
|
+
'xmpp-serve': `factoidal xmpp-serve - serve XMPP (RFC 6120 and RFC 6121) over TCP or direct TLS
|
|
584
|
+
|
|
585
|
+
usage: factoidal xmpp-serve DIR [--domain NAME] [--port N] [--host ADDRESS]
|
|
586
|
+
[--accounts FILE] [--cert FILE --key FILE]
|
|
587
|
+
|
|
588
|
+
Starts a Node TCP server that runs one engine session per connection and
|
|
589
|
+
answers every byte through the engine's xmppSessionFeed operation.
|
|
590
|
+
|
|
591
|
+
DIR is the state directory, laid out as the native carrier's
|
|
592
|
+
(formal/lean4/Harness/XmppServe.lean): DIR/accounts holds one
|
|
593
|
+
user:password per line (unless --accounts names another file), rosters
|
|
594
|
+
are persisted under DIR/roster/, and a stanza written as a file under
|
|
595
|
+
DIR/spool/<bare jid>/ is delivered to that account's bound session.
|
|
596
|
+
|
|
597
|
+
--domain NAME the domain the server is authoritative for (default localhost);
|
|
598
|
+
a stream for any other domain is answered host-unknown
|
|
599
|
+
--port N the port to bind; 0 takes an ephemeral one
|
|
600
|
+
(default 5222, or 5223 with --cert and --key)
|
|
601
|
+
--host ADDRESS the address to bind (default 127.0.0.1)
|
|
602
|
+
--accounts FILE the accounts file (default DIR/accounts)
|
|
603
|
+
--cert FILE PEM certificate chain: XEP-0368 direct TLS, no STARTTLS
|
|
604
|
+
--key FILE PEM private key for --cert
|
|
605
|
+
|
|
606
|
+
The server makes no protocol decision. Stream negotiation, SASL PLAIN and
|
|
607
|
+
SCRAM-SHA-256, resource binding, the roster, presence subscription and
|
|
608
|
+
broadcast, message routing with the server-stamped from, stream errors and
|
|
609
|
+
disco#info are answered by formal/lean4/L4Factoidal/XMPP/. A WebAssembly
|
|
610
|
+
module built before the session operations landed makes this command
|
|
611
|
+
exit 3 and say so.
|
|
612
|
+
|
|
613
|
+
Not implemented: STARTTLS (use --cert/--key), server-to-server federation,
|
|
614
|
+
MUC/MIX/GC3 rooms.
|
|
615
|
+
|
|
616
|
+
Specifications: https://www.rfc-editor.org/rfc/rfc6120 and
|
|
617
|
+
https://www.rfc-editor.org/rfc/rfc6121`,
|
|
618
|
+
|
|
429
619
|
'solid-client': `factoidal solid-client - run one Solid client operation
|
|
430
620
|
|
|
431
621
|
usage: factoidal solid-client <get|put|post|delete|discover> URL [options]
|
|
@@ -447,7 +637,50 @@ kind names are accepted directly as well.
|
|
|
447
637
|
--interpret KIND one of storage, containment, auxiliaries, profile,
|
|
448
638
|
wacAllow, used when the engine names none
|
|
449
639
|
|
|
450
|
-
Specification: https://solidproject.org/TR/protocol
|
|
640
|
+
Specification: https://solidproject.org/TR/protocol`,
|
|
641
|
+
|
|
642
|
+
'lws-client': `factoidal lws-client - run one Linked Web Storage client operation
|
|
643
|
+
|
|
644
|
+
usage: factoidal lws-client <op> URL [options]
|
|
645
|
+
|
|
646
|
+
Every request is chosen and built by the engine (lwsClientStart,
|
|
647
|
+
lwsClientStep; formal/lean4/L4Factoidal/LWS/Client/): the authorization
|
|
648
|
+
flow with its discovery checks, preconditions, retries, paging and linksets.
|
|
649
|
+
This command supplies a socket and prints the engine's result as JSON. It
|
|
650
|
+
exits 1 when the operation fails, and prints the failure document.
|
|
651
|
+
|
|
652
|
+
operations:
|
|
653
|
+
discover URL read the storage description of the storage of URL
|
|
654
|
+
read URL | head URL | getAccess URL
|
|
655
|
+
create CONTAINER --body/--file, --content-type, --slug, --container,
|
|
656
|
+
--type IRI, --link VALUE
|
|
657
|
+
update URL --body/--file, --content-type, --link VALUE
|
|
658
|
+
patch URL --body/--file (JSON Patch unless --content-type)
|
|
659
|
+
delete URL --recursive
|
|
660
|
+
readLinkset URL | updateLinkset URL (--body/--file, --content-type)
|
|
661
|
+
move URL --destination CONTAINER
|
|
662
|
+
list CONTAINER every page, following rel="next"
|
|
663
|
+
typeIndex URL | search URL --query JSON
|
|
664
|
+
subscribe URL --inbox URL [--topic URL]
|
|
665
|
+
unsubscribe URL | revokeAccess URL
|
|
666
|
+
requestAccess URL | grantAccess URL
|
|
667
|
+
--action A[,A] --assignee URI [--target URL[,URL]]
|
|
668
|
+
[--inbox URL]
|
|
669
|
+
|
|
670
|
+
preconditions (update, patch, delete, updateLinkset, move):
|
|
671
|
+
--if-match TAG | --if-match-current | --if-none-match-star |
|
|
672
|
+
--if-unmodified-since DATE | --no-precondition
|
|
673
|
+
(default: --if-match-current for update, patch, updateLinkset and move)
|
|
674
|
+
--retry-412 N read the tag again and resend, at most N times
|
|
675
|
+
|
|
676
|
+
authorization:
|
|
677
|
+
--subject-token-file FILE the credential to exchange (RFC 8693)
|
|
678
|
+
--subject-token-type URI its type, for example
|
|
679
|
+
urn:ietf:params:oauth:token-type:saml2
|
|
680
|
+
--access-token TOKEN a token to present to the storage of URL
|
|
681
|
+
(with --storage IRI and --root URL)
|
|
682
|
+
|
|
683
|
+
Draft: https://w3c.github.io/lws-protocol/lws10-core/`
|
|
451
684
|
}
|
|
452
685
|
|
|
453
686
|
// ------------------------------------------------------------- arguments
|
|
@@ -475,8 +708,16 @@ function parseArguments (args, valueOptions) {
|
|
|
475
708
|
}
|
|
476
709
|
const equals = argument.indexOf('=')
|
|
477
710
|
const name = equals < 0 ? argument.slice(2) : argument.slice(2, equals)
|
|
711
|
+
// An option named in REPEATABLE_OPTIONS keeps every value, in order.
|
|
712
|
+
const keep = (value) => {
|
|
713
|
+
if (REPEATABLE_OPTIONS.has(name)) {
|
|
714
|
+
options[name] = Array.isArray(options[name]) ? [...options[name], value] : [value]
|
|
715
|
+
} else {
|
|
716
|
+
options[name] = value
|
|
717
|
+
}
|
|
718
|
+
}
|
|
478
719
|
if (equals >= 0) {
|
|
479
|
-
|
|
720
|
+
keep(argument.slice(equals + 1))
|
|
480
721
|
continue
|
|
481
722
|
}
|
|
482
723
|
if (valueOptions.has(name)) {
|
|
@@ -484,7 +725,7 @@ function parseArguments (args, valueOptions) {
|
|
|
484
725
|
if (index >= args.length) {
|
|
485
726
|
throw new UsageError(`option --${name} needs a value`)
|
|
486
727
|
}
|
|
487
|
-
|
|
728
|
+
keep(args[index])
|
|
488
729
|
continue
|
|
489
730
|
}
|
|
490
731
|
options[name] = true
|
|
@@ -494,6 +735,9 @@ function parseArguments (args, valueOptions) {
|
|
|
494
735
|
|
|
495
736
|
class UsageError extends Error {}
|
|
496
737
|
|
|
738
|
+
/** Options that may be given more than once; their value is the list. */
|
|
739
|
+
const REPEATABLE_OPTIONS = new Set(['cors-origin'])
|
|
740
|
+
|
|
497
741
|
const VALUE_OPTIONS = {
|
|
498
742
|
version: new Set([]),
|
|
499
743
|
'sample-store': new Set([]),
|
|
@@ -505,9 +749,18 @@ const VALUE_OPTIONS = {
|
|
|
505
749
|
compact: new Set([]),
|
|
506
750
|
serve: new Set(['port', 'host', 'query-path', 'gsp-path', 'file', 'layout', 'tdb2']),
|
|
507
751
|
'lws-serve': new Set(['port', 'host', 'base', 'issuer', 'auth-config', 'owner', 'root-acl',
|
|
508
|
-
'audience'
|
|
752
|
+
'audience', 'page-size', 'moves', 'controller-inbox', 'delivery-limit', 'delivery-window',
|
|
753
|
+
'redirects-max', 'storage', 'cors-origin']),
|
|
509
754
|
'solid-serve': new Set(['port', 'host', 'base', 'owner', 'issuer', 'auth-config']),
|
|
510
|
-
'solid-client': new Set(['body', 'file', 'content-type', 'interpret'])
|
|
755
|
+
'solid-client': new Set(['body', 'file', 'content-type', 'interpret']),
|
|
756
|
+
'lws-client': new Set(['body', 'file', 'content-type', 'slug', 'type', 'link', 'if-match',
|
|
757
|
+
'if-unmodified-since', 'retry-412', 'destination', 'query', 'topic', 'action', 'assignee',
|
|
758
|
+
'target', 'subject-token-file', 'subject-token-type', 'access-token', 'storage', 'root',
|
|
759
|
+
'inbox']),
|
|
760
|
+
'authz-serve': new Set(['port', 'host', 'issuer', 'resource', 'alg', 'token-lifetime', 'rotate-after',
|
|
761
|
+
'cors-origin']),
|
|
762
|
+
'authz-keys': new Set([]),
|
|
763
|
+
'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key'])
|
|
511
764
|
}
|
|
512
765
|
|
|
513
766
|
// ------------------------------------------------------------- commands
|
|
@@ -1086,6 +1339,11 @@ async function commandPack (positional, options) {
|
|
|
1086
1339
|
|
|
1087
1340
|
async function commandSampleStore (positional, options) {
|
|
1088
1341
|
if (positional.length > 1) throw new UsageError('sample-store takes at most one DIR')
|
|
1342
|
+
// Re-execute here, once, before any work. The pack and the activate
|
|
1343
|
+
// below would each re-execute the whole command line under Deno, and
|
|
1344
|
+
// each child printed the path again (three lines, 2026-10-24).
|
|
1345
|
+
const reexec = await denoReexec({ worker: options['no-worker'] !== true })
|
|
1346
|
+
if (reexec !== null) return reexec
|
|
1089
1347
|
const { mkdtempSync, writeFileSync, existsSync } = await import('node:fs')
|
|
1090
1348
|
const { tmpdir } = await import('node:os')
|
|
1091
1349
|
const root = positional.length === 1
|
|
@@ -1198,6 +1456,35 @@ function servePort (options, fallback) {
|
|
|
1198
1456
|
return port
|
|
1199
1457
|
}
|
|
1200
1458
|
|
|
1459
|
+
// --page-size N: a CLI number, passed unchanged as the engine config member
|
|
1460
|
+
// `pageSize`; what a page holds is decided in L4Factoidal/LWS/Pagination.lean.
|
|
1461
|
+
function pageSizeOption (value) {
|
|
1462
|
+
return natOption('page-size', value)
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function natOption (flag, value) {
|
|
1466
|
+
const n = Number(value)
|
|
1467
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
1468
|
+
throw new UsageError(`--${flag} must be a non-negative integer, not "${value}"`)
|
|
1469
|
+
}
|
|
1470
|
+
return n
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// --cors-origin ORIGIN (repeatable) and --no-cors, as the config member
|
|
1474
|
+
// `cors`: a list of origins, or false. Passed unread; the engine checks each
|
|
1475
|
+
// origin and decides every CORS field (L4Factoidal/HTTP/Cors.lean). With
|
|
1476
|
+
// neither option the member is absent and the engine echoes the request
|
|
1477
|
+
// Origin (Solid Protocol section 8.1).
|
|
1478
|
+
function corsConfig (options) {
|
|
1479
|
+
const origins = options['cors-origin']
|
|
1480
|
+
if (options['no-cors'] === true && origins !== undefined) {
|
|
1481
|
+
throw new UsageError('--no-cors cannot be combined with --cors-origin')
|
|
1482
|
+
}
|
|
1483
|
+
if (options['no-cors'] === true) return { cors: false }
|
|
1484
|
+
if (Array.isArray(origins)) return { cors: origins }
|
|
1485
|
+
return {}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1201
1488
|
function serveHost (options) {
|
|
1202
1489
|
if (options.host === undefined) return '127.0.0.1'
|
|
1203
1490
|
if (typeof options.host !== 'string' || options.host.length === 0) {
|
|
@@ -1326,6 +1613,20 @@ async function commandLwsServe (positional, options) {
|
|
|
1326
1613
|
port: servePort(options, 3000),
|
|
1327
1614
|
host: serveHost(options),
|
|
1328
1615
|
...(typeof options.base === 'string' ? { baseIri: options.base } : {}),
|
|
1616
|
+
...(options['page-size'] !== undefined ? { pageSize: pageSizeOption(options['page-size']) } : {}),
|
|
1617
|
+
...(options['unstable-search-results'] === true ? { searchResults: true } : {}),
|
|
1618
|
+
...(options.merkle === true ? { merkle: true } : {}),
|
|
1619
|
+
// Passed unread as config members; the engine decides what they mean
|
|
1620
|
+
// and refuses a value it does not know (Wasm/Ops/Lws.lean).
|
|
1621
|
+
...(options['paginate-always'] === true ? { paginateAlways: true } : {}),
|
|
1622
|
+
...(typeof options.moves === 'string' ? { moves: options.moves } : {}),
|
|
1623
|
+
...(options['no-linkset-put'] === true ? { linksetPut: false } : {}),
|
|
1624
|
+
...(typeof options['controller-inbox'] === 'string' ? { controllerInbox: options['controller-inbox'] } : {}),
|
|
1625
|
+
...(options['delivery-limit'] !== undefined ? { deliveryLimit: natOption('delivery-limit', options['delivery-limit']) } : {}),
|
|
1626
|
+
...(options['delivery-window'] !== undefined ? { deliveryWindow: natOption('delivery-window', options['delivery-window']) } : {}),
|
|
1627
|
+
...(options['redirects-max'] !== undefined ? { redirectsMax: natOption('redirects-max', options['redirects-max']) } : {}),
|
|
1628
|
+
...(typeof options.storage === 'string' ? { storage: options.storage } : {}),
|
|
1629
|
+
...corsConfig(options),
|
|
1329
1630
|
...(insecure ? { insecureNoAuth: true } : { auth }),
|
|
1330
1631
|
...(typeof options.owner === 'string' ? { owner: options.owner } : {}),
|
|
1331
1632
|
...(typeof options['root-acl'] === 'string'
|
|
@@ -1338,6 +1639,14 @@ async function commandLwsServe (positional, options) {
|
|
|
1338
1639
|
if (error instanceof LwsHostError && error.unknownOp) {
|
|
1339
1640
|
return notWired('lws-serve', error.message)
|
|
1340
1641
|
}
|
|
1642
|
+
// The engine refused to open the storage (a bad configuration, or a
|
|
1643
|
+
// snapshot it could not read or authenticate, issue 921), or the state
|
|
1644
|
+
// file is not JSON: the message is the whole answer.
|
|
1645
|
+
if (error !== null && typeof error === 'object' &&
|
|
1646
|
+
(error.envelope !== undefined || error.stateFile === true)) {
|
|
1647
|
+
err(`factoidal lws-serve: ${error.envelope !== undefined ? error.envelope.error : error.message}`)
|
|
1648
|
+
return EXIT_FAILURE
|
|
1649
|
+
}
|
|
1341
1650
|
throw error
|
|
1342
1651
|
}
|
|
1343
1652
|
if (!quiet) {
|
|
@@ -1348,6 +1657,79 @@ async function commandLwsServe (positional, options) {
|
|
|
1348
1657
|
return untilInterrupted(running, quiet)
|
|
1349
1658
|
}
|
|
1350
1659
|
|
|
1660
|
+
async function commandAuthzServe (positional, options) {
|
|
1661
|
+
if (positional.length !== 1) throw new UsageError('authz-serve needs one DIR')
|
|
1662
|
+
if (typeof options.resource !== 'string') {
|
|
1663
|
+
throw new UsageError('authz-serve needs --resource IRI[,IRI...]: the resources tokens may be issued for')
|
|
1664
|
+
}
|
|
1665
|
+
const quiet = options.quiet === true
|
|
1666
|
+
const { listen } = await import('../authz/server.mjs')
|
|
1667
|
+
let running
|
|
1668
|
+
try {
|
|
1669
|
+
running = await listen({
|
|
1670
|
+
port: servePort(options, 3001),
|
|
1671
|
+
host: serveHost(options),
|
|
1672
|
+
dir: positional[0],
|
|
1673
|
+
resources: options.resource.split(',').filter((v) => v !== ''),
|
|
1674
|
+
...(typeof options.issuer === 'string' ? { issuer: options.issuer } : {}),
|
|
1675
|
+
// Passed unread; the engine checks them (Wasm/Ops/Authz.lean).
|
|
1676
|
+
...(typeof options.alg === 'string' ? { alg: options.alg } : {}),
|
|
1677
|
+
...(options['token-lifetime'] !== undefined ? { tokenLifetime: natOption('token-lifetime', options['token-lifetime']) } : {}),
|
|
1678
|
+
...(options['rotate-after'] !== undefined ? { rotateAfter: natOption('rotate-after', options['rotate-after']) } : {}),
|
|
1679
|
+
...(options['allow-http'] === true ? { allowHttp: true } : {}),
|
|
1680
|
+
...(options['allow-private-fetch'] === true ? { allowPrivateFetch: true } : {}),
|
|
1681
|
+
...corsConfig(options)
|
|
1682
|
+
})
|
|
1683
|
+
} catch (error) {
|
|
1684
|
+
if (error !== null && typeof error === 'object' && error.envelope !== undefined) {
|
|
1685
|
+
err(`factoidal authz-serve: ${error.envelope.error}`)
|
|
1686
|
+
return EXIT_FAILURE
|
|
1687
|
+
}
|
|
1688
|
+
if (error !== null && typeof error === 'object' && /unknown op/.test(String(error.message))) {
|
|
1689
|
+
return notWired('authz-serve', error.message)
|
|
1690
|
+
}
|
|
1691
|
+
throw error
|
|
1692
|
+
}
|
|
1693
|
+
if (!quiet) {
|
|
1694
|
+
err(`authz-serve: listening on ${running.origin}, issuer ${running.issuer}`)
|
|
1695
|
+
if (options['allow-http'] === true) err('authz-serve: WARNING: --allow-http: http subject identifiers are accepted')
|
|
1696
|
+
if (options['allow-private-fetch'] === true) err('authz-serve: WARNING: --allow-private-fetch: private addresses may be fetched')
|
|
1697
|
+
}
|
|
1698
|
+
out(running.origin)
|
|
1699
|
+
return untilInterrupted(running, quiet)
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
async function commandAuthzKeys (positional, options) {
|
|
1703
|
+
if (positional.length !== 1) throw new UsageError('authz-keys needs one DIR')
|
|
1704
|
+
const { openAuthorizationServer, readKeyFile } = await import('../authz/server.mjs')
|
|
1705
|
+
if (readKeyFile(positional[0]) === null) {
|
|
1706
|
+
err(`factoidal authz-keys: ${positional[0]} holds no key file`)
|
|
1707
|
+
return EXIT_FAILURE
|
|
1708
|
+
}
|
|
1709
|
+
let authz
|
|
1710
|
+
try {
|
|
1711
|
+
// The issuer and resources do not matter for reading the keys.
|
|
1712
|
+
authz = await openAuthorizationServer({ dir: positional[0], issuer: 'https://authz.invalid', resources: [] })
|
|
1713
|
+
} catch (error) {
|
|
1714
|
+
if (error !== null && typeof error === 'object' && error.envelope !== undefined) {
|
|
1715
|
+
err(`factoidal authz-keys: ${error.envelope.error}`)
|
|
1716
|
+
return EXIT_FAILURE
|
|
1717
|
+
}
|
|
1718
|
+
throw error
|
|
1719
|
+
}
|
|
1720
|
+
try {
|
|
1721
|
+
if (options.private === true) {
|
|
1722
|
+
out(JSON.stringify(authz.privateJwks()))
|
|
1723
|
+
} else {
|
|
1724
|
+
const answer = await authz.step({ method: 'GET', target: '/jwks', headers: [], body: '' })
|
|
1725
|
+
out(answer.body)
|
|
1726
|
+
}
|
|
1727
|
+
} finally {
|
|
1728
|
+
authz.close()
|
|
1729
|
+
}
|
|
1730
|
+
return EXIT_OK
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1351
1733
|
async function commandSolidServe (positional, options) {
|
|
1352
1734
|
if (positional.length !== 1) throw new UsageError('solid-serve needs one DIR')
|
|
1353
1735
|
const quiet = options.quiet === true
|
|
@@ -1385,6 +1767,14 @@ async function commandSolidServe (positional, options) {
|
|
|
1385
1767
|
if (error instanceof SolidServerHostError && error.unknownOp) {
|
|
1386
1768
|
return notWired('solid-serve', error.message)
|
|
1387
1769
|
}
|
|
1770
|
+
// The engine refused to open the storage (a bad configuration, or a
|
|
1771
|
+
// snapshot it could not read or authenticate, issue 921), or the state
|
|
1772
|
+
// file is not JSON: the message is the whole answer.
|
|
1773
|
+
if (error !== null && typeof error === 'object' &&
|
|
1774
|
+
(error.envelope !== undefined || error.stateFile === true)) {
|
|
1775
|
+
err(`factoidal solid-serve: ${error.envelope !== undefined ? error.envelope.error : error.message}`)
|
|
1776
|
+
return EXIT_FAILURE
|
|
1777
|
+
}
|
|
1388
1778
|
throw error
|
|
1389
1779
|
}
|
|
1390
1780
|
if (!quiet) {
|
|
@@ -1395,6 +1785,45 @@ async function commandSolidServe (positional, options) {
|
|
|
1395
1785
|
return untilInterrupted(running, quiet)
|
|
1396
1786
|
}
|
|
1397
1787
|
|
|
1788
|
+
async function commandXmppServe (positional, options) {
|
|
1789
|
+
if (positional.length !== 1) throw new UsageError('xmpp-serve needs one DIR')
|
|
1790
|
+
const quiet = options.quiet === true
|
|
1791
|
+
const { listen, XmppHostError } = await import('../xmpp/server.mjs')
|
|
1792
|
+
const fs = await import('node:fs/promises')
|
|
1793
|
+
const useTls = options.cert !== undefined || options.key !== undefined
|
|
1794
|
+
if (useTls && (typeof options.cert !== 'string' || typeof options.key !== 'string')) {
|
|
1795
|
+
throw new UsageError('--cert and --key go together')
|
|
1796
|
+
}
|
|
1797
|
+
const settings = {
|
|
1798
|
+
dir: positional[0],
|
|
1799
|
+
port: servePort(options, useTls ? 5223 : 5222),
|
|
1800
|
+
host: serveHost(options),
|
|
1801
|
+
onError: (error) => { if (!quiet) err(`xmpp-serve: ${error.message}`) }
|
|
1802
|
+
}
|
|
1803
|
+
if (typeof options.domain === 'string') settings.domain = options.domain
|
|
1804
|
+
if (typeof options.accounts === 'string') settings.accountsText = await fs.readFile(options.accounts, 'utf8')
|
|
1805
|
+
if (useTls) {
|
|
1806
|
+
settings.cert = await fs.readFile(options.cert)
|
|
1807
|
+
settings.key = await fs.readFile(options.key)
|
|
1808
|
+
}
|
|
1809
|
+
let running
|
|
1810
|
+
try {
|
|
1811
|
+
running = await listen(settings)
|
|
1812
|
+
} catch (error) {
|
|
1813
|
+
if (error instanceof XmppHostError && error.unknownOp) {
|
|
1814
|
+
return notWired('xmpp-serve', error.message)
|
|
1815
|
+
}
|
|
1816
|
+
throw error
|
|
1817
|
+
}
|
|
1818
|
+
if (!quiet) {
|
|
1819
|
+
err(`xmpp-serve: listening on ${running.origin} for domain ${running.xmpp.domain}` +
|
|
1820
|
+
` (${running.xmpp.accounts} accounts, state ${positional[0]}` +
|
|
1821
|
+
(useTls ? ', direct TLS)' : ', PLAINTEXT: no TLS on this listener)'))
|
|
1822
|
+
}
|
|
1823
|
+
out(running.origin)
|
|
1824
|
+
return untilInterrupted(running, quiet)
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1398
1827
|
// The command's verbs, as aliases for the wasm ABI's request kinds. A
|
|
1399
1828
|
// kind name is accepted directly too, so this table adds a spelling and
|
|
1400
1829
|
// hides nothing.
|
|
@@ -1451,6 +1880,75 @@ async function commandSolidClient (positional, options) {
|
|
|
1451
1880
|
return EXIT_OK
|
|
1452
1881
|
}
|
|
1453
1882
|
|
|
1883
|
+
// `factoidal lws-client`: the caller's options become the members of one
|
|
1884
|
+
// operation document; the engine decides everything else.
|
|
1885
|
+
async function commandLwsClient (positional, options) {
|
|
1886
|
+
if (positional.length !== 2) {
|
|
1887
|
+
throw new UsageError('lws-client needs an operation and a URL')
|
|
1888
|
+
}
|
|
1889
|
+
const [kind, url] = positional
|
|
1890
|
+
const { createLwsClient, LwsClientError, LWS_CLIENT_KINDS } = await import('../lws/client.mjs')
|
|
1891
|
+
if (!LWS_CLIENT_KINDS.includes(kind) || kind === 'verifyNotification') {
|
|
1892
|
+
throw new UsageError(`unknown operation "${kind}"; one of ` +
|
|
1893
|
+
LWS_CLIENT_KINDS.filter((k) => k !== 'verifyNotification').join(', '))
|
|
1894
|
+
}
|
|
1895
|
+
const list = (v) => String(v).split(',').filter((x) => x !== '')
|
|
1896
|
+
const op = { kind, url }
|
|
1897
|
+
if (typeof options.file === 'string') op.body = readWhole(options.file)
|
|
1898
|
+
else if (typeof options.body === 'string') op.body = options.body
|
|
1899
|
+
if (typeof options['content-type'] === 'string') op.mediaType = options['content-type']
|
|
1900
|
+
if (typeof options.slug === 'string') op.slug = options.slug
|
|
1901
|
+
if (options.container === true) op.container = true
|
|
1902
|
+
if (options.recursive === true) op.recursive = true
|
|
1903
|
+
if (typeof options.type === 'string') op.types = list(options.type)
|
|
1904
|
+
if (typeof options.link === 'string') op.links = [options.link]
|
|
1905
|
+
if (typeof options['if-match'] === 'string') { op.precondition = 'ifMatch'; op.tag = options['if-match'] }
|
|
1906
|
+
if (options['if-match-current'] === true) op.precondition = 'ifMatchCurrent'
|
|
1907
|
+
if (options['if-none-match-star'] === true) op.precondition = 'ifNoneMatchStar'
|
|
1908
|
+
if (typeof options['if-unmodified-since'] === 'string') {
|
|
1909
|
+
op.precondition = 'ifUnmodifiedSince'; op.tag = options['if-unmodified-since']
|
|
1910
|
+
}
|
|
1911
|
+
if (options['no-precondition'] === true) op.precondition = 'none'
|
|
1912
|
+
if (typeof options['retry-412'] === 'string') op.retry412 = Number(options['retry-412'])
|
|
1913
|
+
if (typeof options.destination === 'string') op.destination = options.destination
|
|
1914
|
+
if (typeof options.query === 'string') op.query = JSON.parse(options.query)
|
|
1915
|
+
if (typeof options.topic === 'string') op.topics = list(options.topic)
|
|
1916
|
+
if (typeof options.inbox === 'string') op.inbox = options.inbox
|
|
1917
|
+
if (typeof options.action === 'string') op.actions = list(options.action)
|
|
1918
|
+
if (typeof options.assignee === 'string') op.assignee = options.assignee
|
|
1919
|
+
if (typeof options.target === 'string') op.targets = list(options.target)
|
|
1920
|
+
const clientOptions = {}
|
|
1921
|
+
if (typeof options['subject-token-file'] === 'string') {
|
|
1922
|
+
clientOptions.subjectToken = new TextDecoder('utf-8', { fatal: true })
|
|
1923
|
+
.decode(readWhole(options['subject-token-file'])).trim()
|
|
1924
|
+
}
|
|
1925
|
+
if (typeof options['subject-token-type'] === 'string') {
|
|
1926
|
+
clientOptions.subjectTokenType = options['subject-token-type']
|
|
1927
|
+
}
|
|
1928
|
+
if (typeof options['access-token'] === 'string') {
|
|
1929
|
+
if (typeof options.storage !== 'string' || typeof options.root !== 'string') {
|
|
1930
|
+
throw new UsageError('--access-token needs --storage IRI and --root URL')
|
|
1931
|
+
}
|
|
1932
|
+
clientOptions.session = {
|
|
1933
|
+
tokens: [{ storage: options.storage, root: options.root, token: options['access-token'] }]
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
let answer
|
|
1937
|
+
try {
|
|
1938
|
+
const client = await createLwsClient(clientOptions)
|
|
1939
|
+
answer = await client.run(op)
|
|
1940
|
+
} catch (error) {
|
|
1941
|
+
if (error instanceof LwsClientError) {
|
|
1942
|
+
if (error.unknownOp) return notWired('lws-client', error.message)
|
|
1943
|
+
out(JSON.stringify({ fail: error.detail }, null, 2))
|
|
1944
|
+
return EXIT_FAILURE
|
|
1945
|
+
}
|
|
1946
|
+
throw error
|
|
1947
|
+
}
|
|
1948
|
+
out(JSON.stringify(answer, null, 2))
|
|
1949
|
+
return EXIT_OK
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1454
1952
|
const COMMANDS = {
|
|
1455
1953
|
version: (positional, options) => commandVersion(options),
|
|
1456
1954
|
'sample-store': (positional, options) => commandSampleStore(positional, options),
|
|
@@ -1463,7 +1961,11 @@ const COMMANDS = {
|
|
|
1463
1961
|
serve: commandServe,
|
|
1464
1962
|
'lws-serve': commandLwsServe,
|
|
1465
1963
|
'solid-serve': commandSolidServe,
|
|
1466
|
-
'solid-client': commandSolidClient
|
|
1964
|
+
'solid-client': commandSolidClient,
|
|
1965
|
+
'lws-client': commandLwsClient,
|
|
1966
|
+
'authz-serve': commandAuthzServe,
|
|
1967
|
+
'authz-keys': commandAuthzKeys,
|
|
1968
|
+
'xmpp-serve': commandXmppServe
|
|
1467
1969
|
}
|
|
1468
1970
|
|
|
1469
1971
|
// ----------------------------------------------------------------- main
|