@factoidal/core 0.16.0 → 0.17.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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.17.0 (2026-10-27)
6
+
7
+ - **Breaking:** `canonicalHash(graph)` now returns the SHA-256 (64 lowercase hex digits) of the RDFC-1.0 canonical N-Quads, as the RDF Dataset Canonicalization specification uses it. Before, it returned the canonical text. Use `canonicalize()` for the text. The engine computes the digest in Lean (new op `datasetCanonicalHash`) (https://github.com/danbri/factoidal/issues/1040, https://github.com/danbri/factoidal-core/pull/127).
8
+ - SPARQL string functions follow SPARQL 1.1 §17.4.3 argument types: STRLEN, SUBSTR, UCASE, LCASE, STRSTARTS, STRENDS, CONTAINS, ENCODE_FOR_URI, MD5 and SHA* give a type error on a number, boolean or IRI argument (MD5/SHA* also on a language-tagged literal). STRSTARTS/STRENDS/CONTAINS check argument compatibility. SUBSTR follows `fn:substring`: `SUBSTR("12345", 0, 3)` is now `"12"` (was `"123"`) (https://github.com/danbri/factoidal/issues/1036, https://github.com/danbri/factoidal-core/pull/126).
9
+ - `factoidal pack --layout sbm11` writes an SBM11 generation: the same files as `l4sbm11-pack`, byte for byte (605 of 605 W3C census inputs). This layout also reads RDF/XML and JSON-LD. `factoidal activate`, `inspect` and `query` read SBM11 stores. `activate` checks the length, SHA-256 and Merkle root of every file before it moves `CURRENT`. `serve` does not read SBM11 stores yet. IBK5 stays the default layout. New wasm ops `storeLayout` and `sbm11Verify` (https://github.com/danbri/factoidal/issues/1009, https://github.com/danbri/factoidal-core/pull/131).
10
+ - Store open in Node is faster: on the londat store (142,314 quads, 1,046 blocks) `openStoreHandle` takes 562 ms, not 1485 ms (median of 7 runs, Apple M1, Node 22). The engine decodes the hexadecimal manifest over bytes, reads `JSON.stringify` arguments in place, verifies artifacts with HACL* SHA-256 in `storeOpen`, and decodes the arguments once per call. What remains is the manifest parse, which grows with the number of blocks; an SBM11 store opens in about 5 ms (https://github.com/danbri/factoidal/issues/1039, https://github.com/danbri/factoidal-core/pull/135).
11
+ - The chunked Turtle reader (the default `l4sbm11-pack` path and the source layer's split read) reads a subject such as `prefix:Atto`, `base:x` or `version:v` as a prefixed name. Before, it took the name for a `PREFIX`/`BASE`/`VERSION` directive and refused the document (QUDT-all-in-one-OWL.ttl) (https://github.com/danbri/factoidal/issues/1028, https://github.com/danbri/factoidal-core/pull/128).
12
+ - RML: a reference to a SPARQL result binding (`formats:SPARQL_Results_CSV`) keeps the literal's datatype when the term map states none. `"5"^^xsd:integer` gives `"5"^^xsd:integer`, not `"5"` (https://github.com/danbri/factoidal/issues/1034, https://github.com/danbri/factoidal-core/pull/129).
13
+ - Tests: 996 Apache Jena ARQ expression tests run as SPARQL on the package (`test/jena-arq-expr.test.mjs`). Each case that differs from Jena has a verdict: a Factoidal defect with an open issue (https://github.com/danbri/factoidal/issues/1041, https://github.com/danbri/factoidal/issues/1042, https://github.com/danbri/factoidal/issues/573), or a Jena extension or difference from the SPARQL specification (https://github.com/danbri/factoidal-core/pull/132). Graph names that are blank nodes: `test/graph-names-876.test.mjs` (https://github.com/danbri/factoidal/issues/876 part 2, fixed in 0.16.0 by https://github.com/danbri/factoidal-core/pull/75; https://github.com/danbri/factoidal-core/pull/130).
14
+ - Tests and release process: no npm test skips when the wasm lacks an op; each test file asserts the ops it needs, so a wasm without an op fails. The `serve --tdb2` test, which needs Apache Jena, moved to `npm run test:jena`. Core PR CI runs `tests/dataflow/run.sh` and `npm test`. `tools/npm-release-check.mjs` checks the version in package.json, package-lock.json and lib/version.mjs, and a dated changelog heading; the publish workflow runs it. `tools/fetch-builds.sh` warns when the installed release was built from a commit whose source differs from HEAD; `--strict` fails (https://github.com/danbri/factoidal/issues/1038, https://github.com/danbri/factoidal-core/pull/124, https://github.com/danbri/factoidal-core/pull/125).
15
+ - LWS: `factoidal lws-serve DIR --authz ASDIR` serves the storage and an authorization server on one port, the issuer the storage root without its trailing slash. The token endpoint and the JWK Set are under the storage root's `.well-known/` (`lws-token`, `lws-jwks`), so no storage resource name is taken from clients; the RFC 8414 metadata is at `/.well-known/lws-configuration` + the issuer's path. The engine decides which requests are the authorization server's (new op `authzOwns`, Lean `L4Factoidal/Authz/Mount.lean`, theorem `endpointOf?_mem`); the storage trusts the issuer with the JWK Set the engine publishes. JS: `listen({ authz: { dir, ... } })` from `@factoidal/core/lws/server`. `authzOpen` takes `mountedOn`, `tokenPath` and `jwksPath`, and answers `resources` and `jwks`.
16
+ - LWS and the authorization server read both forms of their well-known paths: the whole path (a client that reaches the server directly) and the path with the storage root's or issuer's path removed (a reverse proxy that serves the server at a path prefix and removes the prefix, as the router of Touchstone's `targetservers` does). Up to 0.16.0 the protected resource metadata named the wrong resource behind such a proxy (`L4Factoidal.LWS.resourceOfMetadataPath`). Test: `tests/authz/mounted.mjs`. Docker target for third-party testing: `deploy/lws-target/`.
17
+
5
18
  ## 0.16.0 (2026-10-26)
6
19
 
7
20
  - Node settings in the evidence (https://github.com/danbri/factoidal/issues/975). The params of `node()` are the node's settings. With a wasm module that has the new op `pipelineCheckNodes` (Lean `Wasm/Ops/PipelineNodes.lean`, `Dataflow/NodeSettings.lean`), each evidence record carries `settings` (canonical: keys sorted, defaults of absent settings that narrow the contract such as `lenient: false` and `entail: 'none'`, a Dataset value as `{nquadsSha256}`) and `settingsDigest` (`sha256:` and the hex SHA-256 of the canonical text). A setting outside the proved domain of a proved stage (a lenient reader, an evaluator with `entail` other than `none` or with `output`, a key the stage's settings table does not name) makes the node's status `unproved`, with `registryStatus` and `outside` (each setting and the reason) in the record, so `run()` reports `proved: false`. A reader's `format` must name the stage's syntax (`ttl12` selects RDF 1.2 mode, reported as `derived.mode`); an evaluator's query must parse, else `node()` rejects. Design record: `docs/designissues/2026-10-25-pipeline-settings-and-provenance.md`.
package/README.md CHANGED
@@ -8,14 +8,15 @@ npm install @factoidal/core
8
8
  ```
9
9
 
10
10
  ```js
11
- import { parse, csvwToRdf } from "@factoidal/core";
11
+ import { parse, csvwToRdf, canonicalHash } from "@factoidal/core";
12
12
 
13
13
  const ds = await parse('<http://x/a> <http://x/n> "A" .', { format: "ntriples" });
14
14
  const rows = await ds.query("SELECT ?n WHERE { ?s <http://x/n> ?n }");
15
15
  console.log(rows[0].get("n").value); // A
16
16
  console.log(ds.toString("turtle"));
17
17
  console.log(await ds.serialize({ format: "rdfxml" }));
18
- console.log(await ds.canonicalize()); // RDFC-1.0
18
+ console.log(await ds.canonicalize()); // RDFC-1.0 canonical N-Quads
19
+ console.log(await canonicalHash(ds)); // its SHA-256, 64 hex digits
19
20
 
20
21
  const table = await csvwToRdf("id,name\n1,a\n", "", { mode: "minimal", url: "http://x/t.csv" });
21
22
  console.log(table.size); // 2
package/authz/server.mjs CHANGED
@@ -22,6 +22,7 @@
22
22
  // authzFetched [handle, { url, status, body, now } | { host, addresses, now }] -> { ok }
23
23
  // authzKeys [handle, { nonce?, private? }] -> { ok, keys } | { ok, jwks }
24
24
  // authzRotate [handle, { now, random }] -> { ok, changed, kid }
25
+ // authzOwns [handle, target] -> { ok, owns }
25
26
  // authzClose [handle] -> { ok }
26
27
  //
27
28
  // FETCHES. `resolve` names a host: this host resolves it (`dns.lookup`, every
@@ -38,7 +39,7 @@ import { loadEngine } from '../bin/engine.mjs'
38
39
  import { storageKeyText } from '../lws/persist.mjs'
39
40
 
40
41
  /** The ops this host needs from the engine. */
41
- export const AUTHZ_OPS = ['authzOpen', 'authzStep', 'authzFetched', 'authzKeys', 'authzRotate', 'authzClose']
42
+ export const AUTHZ_OPS = ['authzOpen', 'authzStep', 'authzFetched', 'authzKeys', 'authzRotate', 'authzOwns', 'authzClose']
42
43
 
43
44
  /** The key file in DIR. */
44
45
  export const KEY_FILE = 'authz-keys.json'
@@ -174,12 +175,16 @@ export async function openAuthorizationServer (options = {}) {
174
175
  const engine = options.engine !== undefined ? options.engine : await loadEngine()
175
176
  const dir = typeof options.dir === 'string' ? options.dir : null
176
177
  const config = {
177
- issuer: options.issuer,
178
178
  resources: Array.isArray(options.resources) ? options.resources : [],
179
179
  now: nowSeconds(),
180
180
  entropy: randomBase64(64)
181
181
  }
182
- for (const name of ['alg', 'tokenLifetime', 'leeway', 'rotateAfter', 'docTtl']) {
182
+ // `mountedOn` (an LWS storage root that shares this issuer) or `issuer`:
183
+ // passed unread; the engine derives the issuer, the resource and the
184
+ // endpoint paths from `mountedOn` (L4Factoidal/Authz/Mount.lean).
185
+ if (typeof options.mountedOn === 'string') config.mountedOn = options.mountedOn
186
+ else config.issuer = options.issuer
187
+ for (const name of ['alg', 'tokenLifetime', 'leeway', 'rotateAfter', 'docTtl', 'tokenPath', 'jwksPath', 'audience']) {
183
188
  if (options[name] !== undefined) config[name] = options[name]
184
189
  }
185
190
  if (options.allowHttp === true) config.allowHttp = true
@@ -208,6 +213,15 @@ export async function openAuthorizationServer (options = {}) {
208
213
  engine,
209
214
  handle,
210
215
  issuer: opened.issuer,
216
+ /** The resources tokens may be issued for, as the engine set them. */
217
+ resources: opened.resources,
218
+ /** The published JWK Set when the handle was opened. */
219
+ jwks: opened.jwks,
220
+ /** Whether a request target is this server's (the engine decides:
221
+ * L4Factoidal.Authz.owns). */
222
+ owns (target) {
223
+ return engine.call('authzOwns', [handle, String(target)]).owns === true
224
+ },
211
225
  /**
212
226
  * One request.
213
227
  * @param {{method: string, target: string, headers: Array<[string, string]>, body: string}} request
package/bin/engine.mjs CHANGED
@@ -120,13 +120,8 @@ export function loadEngine (name = 'l4factoidal') {
120
120
  return promise
121
121
  }
122
122
 
123
- const HEX = (() => {
124
- const table = new Array(256)
125
- for (let byte = 0; byte < 256; byte += 1) {
126
- table[byte] = byte.toString(16).padStart(2, '0')
127
- }
128
- return table
129
- })()
123
+ const HEX_DIGIT = Uint8Array.from('0123456789abcdef', (c) => c.charCodeAt(0))
124
+ const HEX_DECODER = new TextDecoder()
130
125
 
131
126
  /**
132
127
  * Lowercase hexadecimal of a byte array. This is a transport encoding for
@@ -137,7 +132,14 @@ const HEX = (() => {
137
132
  * @returns {string}
138
133
  */
139
134
  export function hexOfBytes (bytes) {
140
- const parts = new Array(bytes.length)
141
- for (let index = 0; index < bytes.length; index += 1) parts[index] = HEX[bytes[index]]
142
- return parts.join('')
135
+ // One byte buffer of ASCII digits, decoded once: about 20 times faster
136
+ // than joining one string per byte for a 753 kB store manifest
137
+ // (https://github.com/danbri/factoidal/issues/1039).
138
+ const out = new Uint8Array(bytes.length * 2)
139
+ for (let index = 0, at = 0; index < bytes.length; index += 1, at += 2) {
140
+ const byte = bytes[index]
141
+ out[at] = HEX_DIGIT[byte >> 4]
142
+ out[at + 1] = HEX_DIGIT[byte & 15]
143
+ }
144
+ return HEX_DECODER.decode(out)
143
145
  }
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.
@@ -226,8 +233,11 @@ normally STORE/gen-N. Packing does not activate: run activate next.
226
233
  options:
227
234
  --layout LAYOUT ibk5 (quads, wire version 10: RDF 1.2 terms,
228
235
  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
236
+ (quads, wire version 9), ibk3 (triples) or sbm11
237
+ (the files l4sbm11-pack writes; the document is read
238
+ whole, in memory; serve does not read it yet)
239
+ --syntax SYNTAX turtle, trig or nquads, and with sbm11 also rdfxml or
240
+ jsonld; default from the file extension
231
241
  --chunk-bytes N Merkle chunk size; default is the engine's
232
242
  --batch-bytes N ibk4 only: source bytes between publication batches;
233
243
  default is the engine's. Blocks are published during
@@ -248,7 +258,10 @@ usage: factoidal activate STORE GENERATION [--json]
248
258
 
249
259
  Verifies every artifact of GENERATION and every cross-artifact relation,
250
260
  then atomically replaces STORE/CURRENT. A generation that fails
251
- verification never becomes current.`,
261
+ verification never becomes current. For an SBM11 generation the engine
262
+ checks the length, SHA-256 and Merkle root of every file and every
263
+ .merkle sidecar against the manifest (op sbm11Verify); it does not run
264
+ the decoder checks of l4sbm11 activate.`,
252
265
 
253
266
  update: `factoidal update - apply a SPARQL Update through the delta log
254
267
 
@@ -379,8 +392,10 @@ private keys (with d): for a conformance harness that mints test tokens
379
392
 
380
393
  'lws-serve': `factoidal lws-serve - serve Linked Web Storage 1.0 over HTTP
381
394
 
382
- usage: factoidal lws-serve DIR (--issuer IRI | --auth-config FILE | --insecure-no-auth)
395
+ usage: factoidal lws-serve DIR (--authz DIR | --issuer IRI | --auth-config FILE | --insecure-no-auth)
383
396
  [--owner WEBID] [--root-acl FILE] [--audience IRI]
397
+ [--alg ES256|EdDSA] [--token-lifetime SECONDS] [--rotate-after SECONDS]
398
+ [--allow-http] [--allow-private-fetch]
384
399
  [--port N] [--host ADDRESS] [--base IRI] [--page-size N]
385
400
  [--paginate-always] [--moves all|data|off]
386
401
  [--no-linkset-put] [--unstable-search-results] [--merkle]
@@ -494,8 +509,21 @@ It protects the file on disk only (formal/lean4/L4Factoidal/LWS/SnapshotSeal.lea
494
509
  --no-cors send no CORS fields; a page on another origin cannot read
495
510
  any answer
496
511
 
497
- Authorization (LWS editor's draft, section Authorization; one of the three
512
+ Authorization (LWS editor's draft, section Authorization; one of the four
498
513
  is required):
514
+ --authz DIR also serve an authorization server on this port, its
515
+ issuer the storage root without the trailing slash
516
+ (BASE less "/"): RFC 8414 metadata at
517
+ /.well-known/lws-configuration, the token endpoint at
518
+ BASE.well-known/lws-token, the JWK Set at
519
+ BASE.well-known/lws-jwks, signing keys in DIR (as
520
+ authz-serve; --alg, --token-lifetime, --rotate-after,
521
+ --allow-http and --allow-private-fetch as there). The
522
+ storage trusts it, and any --issuer too. Each request
523
+ is the authorization server's when the engine says so
524
+ (L4Factoidal/Authz/Mount.lean); every other is the
525
+ storage's. A proxy may serve BASE at a path prefix and
526
+ remove the prefix: both forms of every path are read.
499
527
  --issuer IRI trust this authorization server; several may be
500
528
  given separated by commas. Its metadata is read from
501
529
  IRI/.well-known/lws-configuration and its keys from
@@ -750,7 +778,7 @@ const VALUE_OPTIONS = {
750
778
  serve: new Set(['port', 'host', 'query-path', 'gsp-path', 'file', 'layout', 'tdb2']),
751
779
  'lws-serve': new Set(['port', 'host', 'base', 'issuer', 'auth-config', 'owner', 'root-acl',
752
780
  'audience', 'page-size', 'moves', 'controller-inbox', 'delivery-limit', 'delivery-window',
753
- 'redirects-max', 'storage', 'cors-origin']),
781
+ 'redirects-max', 'storage', 'cors-origin', 'authz', 'alg', 'token-lifetime', 'rotate-after']),
754
782
  'solid-serve': new Set(['port', 'host', 'base', 'owner', 'issuer', 'auth-config']),
755
783
  'solid-client': new Set(['body', 'file', 'content-type', 'interpret']),
756
784
  'lws-client': new Set(['body', 'file', 'content-type', 'slug', 'type', 'link', 'if-match',
@@ -931,14 +959,154 @@ function printTable (headers, rows) {
931
959
  for (const row of rows) out(line(row))
932
960
  }
933
961
 
962
+ // --------------------------------------------------------------- SBM11
963
+
964
+ let sbm11 = null
965
+
966
+ /** The SBM11 functions of lib/sbm11.mjs over this command's engine, with
967
+ * the Node directory reader as the default reader (it moves bytes only). */
968
+ function sbm11Functions () {
969
+ if (sbm11 === null) {
970
+ setDefaultSbm11Reader(nodeDefaultReaderFactory())
971
+ sbm11 = sbm11Api(loadEngine)
972
+ }
973
+ return sbm11
974
+ }
975
+
976
+ /**
977
+ * The engine's answer (op storeLayout) when `source` holds an SBM11
978
+ * generation, or names one through CURRENT: `{layout, generation,
979
+ * manifest}`. null for an IBK store, for a directory the op does not
980
+ * recognise, and for an engine built before the op; the IBK path then
981
+ * runs, and reports a missing store in its own words.
982
+ */
983
+ function sbm11Layout (engine, source) {
984
+ sbm11Functions()
985
+ let answer
986
+ try {
987
+ answer = engine.call('storeLayout', [source])
988
+ } catch (_error) {
989
+ return null
990
+ }
991
+ return answer && answer.layout === 'sbm11' ? answer : null
992
+ }
993
+
994
+ /** The directory a command reads: STORE, or STORE/GENERATION when
995
+ * --generation names one. */
996
+ function storeSource (root, named) {
997
+ return named === null ? root : joinPath(root, named)
998
+ }
999
+
1000
+ /** The engine's words of a refusal, without the loader's prefix. */
1001
+ function engineWords (error) {
1002
+ const message = error && error.message ? String(error.message) : String(error)
1003
+ return message.replace(/^l4factoidal:\s*/, '')
1004
+ }
1005
+
1006
+ async function inspectSbm11 (root, named, layout, options) {
1007
+ let store
1008
+ try {
1009
+ store = await sbm11Functions().openSbm11(storeSource(root, named))
1010
+ } catch (error) {
1011
+ err(`factoidal inspect: ${engineWords(error)}`)
1012
+ return EXIT_FAILURE
1013
+ }
1014
+ const terms = store.terms
1015
+ store.close()
1016
+ const generation = named !== null ? named : layout.generation
1017
+ const generationDir = named !== null || layout.generation === ''
1018
+ ? storeSource(root, named)
1019
+ : joinPath(root, layout.generation)
1020
+ const files = listGeneration(generationDir)
1021
+ let directoryBytes = 0
1022
+ for (const file of files) directoryBytes += file.size
1023
+ if (options.json) {
1024
+ out(JSON.stringify({
1025
+ ok: true,
1026
+ layout: 'sbm11',
1027
+ generation,
1028
+ activated: named === null && layout.generation !== '',
1029
+ manifest: layout.manifest,
1030
+ terms,
1031
+ files: files.length,
1032
+ bytes: directoryBytes
1033
+ }, null, 2))
1034
+ return EXIT_OK
1035
+ }
1036
+ out(`store ${root}`)
1037
+ if (named !== null) out(`generation ${generation} (named on the command line)`)
1038
+ else if (layout.generation !== '') out(`generation ${generation} (activated through CURRENT)`)
1039
+ else out('generation directory (no CURRENT)')
1040
+ out(`manifest ${layout.manifest}`)
1041
+ out('layout sbm11')
1042
+ out(plural(terms, 'term'))
1043
+ out(`generation directory holds ${files.length} files, ${directoryBytes} bytes`)
1044
+ return EXIT_OK
1045
+ }
1046
+
1047
+ /**
1048
+ * Answer a query on an SBM11 store (ops sbm11Open / sbm11Query /
1049
+ * sbm11Close) and print it as `query` prints an IBK answer. The engine
1050
+ * answers SPARQL Query Results JSON for SELECT and ASK and N-Triples for
1051
+ * CONSTRUCT and DESCRIBE; this only chooses which renderer prints it.
1052
+ */
1053
+ async function querySbm11 (engine, root, named, sparql, format, limit, options) {
1054
+ const quiet = options.quiet === true
1055
+ if (options.explain === true) {
1056
+ err('factoidal query: --explain prints the plan of an IBK store; there is ' +
1057
+ 'no plan operation for an SBM11 store yet ' +
1058
+ '(https://github.com/danbri/factoidal/issues/1009)')
1059
+ return EXIT_FAILURE
1060
+ }
1061
+ let answer
1062
+ try {
1063
+ const store = await sbm11Functions().openSbm11(storeSource(root, named))
1064
+ try {
1065
+ const got = store.query(sparql)
1066
+ // read the bytes while the handle is open
1067
+ answer = { path: got.path, format: got.format, reads: got.reads,
1068
+ bytesRead: got.bytesRead, text: got.result }
1069
+ } finally {
1070
+ store.close()
1071
+ }
1072
+ } catch (error) {
1073
+ err(`factoidal query: ${engineWords(error)}`)
1074
+ if (isStackOverflow(error)) {
1075
+ for (const line of stackLimitAdvice(STACK_REMEDY.query)) err(line)
1076
+ }
1077
+ return EXIT_FAILURE
1078
+ }
1079
+ if (!quiet) {
1080
+ err(`${answer.path}, ${plural(answer.reads, 'read')}, ${answer.bytesRead} bytes read`)
1081
+ }
1082
+ let result
1083
+ if (answer.format === 'srj') {
1084
+ const document = JSON.parse(answer.text)
1085
+ if (typeof document.boolean === 'boolean') {
1086
+ if (format === 'json') {
1087
+ out(JSON.stringify(document, null, 2))
1088
+ return EXIT_OK
1089
+ }
1090
+ result = { kind: 'ask', boolean: document.boolean }
1091
+ } else {
1092
+ result = { kind: 'select', srj: document }
1093
+ }
1094
+ } else {
1095
+ result = { kind: 'construct', nquads: answer.text }
1096
+ }
1097
+ return renderQueryResult(engine, result, format, limit, quiet)
1098
+ }
1099
+
934
1100
  // ------------------------------------------------------------- inspect
935
1101
 
936
1102
  async function commandInspect (positional, options) {
937
1103
  if (positional.length !== 1) throw new UsageError('inspect needs exactly one STORE')
938
1104
  const root = positional[0]
939
1105
  const named = typeof options.generation === 'string' ? options.generation : null
940
- const store = openStore(root, named)
941
1106
  const engine = await loadEngine()
1107
+ const layout = sbm11Layout(engine, storeSource(root, named))
1108
+ if (layout !== null) return inspectSbm11(root, named, layout, options)
1109
+ const store = openStore(root, named)
942
1110
  const envelope = inspectManifest(engine, store)
943
1111
 
944
1112
  if (options.json) {
@@ -1102,8 +1270,11 @@ async function commandQuery (positional, options) {
1102
1270
  const quiet = options.quiet === true
1103
1271
 
1104
1272
  const named = typeof options.generation === 'string' ? options.generation : null
1105
- const store = openStore(root, named)
1106
1273
  const engine = await loadEngine()
1274
+ if (sbm11Layout(engine, storeSource(root, named)) !== null) {
1275
+ return querySbm11(engine, root, named, sparql, format, limit, options)
1276
+ }
1277
+ const store = openStore(root, named)
1107
1278
 
1108
1279
  if (options.explain === true) {
1109
1280
  let plan
@@ -1208,9 +1379,15 @@ function readHead (path, length) {
1208
1379
  /** The syntax tag for an input: --syntax when given, else the engine's
1209
1380
  * suffix table (op engineTables), else the engine's content detection
1210
1381
  * (op detectFormat) on the first 64 KiB of the file. */
1211
- function packSyntax (engine, input, options) {
1382
+ function packSyntax (engine, input, options, layout) {
1212
1383
  const tables = engine.call('engineTables', []).tables
1213
- const syntaxes = tables.packSyntaxes
1384
+ // The SBM11 pack reads the whole document (op sbm11PackFiles), so it also
1385
+ // reads the syntaxes of packWholeSuffixes (RDF/XML, JSON-LD).
1386
+ const whole = layout === 'sbm11' ? (tables.packWholeSuffixes ?? {}) : {}
1387
+ const syntaxes = tables.packSyntaxes.slice()
1388
+ for (const syntax of Object.values(whole)) {
1389
+ if (syntaxes.indexOf(syntax) < 0) syntaxes.push(syntax)
1390
+ }
1214
1391
  if (typeof options.syntax === 'string') {
1215
1392
  const syntax = options.syntax.toLowerCase()
1216
1393
  if (syntaxes.indexOf(syntax) >= 0) return syntax
@@ -1220,11 +1397,14 @@ function packSyntax (engine, input, options) {
1220
1397
  for (const [suffix, syntax] of Object.entries(tables.packSuffixes)) {
1221
1398
  if (lower.endsWith(suffix)) return syntax
1222
1399
  }
1400
+ for (const [suffix, syntax] of Object.entries(whole)) {
1401
+ if (lower.endsWith(suffix)) return syntax
1402
+ }
1223
1403
  for (const [suffix, name] of Object.entries(tables.packUnsupportedSuffixes)) {
1224
1404
  if (lower.endsWith(suffix)) {
1225
1405
  throw new UsageError(
1226
- `pack does not read ${name}. The packer's streaming fold reads ` +
1227
- `${syntaxes.join(', ')} only. Convert the file first, for ` +
1406
+ `pack does not read ${name}. The packer reads ` +
1407
+ `${syntaxes.join(', ')} only${layout === 'sbm11' ? '' : ' (--layout sbm11 also reads rdfxml and jsonld)'}. Convert the file first, for ` +
1228
1408
  "example with: factoidal parse FILE --out nquads")
1229
1409
  }
1230
1410
  }
@@ -1253,8 +1433,11 @@ function packSyntax (engine, input, options) {
1253
1433
  * `--base ''` asks for no base, which turns a relative IRI into a parse
1254
1434
  * error rather than a silently different term.
1255
1435
  */
1256
- function packBase (input, options) {
1436
+ function packBase (input, options, layout) {
1257
1437
  if (typeof options.base === 'string') return options.base
1438
+ // `l4sbm11-pack` uses no base unless --base names one; the same default
1439
+ // gives the same bytes.
1440
+ if (layout === 'sbm11') return ''
1258
1441
  const absolute = input.startsWith('/') ? input : joinPath(currentDirectory(), input)
1259
1442
  return 'file://' + absolute
1260
1443
  }
@@ -1275,8 +1458,8 @@ function packLayout (options) {
1275
1458
  async function commandPack (positional, options) {
1276
1459
  if (positional.length !== 2) throw new UsageError('pack needs INPUT and OUTPUT')
1277
1460
  const [input, output] = positional
1278
- const syntax = packSyntax(await loadEngine(), input, options)
1279
1461
  const layout = packLayout(options)
1462
+ const syntax = packSyntax(await loadEngine(), input, options, layout)
1280
1463
  // The pack fold needs a bigger call stack than either runtime gives by
1281
1464
  // default (https://github.com/danbri/factoidal/issues/649). Under Node
1282
1465
  // the work runs on a worker thread with a raised stack; under Deno the
@@ -1291,8 +1474,10 @@ async function commandPack (positional, options) {
1291
1474
  let answer
1292
1475
  try {
1293
1476
  answer = await runPack(
1294
- { kind: 'pack', input, output, syntax, layout, base: packBase(input, options),
1295
- batchBytes: options['batch-bytes'] },
1477
+ layout === 'sbm11'
1478
+ ? { kind: 'packSbm11', input, output, syntax, base: packBase(input, options, layout) }
1479
+ : { kind: 'pack', input, output, syntax, layout, base: packBase(input, options, layout),
1480
+ batchBytes: options['batch-bytes'] },
1296
1481
  quiet
1297
1482
  ? undefined
1298
1483
  : (progress) => {
@@ -1331,7 +1516,7 @@ async function commandPack (positional, options) {
1331
1516
  return EXIT_OK
1332
1517
  }
1333
1518
  out(`packed ${report.bytesRead} bytes of ${syntax} into ${output}`)
1334
- out(`${report.written.length} artifacts, ${report.bytesWritten} bytes, layout ${layout}`)
1519
+ out(`${report.written.length} ${layout === 'sbm11' ? 'files' : 'artifacts'}, ${report.bytesWritten} bytes, layout ${layout}`)
1335
1520
  if (typeof report.rows === 'number') out(`${plural(report.rows, 'row')}`)
1336
1521
  out(`activate it with: factoidal activate ${dirOf(output)} ${nameOf(output)}`)
1337
1522
  return EXIT_OK
@@ -1382,6 +1567,10 @@ async function commandActivate (positional, options) {
1382
1567
  const host = { worker: options['no-worker'] !== true }
1383
1568
  const reexec = await denoReexec(host)
1384
1569
  if (reexec !== null) return reexec
1570
+ const engine = await loadEngine()
1571
+ if (sbm11Layout(engine, joinPath(root, generation)) !== null) {
1572
+ return activateSbm11(engine, root, generation, options)
1573
+ }
1385
1574
  let answer
1386
1575
  try {
1387
1576
  answer = await runPack({ kind: 'activate', root, generation }, undefined, host)
@@ -1425,6 +1614,32 @@ async function commandActivate (positional, options) {
1425
1614
  return EXIT_OK
1426
1615
  }
1427
1616
 
1617
+ /**
1618
+ * Activate an SBM11 generation: the engine checks every file against the
1619
+ * manifest (op sbm11Verify), and only after its "ok" does CURRENT move.
1620
+ */
1621
+ function activateSbm11 (engine, root, generation, options) {
1622
+ let verdict
1623
+ try {
1624
+ verdict = engine.call('sbm11Verify', [joinPath(root, generation)])
1625
+ } catch (error) {
1626
+ err(`factoidal activate: ${engineWords(error)}`)
1627
+ err('The generation is NOT activated; CURRENT is unchanged.')
1628
+ return EXIT_FAILURE
1629
+ }
1630
+ const synced = atomicReplace(joinPath(root, 'CURRENT'), new TextEncoder().encode(generation))
1631
+ if (options.json === true) {
1632
+ out(JSON.stringify({ ...verdict, layout: 'sbm11', generation, directorySynced: synced }, null, 2))
1633
+ return EXIT_OK
1634
+ }
1635
+ out(`activated ${generation}: ${verdict.artifacts} artifacts verified (${verdict.files} files, ${verdict.bytes} bytes), layout sbm11`)
1636
+ if (!synced) {
1637
+ err('CURRENT was replaced, but the directory entry was not synced; a ' +
1638
+ 'crash now could lose the pointer update.')
1639
+ }
1640
+ return EXIT_OK
1641
+ }
1642
+
1428
1643
  function commandUpdate (positional, options) {
1429
1644
  if (positional.length < 1) throw new UsageError('update needs a STORE')
1430
1645
  const text = positional.length > 1
@@ -1529,6 +1744,10 @@ async function commandServe (positional, options) {
1529
1744
  const { join } = await import('node:path')
1530
1745
  const root = mkdtempSync(join(tmpdir(), 'factoidal-serve-'))
1531
1746
  const packOptions = { quiet: true, layout: packLayout(options) }
1747
+ if (packOptions.layout === 'sbm11') {
1748
+ throw new UsageError('serve --file packs a store that accepts updates; --layout sbm11 ' +
1749
+ 'packs a store that serve does not read yet. Use ibk3, ibk4 or ibk5.')
1750
+ }
1532
1751
  let rc = await commandPack([options.file, join(root, 'gen-1')], packOptions)
1533
1752
  if (rc !== 0) return rc
1534
1753
  rc = await commandActivate([root, 'gen-1'], { quiet: true })
@@ -1549,6 +1768,12 @@ async function commandServe (positional, options) {
1549
1768
  options = { ...options, 'query-path': `${dsName}/sparql`, 'gsp-path': `${dsName}/data` }
1550
1769
  }
1551
1770
  if (positional.length !== 1) throw new UsageError('serve needs one STORE')
1771
+ if (!tdb2 && sbm11Layout(await loadEngine(), positional[0]) !== null) {
1772
+ err(`factoidal serve: ${positional[0]} is an SBM11 store, and serve reads IBK ` +
1773
+ 'stores only (the storeServe operations). Query it with: factoidal query ' +
1774
+ `${positional[0]} QUERY (https://github.com/danbri/factoidal/issues/1009)`)
1775
+ return EXIT_FAILURE
1776
+ }
1552
1777
  const { listen, listenOnWorker, ServeHostError } = await import('./serve.mjs')
1553
1778
  const serveOptions = {
1554
1779
  port: servePort(options, 3030),
@@ -1590,12 +1815,25 @@ async function commandLwsServe (positional, options) {
1590
1815
  const insecure = options['insecure-no-auth'] === true
1591
1816
  const issuerOption = typeof options.issuer === 'string' ? options.issuer : null
1592
1817
  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, ' +
1818
+ const authzDir = typeof options.authz === 'string' ? options.authz : null
1819
+ if (!insecure && issuerOption === null && authFile === null && authzDir === null) {
1820
+ throw new UsageError('lws-serve needs --authz DIR, --issuer IRI or --auth-config FILE, ' +
1595
1821
  'or --insecure-no-auth for development')
1596
1822
  }
1597
- if (insecure && (issuerOption !== null || authFile !== null)) {
1598
- throw new UsageError('--insecure-no-auth cannot be combined with --issuer or --auth-config')
1823
+ if (insecure && (issuerOption !== null || authFile !== null || authzDir !== null)) {
1824
+ throw new UsageError('--insecure-no-auth cannot be combined with --authz, --issuer or --auth-config')
1825
+ }
1826
+ // --authz DIR: an authorization server on the same port, its issuer the
1827
+ // storage root (L4Factoidal/Authz/Mount.lean). Its options are passed
1828
+ // unread; the engine checks them (Wasm/Ops/Authz.lean).
1829
+ const authz = authzDir === null ? undefined : {
1830
+ dir: authzDir,
1831
+ ...(typeof options.alg === 'string' ? { alg: options.alg } : {}),
1832
+ ...(options['token-lifetime'] !== undefined ? { tokenLifetime: natOption('token-lifetime', options['token-lifetime']) } : {}),
1833
+ ...(options['rotate-after'] !== undefined ? { rotateAfter: natOption('rotate-after', options['rotate-after']) } : {}),
1834
+ ...(options['allow-http'] === true ? { allowHttp: true } : {}),
1835
+ ...(options['allow-private-fetch'] === true ? { allowPrivateFetch: true } : {}),
1836
+ ...corsConfig(options)
1599
1837
  }
1600
1838
  let auth
1601
1839
  if (authFile !== null) {
@@ -1628,6 +1866,7 @@ async function commandLwsServe (positional, options) {
1628
1866
  ...(typeof options.storage === 'string' ? { storage: options.storage } : {}),
1629
1867
  ...corsConfig(options),
1630
1868
  ...(insecure ? { insecureNoAuth: true } : { auth }),
1869
+ ...(authz !== undefined ? { authz } : {}),
1631
1870
  ...(typeof options.owner === 'string' ? { owner: options.owner } : {}),
1632
1871
  ...(typeof options['root-acl'] === 'string'
1633
1872
  ? { rootAcl: new TextDecoder('utf-8', { fatal: true }).decode(readWhole(options['root-acl'])) }
@@ -1651,6 +1890,9 @@ async function commandLwsServe (positional, options) {
1651
1890
  }
1652
1891
  if (!quiet) {
1653
1892
  err(`lws-serve: listening on ${running.origin} for ${positional[0]}`)
1893
+ if (running.authz() !== null) err(`lws-serve: authorization server ${running.authz().issuer} (keys in ${authzDir})`)
1894
+ if (options['allow-http'] === true) err('lws-serve: WARNING: --allow-http: http subject identifiers are accepted')
1895
+ if (options['allow-private-fetch'] === true) err('lws-serve: WARNING: --allow-private-fetch: private addresses may be fetched')
1654
1896
  if (insecure) err('lws-serve: WARNING: --insecure-no-auth: every request is served without authorization')
1655
1897
  }
1656
1898
  out(running.origin)
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
  *
package/index.d.ts CHANGED
@@ -529,14 +529,15 @@ export function canonicalize(
529
529
  export function graphs(dataset: Dataset): Array<[name: NamedNode | BlankNode, graph: Dataset]>;
530
530
 
531
531
  /**
532
- * RDFC-1.0 canonical form of a single graph -- the graph-scoped
533
- * sibling of canonicalize(). It returns the canonical N-Quads TEXT, not
534
- * a digest: hash the string yourself (for example `vcSha256Hex`) when a
535
- * fixed-length value is needed. Every quad's graph component is dropped
536
- * before canonicalizing, so isomorphic graphs (including under
537
- * blank-node relabeling) give the same string regardless of what
538
- * graph name they were extracted from. Typically called with one
539
- * entry of graphs()'s output.
532
+ * The RDFC-1.0 canonical hash of a single graph: the SHA-256 of its
533
+ * canonical N-Quads document, as 64 lowercase hexadecimal digits (RDF
534
+ * Dataset Canonicalization 1.0, section 4.4 note 2). The engine computes
535
+ * the canonical form and the digest. Every quad's graph component is
536
+ * dropped first, so isomorphic graphs (including under blank-node
537
+ * relabeling) give the same hash whatever graph name they were extracted
538
+ * from. Typically called with one entry of graphs()'s output. For the
539
+ * canonical text, use canonicalize(). Rejects when the input holds an
540
+ * RDF 1.2 triple term or the section 4.4 call budget is exceeded.
540
541
  */
541
542
  export function canonicalHash(datasetOrGraph: Dataset): Promise<string>;
542
543
 
@@ -37,7 +37,7 @@
37
37
  // bytes change.
38
38
 
39
39
  // Stamped by formal/lean4/Wasm/build-wasm.sh step 9 -- do not hand-edit.
40
- const WASM_VERSION = "1b10444e4416";
40
+ const WASM_VERSION = "ebac38a482d0";
41
41
 
42
42
  import createModule from './l4factoidal.mjs';
43
43
 
Binary file
@@ -2,17 +2,17 @@
2
2
  "engine": "lean4",
3
3
  "note": "The Lean 4 (L4Factoidal) engine build provenance and claims: the only engine of @factoidal/core since 0.9.0 (issue 713). This committed file is a placeholder. tools/fetch-builds.sh --set web (through tools/stamp-l4-version.mjs) writes the provenance members from the danbri/factoidal-builds release that built the wasm; formal/lean4/Wasm/build-wasm.sh step 9 writes them for a local build. Do not commit a stamped copy.",
4
4
  "stamped": true,
5
- "release": "danbri/factoidal-builds build-0069da82efd7-web",
6
- "releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/40281606727",
7
- "gitSha": "0069da82efd75cc3bfe10785ef8853bd84931957",
8
- "builtAt": "2026-10-26T08:11:18Z",
5
+ "release": "danbri/factoidal-builds build-fa72ba5bc828-web",
6
+ "releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/40439252690",
7
+ "gitSha": "fa72ba5bc828c1eb75572c3436e9d739f76fdd57",
8
+ "builtAt": "2026-10-27T03:13:16Z",
9
9
  "leanToolchain": "leanprover/lean4:v4.33.1",
10
10
  "emscripten": "6.0.8",
11
11
  "abiVersion": "1",
12
12
  "engineVersion": "l4factoidal-wasm 0.2.0 (dispatch: RDF parse/serialize, SPARQL query+update, RDFS/OWL-RL closures, RDFC-1.0, CL/IKL)",
13
13
  "version": "l4factoidal-wasm 0.2.0 (dispatch: RDF parse/serialize, SPARQL query+update, RDFS/OWL-RL closures, RDFC-1.0, CL/IKL)",
14
- "wasmSha256": "1b10444e44160b8689ebd5375c074509196930b1927ce4c9fbc829a73d9b58ac",
15
- "wasmBytes": 17034490,
14
+ "wasmSha256": "ebac38a482d03d4a6ce1cf4a59164288628fed6957483dd55180743e7a3daf40",
15
+ "wasmBytes": 17071472,
16
16
  "claims": {
17
17
  "source": "formal/lean4 (L4Factoidal): no sorry, no user axioms, no native_decide; W3C behaviour pinned by build-time #guard",
18
18
  "suitesAtBuildSha": "see docs/test-results and formal/lean4/PORT_NOTES.md at gitSha"
package/lib/api.mjs CHANGED
@@ -884,9 +884,12 @@ function buildApi(driver) {
884
884
  }
885
885
 
886
886
  /**
887
- * RDFC-1.0 canonical N-Quads of one graph: every quad's graph name is
888
- * dropped first (op datasetGraphAsDefault with the union of the graphs).
889
- * Text, not a digest.
887
+ * The RDFC-1.0 canonical hash of one graph: the SHA-256, as 64 lowercase
888
+ * hexadecimal digits, of the canonical N-Quads document (RDF Dataset
889
+ * Canonicalization 1.0, section 4.4 note 2). Every quad's graph name is
890
+ * dropped first (op datasetGraphAsDefault with the union of the graphs);
891
+ * then op datasetCanonicalHash computes the canonical form and the digest
892
+ * in Lean. canonicalize() answers the canonical text.
890
893
  * @param {Dataset} datasetOrGraph
891
894
  * @returns {Promise<string>}
892
895
  */
@@ -897,10 +900,11 @@ function buildApi(driver) {
897
900
  datasetOrGraph.size; // throws the disposed error
898
901
  const c = datasetOrGraph._ctx;
899
902
  requireEntryFn(c.e, 'datasetGraphAsDefault', 'canonicalHash');
903
+ requireEntryFn(c.e, 'datasetCanonicalHash', 'canonicalHash');
900
904
  const merged = c.wrap(entryResult(c.e.datasetGraphAsDefault(datasetOrGraph.handle,
901
905
  JSON.stringify({ type: 'union' })), 'canonicalHash'), {});
902
906
  try {
903
- return merged.toString('rdfc');
907
+ return entryResult(c.e.datasetCanonicalHash(merged.handle), 'canonicalHash').hash;
904
908
  } finally {
905
909
  merged.dispose();
906
910
  }
package/lib/store.mjs CHANGED
@@ -99,16 +99,18 @@ export function manifestKeys(inspectEnvelope) {
99
99
  return keys;
100
100
  }
101
101
 
102
- const HEX = (() => {
103
- const t = new Array(256);
104
- for (let b = 0; b < 256; b += 1) t[b] = b.toString(16).padStart(2, '0');
105
- return t;
106
- })();
102
+ const HEX_DIGIT = Uint8Array.from('0123456789abcdef', (c) => c.charCodeAt(0));
103
+ const HEX_DECODER = new TextDecoder();
107
104
 
108
105
  /** Lowercase hexadecimal: the transport the manifest argument of the store
109
106
  * ops takes. Artifact bytes never take this path. */
110
107
  export function hexOfBytes(bytes) {
111
- const parts = new Array(bytes.length);
112
- for (let i = 0; i < bytes.length; i += 1) parts[i] = HEX[bytes[i]];
113
- return parts.join('');
108
+ // One byte buffer of ASCII digits, decoded once (issue 1039; see
109
+ // bin/engine.mjs hexOfBytes).
110
+ const out = new Uint8Array(bytes.length * 2);
111
+ for (let i = 0, at = 0; i < bytes.length; i += 1, at += 2) {
112
+ out[at] = HEX_DIGIT[bytes[i] >> 4];
113
+ out[at + 1] = HEX_DIGIT[bytes[i] & 15];
114
+ }
115
+ return HEX_DECODER.decode(out);
114
116
  }
package/lib/version.mjs CHANGED
@@ -2,4 +2,4 @@
2
2
  // needs import attributes, which not every runtime this package targets
3
3
  // reads). test/dts-drift.test.js checks that it equals package.json's
4
4
  // `version`.
5
- export const version = '0.16.0';
5
+ export const version = '0.17.0';
package/lws/server.mjs CHANGED
@@ -584,11 +584,33 @@ export async function createLwsServer (options = {}) {
584
584
  let settings = { ...options }
585
585
  let opening = null
586
586
  let opened = null
587
+ let authz = null
587
588
 
588
- /** The storage handle, opened once. */
589
+ /** The storage handle, opened once. With `authz` (an object of
590
+ * `openAuthorizationServer` options), an authorization server that shares
591
+ * the storage's issuer is opened first, on `mountedOn: baseIri`; the
592
+ * storage then trusts that issuer with the JWK Set the engine published
593
+ * (L4Factoidal/Authz/Mount.lean). */
589
594
  function storage () {
590
595
  if (opening === null) {
591
- opening = openLwsStorage(settings).then((value) => { opened = value; return value })
596
+ opening = (async () => {
597
+ let storageSettings = settings
598
+ if (settings.authz !== undefined && settings.authz !== null) {
599
+ if (typeof settings.baseIri !== 'string') throw new LwsHostError('authz needs baseIri')
600
+ const { openAuthorizationServer } = await import('../authz/server.mjs')
601
+ authz = await openAuthorizationServer({
602
+ ...settings.authz,
603
+ ...(settings.engine !== undefined ? { engine: settings.engine } : {}),
604
+ ...(settings.auth && typeof settings.auth.audience === 'string' ? { audience: settings.auth.audience } : {}),
605
+ mountedOn: settings.baseIri
606
+ })
607
+ const issuers = [{ issuer: authz.issuer, jwks: authz.jwks }]
608
+ const prior = settings.auth && Array.isArray(settings.auth.issuers) ? settings.auth.issuers : []
609
+ storageSettings = { ...settings, auth: { ...(settings.auth || {}), issuers: issuers.concat(prior) } }
610
+ }
611
+ opened = await openLwsStorage(storageSettings)
612
+ return opened
613
+ })()
592
614
  }
593
615
  return opening
594
616
  }
@@ -610,7 +632,13 @@ export async function createLwsServer (options = {}) {
610
632
  }
611
633
  let answer
612
634
  try {
613
- answer = await (await storage()).step(record)
635
+ const store = await storage()
636
+ // One port for both servers: the engine names the authorization
637
+ // server's paths (L4Factoidal.Authz.owns); every other request is the
638
+ // storage's. The token request body is form text.
639
+ answer = authz !== null && authz.owns(record.target)
640
+ ? await authz.step({ ...record, body: new TextDecoder('utf-8').decode(record.body) })
641
+ : await store.step(record)
614
642
  } catch (error) {
615
643
  // A failure of this host or of the engine, not a protocol answer.
616
644
  // The engine's own words are kept; nothing is invented.
@@ -627,6 +655,8 @@ export async function createLwsServer (options = {}) {
627
655
  return {
628
656
  server,
629
657
  storage,
658
+ /** The authorization server sharing the storage's issuer, or null. */
659
+ authz () { return authz },
630
660
  /** Set the storage root's absolute IRI, before the first request. */
631
661
  setBaseIri (value) {
632
662
  if (opening !== null) {
@@ -646,6 +676,7 @@ export async function createLwsServer (options = {}) {
646
676
  return new Promise((resolve) => {
647
677
  server.close(() => {
648
678
  if (opened !== null) opened.close()
679
+ if (authz !== null) authz.close()
649
680
  resolve()
650
681
  })
651
682
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "RDF parsing and writing (Turtle, TriG, N-Triples, N-Quads, RDF/XML, JSON-LD), SPARQL 1.1 and 1.2, RDFS and OWL reasoning, SHACL and ShEx validation, CSVW and RML mapping, RDFC-1.0, and SPARQL, LWS and Solid servers, for Node, Deno and browsers.",
5
5
  "keywords": [
6
6
  "sparql",
@@ -142,6 +142,7 @@
142
142
  "scripts": {
143
143
  "build": "bash ../../formal/lean4/Wasm/build-wasm.sh",
144
144
  "test": "node --test 'test/*.test.js' 'test/*.test.mjs'",
145
+ "test:jena": "node --test 'test/jena/*.test.mjs'",
145
146
  "test:speed": "node ../../tests/perf/npm_speed_gate.mjs",
146
147
  "test:costs": "node ../../tests/perf/npm_costs.mjs",
147
148
  "prepublishOnly": "npm test"