@factoidal/core 0.16.0 → 0.18.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
@@ -35,6 +35,8 @@ import {
35
35
  STACK_REMEDY, StoreOperationError, inspectManifest, openStore, planQuery,
36
36
  queryStore, stackLimitAdvice, turtleOfNQuads
37
37
  } from './store.mjs'
38
+ import { sbm11Api, setDefaultSbm11Reader } from '../lib/sbm11.mjs'
39
+ import { nodeDefaultReaderFactory } from '../lib/sbm11-node.mjs'
38
40
 
39
41
  const EXIT_OK = 0
40
42
  const EXIT_FAILURE = 1
@@ -45,7 +47,12 @@ const EXIT_NOT_WIRED = 3
45
47
  // a time, so this is the window that catches one feed per report.
46
48
  const FEED_PROGRESS = 65536
47
49
 
48
- const PACK_LAYOUTS = ['ibk3', 'ibk4', 'ibk5']
50
+ const PACK_LAYOUTS = ['ibk3', 'ibk4', 'ibk5', 'sbm11']
51
+ // https://github.com/danbri/factoidal/issues/1009: an SBM11 generation is
52
+ // packed by the wasm op sbm11PackFiles, verified by sbm11Verify and queried
53
+ // by sbm11Open / sbm11Query (formal/lean4/Wasm/Ops/Sbm11*.lean). The op
54
+ // storeLayout says which store a directory holds; this file does not decide
55
+ // it from file names (iron rule 7).
49
56
  // The syntaxes the packer reads and the file-name suffixes of each are the
50
57
  // engine's tables (op engineTables: packSyntaxes, packSuffixes,
51
58
  // packUnsupportedSuffixes), https://github.com/danbri/factoidal/issues/894.
@@ -106,6 +113,8 @@ commands:
106
113
  (RFC 8414 metadata, JWK Set, RFC 8693 token exchange)
107
114
  authz-keys DIR print the public JWK Set of an authorization server
108
115
  xmpp-serve DIR serve XMPP (RFC 6120/6121) over TCP or direct TLS
116
+ webdav-serve CONFIGDIR serve local and remote LWS storages to file
117
+ browsers over WebDAV (RFC 4918)
109
118
 
110
119
  global options:
111
120
  -h, --help print this text, or a command's own help
@@ -226,8 +235,11 @@ normally STORE/gen-N. Packing does not activate: run activate next.
226
235
  options:
227
236
  --layout LAYOUT ibk5 (quads, wire version 10: RDF 1.2 terms,
228
237
  out-of-line literals, zone maps; default), ibk4
229
- (quads, wire version 9) or ibk3 (triples)
230
- --syntax SYNTAX turtle, trig or nquads; default from the file extension
238
+ (quads, wire version 9), ibk3 (triples) or sbm11
239
+ (the files l4sbm11-pack writes; the document is read
240
+ whole, in memory; serve does not read it yet)
241
+ --syntax SYNTAX turtle, trig or nquads, and with sbm11 also rdfxml or
242
+ jsonld; default from the file extension
231
243
  --chunk-bytes N Merkle chunk size; default is the engine's
232
244
  --batch-bytes N ibk4 only: source bytes between publication batches;
233
245
  default is the engine's. Blocks are published during
@@ -248,7 +260,10 @@ usage: factoidal activate STORE GENERATION [--json]
248
260
 
249
261
  Verifies every artifact of GENERATION and every cross-artifact relation,
250
262
  then atomically replaces STORE/CURRENT. A generation that fails
251
- verification never becomes current.`,
263
+ verification never becomes current. For an SBM11 generation the engine
264
+ checks the length, SHA-256 and Merkle root of every file and every
265
+ .merkle sidecar against the manifest (op sbm11Verify); it does not run
266
+ the decoder checks of l4sbm11 activate.`,
252
267
 
253
268
  update: `factoidal update - apply a SPARQL Update through the delta log
254
269
 
@@ -379,8 +394,10 @@ private keys (with d): for a conformance harness that mints test tokens
379
394
 
380
395
  'lws-serve': `factoidal lws-serve - serve Linked Web Storage 1.0 over HTTP
381
396
 
382
- usage: factoidal lws-serve DIR (--issuer IRI | --auth-config FILE | --insecure-no-auth)
397
+ usage: factoidal lws-serve DIR (--authz DIR | --issuer IRI | --auth-config FILE | --insecure-no-auth)
383
398
  [--owner WEBID] [--root-acl FILE] [--audience IRI]
399
+ [--alg ES256|EdDSA] [--token-lifetime SECONDS] [--rotate-after SECONDS]
400
+ [--allow-http] [--allow-private-fetch]
384
401
  [--port N] [--host ADDRESS] [--base IRI] [--page-size N]
385
402
  [--paginate-always] [--moves all|data|off]
386
403
  [--no-linkset-put] [--unstable-search-results] [--merkle]
@@ -494,8 +511,21 @@ It protects the file on disk only (formal/lean4/L4Factoidal/LWS/SnapshotSeal.lea
494
511
  --no-cors send no CORS fields; a page on another origin cannot read
495
512
  any answer
496
513
 
497
- Authorization (LWS editor's draft, section Authorization; one of the three
514
+ Authorization (LWS editor's draft, section Authorization; one of the four
498
515
  is required):
516
+ --authz DIR also serve an authorization server on this port, its
517
+ issuer the storage root without the trailing slash
518
+ (BASE less "/"): RFC 8414 metadata at
519
+ /.well-known/lws-configuration, the token endpoint at
520
+ BASE.well-known/lws-token, the JWK Set at
521
+ BASE.well-known/lws-jwks, signing keys in DIR (as
522
+ authz-serve; --alg, --token-lifetime, --rotate-after,
523
+ --allow-http and --allow-private-fetch as there). The
524
+ storage trusts it, and any --issuer too. Each request
525
+ is the authorization server's when the engine says so
526
+ (L4Factoidal/Authz/Mount.lean); every other is the
527
+ storage's. A proxy may serve BASE at a path prefix and
528
+ remove the prefix: both forms of every path are read.
499
529
  --issuer IRI trust this authorization server; several may be
500
530
  given separated by commas. Its metadata is read from
501
531
  IRI/.well-known/lws-configuration and its keys from
@@ -616,6 +646,47 @@ MUC/MIX/GC3 rooms.
616
646
  Specifications: https://www.rfc-editor.org/rfc/rfc6120 and
617
647
  https://www.rfc-editor.org/rfc/rfc6121`,
618
648
 
649
+ 'webdav-serve': `factoidal webdav-serve - serve LWS storages to file browsers over WebDAV
650
+
651
+ usage: factoidal webdav-serve CONFIGDIR [--port N] [--host ADDRESS]
652
+
653
+ Starts a WebDAV server (RFC 4918, classes 1 and 2) that macOS Finder,
654
+ Windows Explorer, GNOME Files and KDE Dolphin can connect to. Its root
655
+ collection holds one folder per mount; a mount is one Markdown file
656
+ CONFIGDIR/NAME.md (README.md is not a mount):
657
+
658
+ # Display name
659
+ - kind: local | lws
660
+ - path: ./alice-storage (local: an lws-serve storage directory)
661
+ - url: https://storage.example/alice/ (lws: the storage root)
662
+ - auth: none | did-key | bearer
663
+ - key-file: ./alice.jwk (did-key: a P-256 private JWK; made when absent)
664
+ - token-file: ./alice.token (bearer: an access token)
665
+ - writable: true | false (default true for local, false for lws)
666
+ - storage: snapshot | files (local: default what the directory holds)
667
+ - base: IRI (local: the storage root's IRI, default http://localhost/)
668
+
669
+ Only the first "# " heading and the first "- key: value" list are read; the
670
+ words in brackets above are notes, not part of a value. A malformed file
671
+ stops the start with its name and line number.
672
+
673
+ A local mount opens the directory with the operations lws-serve uses, as
674
+ the storage's owner (do not serve the same directory with lws-serve at the
675
+ same time). An lws mount reaches the storage through the LWS client.
676
+
677
+ --port N the port to bind (default 8090; 0 takes an ephemeral one)
678
+ --host ADDRESS the address to bind (default 127.0.0.1)
679
+
680
+ Connect: macOS Finder, Go > Connect to Server, http://127.0.0.1:8090/ ;
681
+ Windows, Map network drive, http://127.0.0.1:8090/ ; GNOME Files,
682
+ dav://127.0.0.1:8090/ .
683
+
684
+ The server makes no protocol decision: every method, status, property, lock
685
+ and mapping is decided by formal/lean4/L4Factoidal/WebDAV/. A WebAssembly
686
+ module built before the WebDAV operations landed makes this command exit 3
687
+ and say so. Design: docs/designissues/2026-10-27-webdav.md
688
+ (https://github.com/danbri/factoidal/issues/1066).`,
689
+
619
690
  'solid-client': `factoidal solid-client - run one Solid client operation
620
691
 
621
692
  usage: factoidal solid-client <get|put|post|delete|discover> URL [options]
@@ -750,7 +821,7 @@ const VALUE_OPTIONS = {
750
821
  serve: new Set(['port', 'host', 'query-path', 'gsp-path', 'file', 'layout', 'tdb2']),
751
822
  'lws-serve': new Set(['port', 'host', 'base', 'issuer', 'auth-config', 'owner', 'root-acl',
752
823
  'audience', 'page-size', 'moves', 'controller-inbox', 'delivery-limit', 'delivery-window',
753
- 'redirects-max', 'storage', 'cors-origin']),
824
+ 'redirects-max', 'storage', 'cors-origin', 'authz', 'alg', 'token-lifetime', 'rotate-after']),
754
825
  'solid-serve': new Set(['port', 'host', 'base', 'owner', 'issuer', 'auth-config']),
755
826
  'solid-client': new Set(['body', 'file', 'content-type', 'interpret']),
756
827
  'lws-client': new Set(['body', 'file', 'content-type', 'slug', 'type', 'link', 'if-match',
@@ -760,7 +831,8 @@ const VALUE_OPTIONS = {
760
831
  'authz-serve': new Set(['port', 'host', 'issuer', 'resource', 'alg', 'token-lifetime', 'rotate-after',
761
832
  'cors-origin']),
762
833
  'authz-keys': new Set([]),
763
- 'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key'])
834
+ 'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key']),
835
+ 'webdav-serve': new Set(['port', 'host'])
764
836
  }
765
837
 
766
838
  // ------------------------------------------------------------- commands
@@ -931,14 +1003,154 @@ function printTable (headers, rows) {
931
1003
  for (const row of rows) out(line(row))
932
1004
  }
933
1005
 
1006
+ // --------------------------------------------------------------- SBM11
1007
+
1008
+ let sbm11 = null
1009
+
1010
+ /** The SBM11 functions of lib/sbm11.mjs over this command's engine, with
1011
+ * the Node directory reader as the default reader (it moves bytes only). */
1012
+ function sbm11Functions () {
1013
+ if (sbm11 === null) {
1014
+ setDefaultSbm11Reader(nodeDefaultReaderFactory())
1015
+ sbm11 = sbm11Api(loadEngine)
1016
+ }
1017
+ return sbm11
1018
+ }
1019
+
1020
+ /**
1021
+ * The engine's answer (op storeLayout) when `source` holds an SBM11
1022
+ * generation, or names one through CURRENT: `{layout, generation,
1023
+ * manifest}`. null for an IBK store, for a directory the op does not
1024
+ * recognise, and for an engine built before the op; the IBK path then
1025
+ * runs, and reports a missing store in its own words.
1026
+ */
1027
+ function sbm11Layout (engine, source) {
1028
+ sbm11Functions()
1029
+ let answer
1030
+ try {
1031
+ answer = engine.call('storeLayout', [source])
1032
+ } catch (_error) {
1033
+ return null
1034
+ }
1035
+ return answer && answer.layout === 'sbm11' ? answer : null
1036
+ }
1037
+
1038
+ /** The directory a command reads: STORE, or STORE/GENERATION when
1039
+ * --generation names one. */
1040
+ function storeSource (root, named) {
1041
+ return named === null ? root : joinPath(root, named)
1042
+ }
1043
+
1044
+ /** The engine's words of a refusal, without the loader's prefix. */
1045
+ function engineWords (error) {
1046
+ const message = error && error.message ? String(error.message) : String(error)
1047
+ return message.replace(/^l4factoidal:\s*/, '')
1048
+ }
1049
+
1050
+ async function inspectSbm11 (root, named, layout, options) {
1051
+ let store
1052
+ try {
1053
+ store = await sbm11Functions().openSbm11(storeSource(root, named))
1054
+ } catch (error) {
1055
+ err(`factoidal inspect: ${engineWords(error)}`)
1056
+ return EXIT_FAILURE
1057
+ }
1058
+ const terms = store.terms
1059
+ store.close()
1060
+ const generation = named !== null ? named : layout.generation
1061
+ const generationDir = named !== null || layout.generation === ''
1062
+ ? storeSource(root, named)
1063
+ : joinPath(root, layout.generation)
1064
+ const files = listGeneration(generationDir)
1065
+ let directoryBytes = 0
1066
+ for (const file of files) directoryBytes += file.size
1067
+ if (options.json) {
1068
+ out(JSON.stringify({
1069
+ ok: true,
1070
+ layout: 'sbm11',
1071
+ generation,
1072
+ activated: named === null && layout.generation !== '',
1073
+ manifest: layout.manifest,
1074
+ terms,
1075
+ files: files.length,
1076
+ bytes: directoryBytes
1077
+ }, null, 2))
1078
+ return EXIT_OK
1079
+ }
1080
+ out(`store ${root}`)
1081
+ if (named !== null) out(`generation ${generation} (named on the command line)`)
1082
+ else if (layout.generation !== '') out(`generation ${generation} (activated through CURRENT)`)
1083
+ else out('generation directory (no CURRENT)')
1084
+ out(`manifest ${layout.manifest}`)
1085
+ out('layout sbm11')
1086
+ out(plural(terms, 'term'))
1087
+ out(`generation directory holds ${files.length} files, ${directoryBytes} bytes`)
1088
+ return EXIT_OK
1089
+ }
1090
+
1091
+ /**
1092
+ * Answer a query on an SBM11 store (ops sbm11Open / sbm11Query /
1093
+ * sbm11Close) and print it as `query` prints an IBK answer. The engine
1094
+ * answers SPARQL Query Results JSON for SELECT and ASK and N-Triples for
1095
+ * CONSTRUCT and DESCRIBE; this only chooses which renderer prints it.
1096
+ */
1097
+ async function querySbm11 (engine, root, named, sparql, format, limit, options) {
1098
+ const quiet = options.quiet === true
1099
+ if (options.explain === true) {
1100
+ err('factoidal query: --explain prints the plan of an IBK store; there is ' +
1101
+ 'no plan operation for an SBM11 store yet ' +
1102
+ '(https://github.com/danbri/factoidal/issues/1009)')
1103
+ return EXIT_FAILURE
1104
+ }
1105
+ let answer
1106
+ try {
1107
+ const store = await sbm11Functions().openSbm11(storeSource(root, named))
1108
+ try {
1109
+ const got = store.query(sparql)
1110
+ // read the bytes while the handle is open
1111
+ answer = { path: got.path, format: got.format, reads: got.reads,
1112
+ bytesRead: got.bytesRead, text: got.result }
1113
+ } finally {
1114
+ store.close()
1115
+ }
1116
+ } catch (error) {
1117
+ err(`factoidal query: ${engineWords(error)}`)
1118
+ if (isStackOverflow(error)) {
1119
+ for (const line of stackLimitAdvice(STACK_REMEDY.query)) err(line)
1120
+ }
1121
+ return EXIT_FAILURE
1122
+ }
1123
+ if (!quiet) {
1124
+ err(`${answer.path}, ${plural(answer.reads, 'read')}, ${answer.bytesRead} bytes read`)
1125
+ }
1126
+ let result
1127
+ if (answer.format === 'srj') {
1128
+ const document = JSON.parse(answer.text)
1129
+ if (typeof document.boolean === 'boolean') {
1130
+ if (format === 'json') {
1131
+ out(JSON.stringify(document, null, 2))
1132
+ return EXIT_OK
1133
+ }
1134
+ result = { kind: 'ask', boolean: document.boolean }
1135
+ } else {
1136
+ result = { kind: 'select', srj: document }
1137
+ }
1138
+ } else {
1139
+ result = { kind: 'construct', nquads: answer.text }
1140
+ }
1141
+ return renderQueryResult(engine, result, format, limit, quiet)
1142
+ }
1143
+
934
1144
  // ------------------------------------------------------------- inspect
935
1145
 
936
1146
  async function commandInspect (positional, options) {
937
1147
  if (positional.length !== 1) throw new UsageError('inspect needs exactly one STORE')
938
1148
  const root = positional[0]
939
1149
  const named = typeof options.generation === 'string' ? options.generation : null
940
- const store = openStore(root, named)
941
1150
  const engine = await loadEngine()
1151
+ const layout = sbm11Layout(engine, storeSource(root, named))
1152
+ if (layout !== null) return inspectSbm11(root, named, layout, options)
1153
+ const store = openStore(root, named)
942
1154
  const envelope = inspectManifest(engine, store)
943
1155
 
944
1156
  if (options.json) {
@@ -1102,8 +1314,11 @@ async function commandQuery (positional, options) {
1102
1314
  const quiet = options.quiet === true
1103
1315
 
1104
1316
  const named = typeof options.generation === 'string' ? options.generation : null
1105
- const store = openStore(root, named)
1106
1317
  const engine = await loadEngine()
1318
+ if (sbm11Layout(engine, storeSource(root, named)) !== null) {
1319
+ return querySbm11(engine, root, named, sparql, format, limit, options)
1320
+ }
1321
+ const store = openStore(root, named)
1107
1322
 
1108
1323
  if (options.explain === true) {
1109
1324
  let plan
@@ -1208,9 +1423,15 @@ function readHead (path, length) {
1208
1423
  /** The syntax tag for an input: --syntax when given, else the engine's
1209
1424
  * suffix table (op engineTables), else the engine's content detection
1210
1425
  * (op detectFormat) on the first 64 KiB of the file. */
1211
- function packSyntax (engine, input, options) {
1426
+ function packSyntax (engine, input, options, layout) {
1212
1427
  const tables = engine.call('engineTables', []).tables
1213
- const syntaxes = tables.packSyntaxes
1428
+ // The SBM11 pack reads the whole document (op sbm11PackFiles), so it also
1429
+ // reads the syntaxes of packWholeSuffixes (RDF/XML, JSON-LD).
1430
+ const whole = layout === 'sbm11' ? (tables.packWholeSuffixes ?? {}) : {}
1431
+ const syntaxes = tables.packSyntaxes.slice()
1432
+ for (const syntax of Object.values(whole)) {
1433
+ if (syntaxes.indexOf(syntax) < 0) syntaxes.push(syntax)
1434
+ }
1214
1435
  if (typeof options.syntax === 'string') {
1215
1436
  const syntax = options.syntax.toLowerCase()
1216
1437
  if (syntaxes.indexOf(syntax) >= 0) return syntax
@@ -1220,11 +1441,14 @@ function packSyntax (engine, input, options) {
1220
1441
  for (const [suffix, syntax] of Object.entries(tables.packSuffixes)) {
1221
1442
  if (lower.endsWith(suffix)) return syntax
1222
1443
  }
1444
+ for (const [suffix, syntax] of Object.entries(whole)) {
1445
+ if (lower.endsWith(suffix)) return syntax
1446
+ }
1223
1447
  for (const [suffix, name] of Object.entries(tables.packUnsupportedSuffixes)) {
1224
1448
  if (lower.endsWith(suffix)) {
1225
1449
  throw new UsageError(
1226
- `pack does not read ${name}. The packer's streaming fold reads ` +
1227
- `${syntaxes.join(', ')} only. Convert the file first, for ` +
1450
+ `pack does not read ${name}. The packer reads ` +
1451
+ `${syntaxes.join(', ')} only${layout === 'sbm11' ? '' : ' (--layout sbm11 also reads rdfxml and jsonld)'}. Convert the file first, for ` +
1228
1452
  "example with: factoidal parse FILE --out nquads")
1229
1453
  }
1230
1454
  }
@@ -1253,8 +1477,11 @@ function packSyntax (engine, input, options) {
1253
1477
  * `--base ''` asks for no base, which turns a relative IRI into a parse
1254
1478
  * error rather than a silently different term.
1255
1479
  */
1256
- function packBase (input, options) {
1480
+ function packBase (input, options, layout) {
1257
1481
  if (typeof options.base === 'string') return options.base
1482
+ // `l4sbm11-pack` uses no base unless --base names one; the same default
1483
+ // gives the same bytes.
1484
+ if (layout === 'sbm11') return ''
1258
1485
  const absolute = input.startsWith('/') ? input : joinPath(currentDirectory(), input)
1259
1486
  return 'file://' + absolute
1260
1487
  }
@@ -1275,8 +1502,8 @@ function packLayout (options) {
1275
1502
  async function commandPack (positional, options) {
1276
1503
  if (positional.length !== 2) throw new UsageError('pack needs INPUT and OUTPUT')
1277
1504
  const [input, output] = positional
1278
- const syntax = packSyntax(await loadEngine(), input, options)
1279
1505
  const layout = packLayout(options)
1506
+ const syntax = packSyntax(await loadEngine(), input, options, layout)
1280
1507
  // The pack fold needs a bigger call stack than either runtime gives by
1281
1508
  // default (https://github.com/danbri/factoidal/issues/649). Under Node
1282
1509
  // the work runs on a worker thread with a raised stack; under Deno the
@@ -1291,8 +1518,10 @@ async function commandPack (positional, options) {
1291
1518
  let answer
1292
1519
  try {
1293
1520
  answer = await runPack(
1294
- { kind: 'pack', input, output, syntax, layout, base: packBase(input, options),
1295
- batchBytes: options['batch-bytes'] },
1521
+ layout === 'sbm11'
1522
+ ? { kind: 'packSbm11', input, output, syntax, base: packBase(input, options, layout) }
1523
+ : { kind: 'pack', input, output, syntax, layout, base: packBase(input, options, layout),
1524
+ batchBytes: options['batch-bytes'] },
1296
1525
  quiet
1297
1526
  ? undefined
1298
1527
  : (progress) => {
@@ -1331,7 +1560,7 @@ async function commandPack (positional, options) {
1331
1560
  return EXIT_OK
1332
1561
  }
1333
1562
  out(`packed ${report.bytesRead} bytes of ${syntax} into ${output}`)
1334
- out(`${report.written.length} artifacts, ${report.bytesWritten} bytes, layout ${layout}`)
1563
+ out(`${report.written.length} ${layout === 'sbm11' ? 'files' : 'artifacts'}, ${report.bytesWritten} bytes, layout ${layout}`)
1335
1564
  if (typeof report.rows === 'number') out(`${plural(report.rows, 'row')}`)
1336
1565
  out(`activate it with: factoidal activate ${dirOf(output)} ${nameOf(output)}`)
1337
1566
  return EXIT_OK
@@ -1382,6 +1611,10 @@ async function commandActivate (positional, options) {
1382
1611
  const host = { worker: options['no-worker'] !== true }
1383
1612
  const reexec = await denoReexec(host)
1384
1613
  if (reexec !== null) return reexec
1614
+ const engine = await loadEngine()
1615
+ if (sbm11Layout(engine, joinPath(root, generation)) !== null) {
1616
+ return activateSbm11(engine, root, generation, options)
1617
+ }
1385
1618
  let answer
1386
1619
  try {
1387
1620
  answer = await runPack({ kind: 'activate', root, generation }, undefined, host)
@@ -1425,6 +1658,32 @@ async function commandActivate (positional, options) {
1425
1658
  return EXIT_OK
1426
1659
  }
1427
1660
 
1661
+ /**
1662
+ * Activate an SBM11 generation: the engine checks every file against the
1663
+ * manifest (op sbm11Verify), and only after its "ok" does CURRENT move.
1664
+ */
1665
+ function activateSbm11 (engine, root, generation, options) {
1666
+ let verdict
1667
+ try {
1668
+ verdict = engine.call('sbm11Verify', [joinPath(root, generation)])
1669
+ } catch (error) {
1670
+ err(`factoidal activate: ${engineWords(error)}`)
1671
+ err('The generation is NOT activated; CURRENT is unchanged.')
1672
+ return EXIT_FAILURE
1673
+ }
1674
+ const synced = atomicReplace(joinPath(root, 'CURRENT'), new TextEncoder().encode(generation))
1675
+ if (options.json === true) {
1676
+ out(JSON.stringify({ ...verdict, layout: 'sbm11', generation, directorySynced: synced }, null, 2))
1677
+ return EXIT_OK
1678
+ }
1679
+ out(`activated ${generation}: ${verdict.artifacts} artifacts verified (${verdict.files} files, ${verdict.bytes} bytes), layout sbm11`)
1680
+ if (!synced) {
1681
+ err('CURRENT was replaced, but the directory entry was not synced; a ' +
1682
+ 'crash now could lose the pointer update.')
1683
+ }
1684
+ return EXIT_OK
1685
+ }
1686
+
1428
1687
  function commandUpdate (positional, options) {
1429
1688
  if (positional.length < 1) throw new UsageError('update needs a STORE')
1430
1689
  const text = positional.length > 1
@@ -1529,6 +1788,10 @@ async function commandServe (positional, options) {
1529
1788
  const { join } = await import('node:path')
1530
1789
  const root = mkdtempSync(join(tmpdir(), 'factoidal-serve-'))
1531
1790
  const packOptions = { quiet: true, layout: packLayout(options) }
1791
+ if (packOptions.layout === 'sbm11') {
1792
+ throw new UsageError('serve --file packs a store that accepts updates; --layout sbm11 ' +
1793
+ 'packs a store that serve does not read yet. Use ibk3, ibk4 or ibk5.')
1794
+ }
1532
1795
  let rc = await commandPack([options.file, join(root, 'gen-1')], packOptions)
1533
1796
  if (rc !== 0) return rc
1534
1797
  rc = await commandActivate([root, 'gen-1'], { quiet: true })
@@ -1549,6 +1812,12 @@ async function commandServe (positional, options) {
1549
1812
  options = { ...options, 'query-path': `${dsName}/sparql`, 'gsp-path': `${dsName}/data` }
1550
1813
  }
1551
1814
  if (positional.length !== 1) throw new UsageError('serve needs one STORE')
1815
+ if (!tdb2 && sbm11Layout(await loadEngine(), positional[0]) !== null) {
1816
+ err(`factoidal serve: ${positional[0]} is an SBM11 store, and serve reads IBK ` +
1817
+ 'stores only (the storeServe operations). Query it with: factoidal query ' +
1818
+ `${positional[0]} QUERY (https://github.com/danbri/factoidal/issues/1009)`)
1819
+ return EXIT_FAILURE
1820
+ }
1552
1821
  const { listen, listenOnWorker, ServeHostError } = await import('./serve.mjs')
1553
1822
  const serveOptions = {
1554
1823
  port: servePort(options, 3030),
@@ -1590,12 +1859,25 @@ async function commandLwsServe (positional, options) {
1590
1859
  const insecure = options['insecure-no-auth'] === true
1591
1860
  const issuerOption = typeof options.issuer === 'string' ? options.issuer : null
1592
1861
  const authFile = typeof options['auth-config'] === 'string' ? options['auth-config'] : null
1593
- if (!insecure && issuerOption === null && authFile === null) {
1594
- throw new UsageError('lws-serve needs --issuer IRI or --auth-config FILE, ' +
1862
+ const authzDir = typeof options.authz === 'string' ? options.authz : null
1863
+ if (!insecure && issuerOption === null && authFile === null && authzDir === null) {
1864
+ throw new UsageError('lws-serve needs --authz DIR, --issuer IRI or --auth-config FILE, ' +
1595
1865
  'or --insecure-no-auth for development')
1596
1866
  }
1597
- if (insecure && (issuerOption !== null || authFile !== null)) {
1598
- throw new UsageError('--insecure-no-auth cannot be combined with --issuer or --auth-config')
1867
+ if (insecure && (issuerOption !== null || authFile !== null || authzDir !== null)) {
1868
+ throw new UsageError('--insecure-no-auth cannot be combined with --authz, --issuer or --auth-config')
1869
+ }
1870
+ // --authz DIR: an authorization server on the same port, its issuer the
1871
+ // storage root (L4Factoidal/Authz/Mount.lean). Its options are passed
1872
+ // unread; the engine checks them (Wasm/Ops/Authz.lean).
1873
+ const authz = authzDir === null ? undefined : {
1874
+ dir: authzDir,
1875
+ ...(typeof options.alg === 'string' ? { alg: options.alg } : {}),
1876
+ ...(options['token-lifetime'] !== undefined ? { tokenLifetime: natOption('token-lifetime', options['token-lifetime']) } : {}),
1877
+ ...(options['rotate-after'] !== undefined ? { rotateAfter: natOption('rotate-after', options['rotate-after']) } : {}),
1878
+ ...(options['allow-http'] === true ? { allowHttp: true } : {}),
1879
+ ...(options['allow-private-fetch'] === true ? { allowPrivateFetch: true } : {}),
1880
+ ...corsConfig(options)
1599
1881
  }
1600
1882
  let auth
1601
1883
  if (authFile !== null) {
@@ -1628,6 +1910,7 @@ async function commandLwsServe (positional, options) {
1628
1910
  ...(typeof options.storage === 'string' ? { storage: options.storage } : {}),
1629
1911
  ...corsConfig(options),
1630
1912
  ...(insecure ? { insecureNoAuth: true } : { auth }),
1913
+ ...(authz !== undefined ? { authz } : {}),
1631
1914
  ...(typeof options.owner === 'string' ? { owner: options.owner } : {}),
1632
1915
  ...(typeof options['root-acl'] === 'string'
1633
1916
  ? { rootAcl: new TextDecoder('utf-8', { fatal: true }).decode(readWhole(options['root-acl'])) }
@@ -1651,6 +1934,9 @@ async function commandLwsServe (positional, options) {
1651
1934
  }
1652
1935
  if (!quiet) {
1653
1936
  err(`lws-serve: listening on ${running.origin} for ${positional[0]}`)
1937
+ if (running.authz() !== null) err(`lws-serve: authorization server ${running.authz().issuer} (keys in ${authzDir})`)
1938
+ if (options['allow-http'] === true) err('lws-serve: WARNING: --allow-http: http subject identifiers are accepted')
1939
+ if (options['allow-private-fetch'] === true) err('lws-serve: WARNING: --allow-private-fetch: private addresses may be fetched')
1654
1940
  if (insecure) err('lws-serve: WARNING: --insecure-no-auth: every request is served without authorization')
1655
1941
  }
1656
1942
  out(running.origin)
@@ -1785,6 +2071,34 @@ async function commandSolidServe (positional, options) {
1785
2071
  return untilInterrupted(running, quiet)
1786
2072
  }
1787
2073
 
2074
+ // webdav-serve CONFIGDIR (https://github.com/danbri/factoidal/issues/1066):
2075
+ // the host carries sockets, files and fetches; the engine reads the mount
2076
+ // files and decides every answer (L4Factoidal/WebDAV/).
2077
+ async function commandWebdavServe (positional, options) {
2078
+ if (positional.length !== 1) throw new UsageError('webdav-serve needs one CONFIGDIR')
2079
+ const quiet = options.quiet === true
2080
+ const { listen, WebdavHostError } = await import('../webdav/server.mjs')
2081
+ let running
2082
+ try {
2083
+ running = await listen({ configDir: positional[0], port: servePort(options, 8090), host: serveHost(options) })
2084
+ } catch (error) {
2085
+ if (error instanceof WebdavHostError && error.unknownOp) return notWired('webdav-serve', error.message)
2086
+ if (error instanceof WebdavHostError || (error !== null && typeof error === 'object' && error.code === 'ENOENT')) {
2087
+ err(`factoidal webdav-serve: ${error.message}`)
2088
+ return EXIT_FAILURE
2089
+ }
2090
+ throw error
2091
+ }
2092
+ if (!quiet) {
2093
+ err(`webdav-serve: listening on ${running.origin} for ${positional[0]}`)
2094
+ for (const m of running.mounts) {
2095
+ err(`webdav-serve: ${running.origin}/${encodeURIComponent(m.name)}/ - ${m.title} (${m.kind}${m.writable ? '' : ', read-only'}${m.agent ? ', as ' + m.agent : ''})`)
2096
+ }
2097
+ }
2098
+ out(running.origin)
2099
+ return untilInterrupted(running, quiet)
2100
+ }
2101
+
1788
2102
  async function commandXmppServe (positional, options) {
1789
2103
  if (positional.length !== 1) throw new UsageError('xmpp-serve needs one DIR')
1790
2104
  const quiet = options.quiet === true
@@ -1965,7 +2279,8 @@ const COMMANDS = {
1965
2279
  'lws-client': commandLwsClient,
1966
2280
  'authz-serve': commandAuthzServe,
1967
2281
  'authz-keys': commandAuthzKeys,
1968
- 'xmpp-serve': commandXmppServe
2282
+ 'xmpp-serve': commandXmppServe,
2283
+ 'webdav-serve': commandWebdavServe
1969
2284
  }
1970
2285
 
1971
2286
  // ----------------------------------------------------------------- main
package/bin/pack-host.mjs CHANGED
@@ -42,11 +42,12 @@
42
42
  // route rescues an in-page packer. See the closing section of
43
43
  // `docs/designissues/2026-09-03-npm-pack-in-wasm.md`.
44
44
 
45
- import { StoreHostError, listGeneration, readWhole } from '../store-host/index.mjs'
45
+ import { StoreHostError, listGeneration, readWhole, writeNew } from '../store-host/index.mjs'
46
46
  import { fileUrlToPath, joinPath } from '../store-host/paths.mjs'
47
47
  import { loadEngine } from './engine.mjs'
48
48
  import { PackError, packFile, packSupported, verifyGeneration } from './pack.mjs'
49
49
  import { StoreOperationError, openStore } from './store.mjs'
50
+ import { sbm11Api } from '../lib/sbm11.mjs'
50
51
 
51
52
  const isDeno = typeof globalThis.Deno !== 'undefined'
52
53
 
@@ -207,6 +208,7 @@ export function isStackOverflow (error) {
207
208
  */
208
209
  export async function packHere (task, onProgress) {
209
210
  const engine = await loadEngine()
211
+ if (task.kind === 'packSbm11') return await packSbm11Here(engine, task)
210
212
  if (!packSupported(engine)) return { notWired: true }
211
213
  if (task.kind === 'activate') {
212
214
  return { report: activateHere(engine, task) }
@@ -221,6 +223,34 @@ export async function packHere (task, onProgress) {
221
223
  return { report }
222
224
  }
223
225
 
226
+ /**
227
+ * Pack one document into the files of one SBM11 generation (op
228
+ * sbm11PackFiles, through lib/sbm11.mjs packSbm11) and write each file
229
+ * under the name the engine gave it. The document is read whole: the op
230
+ * takes the whole document, and refuses one above its limit.
231
+ *
232
+ * @returns {{notWired: true}|{report: object}}
233
+ */
234
+ async function packSbm11Here (engine, task) {
235
+ const { packSbm11 } = sbm11Api(async () => engine)
236
+ const bytes = readWhole(task.input)
237
+ let files
238
+ try {
239
+ files = await packSbm11(bytes, { syntax: task.syntax, base: task.base })
240
+ } catch (error) {
241
+ if (error && error.code === 'SBM11_NO_PACK_OP') return { notWired: true }
242
+ throw error
243
+ }
244
+ const written = []
245
+ let bytesWritten = 0
246
+ for (const [name, fileBytes] of files) {
247
+ writeNew(joinPath(task.output, name), fileBytes)
248
+ written.push(name)
249
+ bytesWritten += fileBytes.length
250
+ }
251
+ return { report: { bytesRead: bytes.length, written, bytesWritten } }
252
+ }
253
+
224
254
  /**
225
255
  * Verify one generation and answer the engine's verdict.
226
256
  *