@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/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 root IRI)
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
- options[name] = argument.slice(equals + 1)
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
- options[name] = args[index]
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