@factoidal/core 0.31.1 → 0.33.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/index.d.ts CHANGED
@@ -240,7 +240,7 @@ export type WriteFormat =
240
240
  | 'rdfxml' | 'rdf-xml' | 'rdf' | 'jsonld' | 'json-ld' | 'rdfc' | 'canonical';
241
241
 
242
242
  /** Results formats of query() with `output`. */
243
- export type ResultsFormat = 'json' | 'xml' | 'csv' | 'tsv' | 'table' | 'ntriples';
243
+ export type ResultsFormat = 'json' | 'xml' | 'xml-report' | 'csv' | 'tsv' | 'table' | 'ntriples';
244
244
 
245
245
  /** One row of formats(). */
246
246
  export interface FormatCapability {
@@ -1570,10 +1570,14 @@ export type Sbm11Transport = (url: string, headers: Record<string, string>) =>
1570
1570
  * null before transport and appear in errors().
1571
1571
  * Browser worker default XHR accepts no caller auth/headers; provide an explicit
1572
1572
  * trusted transport for those. Its final-URL check cannot prevent an already
1573
- * sent anonymous request or ambient same-origin browser cookies. */
1573
+ * sent anonymous request or ambient same-origin browser cookies.
1574
+ * Asynchronous: the engine's op containerPath (Lean, L4Factoidal.LWS.ContainerPath)
1575
+ * decides the container URL and every resource URL. */
1574
1576
  export function lwsSbm11Reader(url: string, opts?: {
1575
- auth?: string; headers?: Record<string, string>; transport?: Sbm11Transport; timeoutMs?: number
1576
- }): Sbm11Reader & { evidence(): Sbm11ReadEvidence[]; errors(): string[]; close(): void };
1577
+ auth?: string; headers?: Record<string, string>; transport?: Sbm11Transport; timeoutMs?: number;
1578
+ /** a loaded engine handle (default: loadEngine()); its op containerPath decides every URL */
1579
+ engine?: object
1580
+ }): Promise<Sbm11Reader & { evidence(): Sbm11ReadEvidence[]; errors(): string[]; close(): void }>;
1577
1581
 
1578
1582
  /** One query against a store: open with the query's plan, query, close. */
1579
1583
  export function queryStore(
@@ -2260,13 +2264,22 @@ export interface SparqlResultsJson {
2260
2264
  boolean?: boolean;
2261
2265
  }
2262
2266
 
2267
+ /** The exact serialized result is checked by the Lean XML parser. This is
2268
+ * parser admission, not a proof of SPARQL serialization fidelity or XSLT success.
2269
+ * Invalid RDF/XML characters are refused, never removed or replaced.
2270
+ */
2271
+ export type SparqlXmlReport =
2272
+ | { kind: 'xml'; xml: string }
2273
+ | { kind: 'diagnostic'; code: 'XML_ADMISSION_REFUSED'; message: string };
2274
+
2263
2275
  export type OutputFormat =
2264
- | 'json' | 'csv' | 'tsv' | 'xml' | 'table' | 'ntriples';
2276
+ | 'json' | 'csv' | 'tsv' | 'xml' | 'xml-report' | 'table' | 'ntriples';
2265
2277
 
2266
2278
  /**
2267
2279
  * One call, serialised answer: parse `dataString`, close the default
2268
2280
  * graph under `entail`, run the query. Resolves to parsed SPARQL Results
2269
- * JSON when output is 'json' (default), the text otherwise. 'csv', 'tsv'
2281
+ * JSON when output is 'json' (default), an XML/diagnostic object for
2282
+ * 'xml-report', and text otherwise. 'csv', 'tsv'
2270
2283
  * and 'xml' are the W3C SPARQL results formats (CSV and TSV have no ASK
2271
2284
  * encoding, so an ASK with those rejects); 'table' and 'ntriples' are
2272
2285
  * display formats. A CONSTRUCT or DESCRIBE answer is a graph and takes
@@ -2280,7 +2293,7 @@ export function queryRaw(
2280
2293
  entail?: EntailRegime;
2281
2294
  output?: OutputFormat;
2282
2295
  }
2283
- ): Promise<SparqlResultsJson | string>;
2296
+ ): Promise<SparqlResultsJson | SparqlXmlReport | string>;
2284
2297
 
2285
2298
  /** Package version string, e.g. '0.9.0'. */
2286
2299
  export const version: string;
package/index.mjs CHANGED
@@ -103,12 +103,15 @@ export { registerSbm11Source, mapReader };
103
103
  // A reader of an SBM11 generation held as LWS resources (lib/sbm11-lws.mjs;
104
104
  // https://github.com/danbri/factoidal/issues/1010 KR3). In Node the default
105
105
  // synchronous transport is a fetch in a worker thread (lib/sync-http-node.mjs),
106
- // stopped by the reader's close().
107
- export function lwsSbm11Reader(url, opts = {}) {
108
- if (typeof opts.transport === 'function') return lwsSbm11ReaderWith(url, opts);
106
+ // stopped by the reader's close(). Asynchronous: it loads the engine, whose
107
+ // op containerPath decides the container URL and every resource URL
108
+ // (formal/lean4/L4Factoidal/LWS/ContainerPath.lean).
109
+ export async function lwsSbm11Reader(url, opts = {}) {
110
+ const engine = opts.engine ?? await loadEngine();
111
+ if (typeof opts.transport === 'function') return lwsSbm11ReaderWith(url, { ...opts, engine });
109
112
  const transport = nodeSyncTransport({ timeoutMs: opts.timeoutMs });
110
113
  try {
111
- return lwsSbm11ReaderWith(url, { ...opts, transport, onClose: () => transport.close() });
114
+ return lwsSbm11ReaderWith(url, { ...opts, engine, transport, onClose: () => transport.close() });
112
115
  } catch (error) {
113
116
  transport.close();
114
117
  throw error;
@@ -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 = "745308558206";
40
+ const WASM_VERSION = "7f617da4ecbe";
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-72a8fcf2e2a4-web",
6
- "releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/42071487615",
7
- "gitSha": "72a8fcf2e2a4bd88ce730f2420fcbc8a094ee0ce",
8
- "builtAt": "2026-11-05T18:45:52Z",
5
+ "release": "danbri/factoidal-builds build-31be9833264a-web",
6
+ "releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/42304532396",
7
+ "gitSha": "31be9833264a2289526a8c9fcc1d6b67610cf746",
8
+ "builtAt": "2026-11-06T23:08:01Z",
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": "7453085582064ea77fd61dedcaeab0bc9cb94a234aa2484a1cddd7beb9ac3342",
15
- "wasmBytes": 31370655,
14
+ "wasmSha256": "7f617da4ecbe7352b94f361802e12bb785f401aafbf78f92b6031c8e920011f2",
15
+ "wasmBytes": 32611669,
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
@@ -693,7 +693,7 @@ function buildApi(driver) {
693
693
  }
694
694
  const r = await withExtensionRounds(() => entryResult(e.datasetQueryRaw(ds.handle, sparql,
695
695
  JSON.stringify({ entail, output })), 'query', sparql));
696
- return output === 'json' && !graphForm ? JSON.parse(r.text) : r.text;
696
+ return (output === 'json' || output === 'xml-report') && !graphForm ? JSON.parse(r.text) : r.text;
697
697
  }
698
698
  const fn = sparql12 ? e.datasetQuery12 : e.datasetQuery;
699
699
  if (typeof fn !== 'function') {
@@ -2487,7 +2487,9 @@ function buildApi(driver) {
2487
2487
  * One call, serialised answer (op queryRaw): parse `dataString`, close
2488
2488
  * the default graph under `entail`, run the query, and write the answer
2489
2489
  * in `output`: 'json' (default; parsed SPARQL results JSON), or 'csv',
2490
- * 'tsv', 'xml', 'table', 'ntriples' (text). A CONSTRUCT or DESCRIBE
2490
+ * 'tsv', 'xml', 'table', 'ntriples' (text), or 'xml-report' (a checked
2491
+ * XML/diagnostic object from Lean). Ordinary 'xml' rejects an XML
2492
+ * admission failure; 'xml-report' returns it as data. A CONSTRUCT or DESCRIBE
2491
2493
  * answer is a graph: 'ntriples' or 'table' only.
2492
2494
  * @param {string} dataString
2493
2495
  * @param {string} queryString
@@ -2511,7 +2513,7 @@ function buildApi(driver) {
2511
2513
  const r = entryResult(e.queryRaw(dataString, queryString,
2512
2514
  JSON.stringify({ dataFormat: tables.rawDataFormats[dataFormat], entail, output })),
2513
2515
  'queryRaw', queryString);
2514
- return output === 'json' ? JSON.parse(r.text) : r.text;
2516
+ return output === 'json' || output === 'xml-report' ? JSON.parse(r.text) : r.text;
2515
2517
  }
2516
2518
 
2517
2519
  // ---------------------------------------------------------------
package/lib/sbm11-lws.mjs CHANGED
@@ -40,40 +40,23 @@
40
40
  // thread has neither: pass `transport` or run the engine in a worker.
41
41
 
42
42
  // This reader grants its transport authority only within one HTTP container.
43
- // A file is a raw relative filename/path, never a URL reference. Reject both
44
- // literal traversal and encoded structural characters before attaching headers.
45
- function containerUrl (url) {
46
- let base
47
- try { base = new URL(String(url)) } catch { throw new TypeError('lwsSbm11Reader: expected an absolute HTTP(S) container URL') }
48
- if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || /[?#]/.test(base.href)) {
49
- throw new TypeError('lwsSbm11Reader: container must be HTTP(S), without userinfo, query or fragment; use opts.auth/headers')
43
+ // A file is a raw relative filename/path, never a URL reference. The engine
44
+ // decides the container URL and every resource URL (op containerPath,
45
+ // formal/lean4/L4Factoidal/LWS/ContainerPath.lean: containerOf, resourceIn,
46
+ // theorem resourceIn_under) before any header is attached; this file only
47
+ // turns a refusal into a TypeError.
48
+ function containerUrl (engine, url) {
49
+ const r = engine.call('containerPath', [JSON.stringify({ kind: 'container', url: String(url) })])
50
+ if (!r.allowed) {
51
+ throw new TypeError(`lwsSbm11Reader: ${r.reason}; the container must be an absolute HTTP(S) URL without userinfo, query or fragment (use opts.auth/headers)`)
50
52
  }
51
- if (!base.pathname.endsWith('/')) base.pathname += '/'
52
- return base
53
+ return r.url
53
54
  }
54
55
 
55
- function resourceUrl (base, file) {
56
- if (typeof file !== 'string' || file.length === 0 || file.length > 4096) {
57
- throw new TypeError('invalid relative resource path')
58
- }
59
- let probe = file
60
- for (;;) {
61
- if (/^[a-z][a-z0-9+.-]*:/i.test(probe) || /[\\?#\x00-\x1f\x7f]/.test(probe) ||
62
- probe.split('/').some(part => part === '' || part === '.' || part === '..') ||
63
- /%(?:2f|5c|3f|23)/i.test(probe)) {
64
- throw new TypeError('resource path must remain inside the configured container')
65
- }
66
- // Decode only for rejection, never for the requested filename. Each changed
67
- // pass shortens the string, so nested percent encodings are also bounded.
68
- const next = probe.replace(/%([a-f0-9]{2})/gi, (_match, hex) => String.fromCharCode(parseInt(hex, 16)))
69
- if (next === probe) break
70
- probe = next
71
- }
72
- const resource = new URL(file.split('/').map(encodeURIComponent).join('/'), base)
73
- if (resource.origin !== base.origin || !resource.pathname.startsWith(base.pathname)) {
74
- throw new TypeError('resource URL escaped the configured container')
75
- }
76
- return resource.href
56
+ function resourceUrl (engine, base, file) {
57
+ const r = engine.call('containerPath', [JSON.stringify({ kind: 'resource', container: base, path: file })])
58
+ if (!r.allowed) throw new TypeError(`resource path must remain inside the configured container: ${r.reason}`)
59
+ return r.url
77
60
  }
78
61
 
79
62
  /** A synchronous GET with XMLHttpRequest (a browser worker). */
@@ -85,7 +68,8 @@ export function xhrSyncTransport (url, headers) {
85
68
  x.send()
86
69
  // XHR follows redirects internally. This rejects returned bytes, not the
87
70
  // already-sent request. Caller headers require an explicit trusted transport.
88
- if (x.responseURL !== new URL(url).href) throw new Error('LWS reader: redirected XHR resource refused')
71
+ // `url` is the engine's serialization, so any other response URL is a redirect.
72
+ if (x.responseURL !== url) throw new Error('LWS reader: redirected XHR resource refused')
89
73
  return {
90
74
  status: x.status,
91
75
  header: (name) => x.getResponseHeader(name),
@@ -110,14 +94,20 @@ function unsatisfiedTotal (value) {
110
94
  *
111
95
  * @param {string} url absolute HTTP(S), no userinfo/query/fragment; container that holds CURRENT and the generation
112
96
  * (or the generation container itself)
113
- * @param {object} [opts]
97
+ * @param {object} opts
98
+ * @param {object} opts.engine a loaded engine handle (`call(op, args)`); its op
99
+ * containerPath decides the container URL and every resource URL
114
100
  * @param {Function} [opts.transport] trusted synchronous GET; must constrain redirect/header forwarding
115
101
  * @param {string} [opts.auth] a value for the Authorization header, such as
116
102
  * `Bearer <access token>` (an LWS access token)
117
103
  * @param {object} [opts.headers] further request headers; browser use requires explicit trusted transport
118
104
  */
119
105
  export function lwsSbm11Reader (url, opts = {}) {
120
- const base = containerUrl(url)
106
+ const engine = opts.engine
107
+ if (!engine || typeof engine.call !== 'function') {
108
+ throw new TypeError('lwsSbm11Reader: opts.engine (a loaded engine handle) decides the resource URLs')
109
+ }
110
+ const base = containerUrl(engine, url)
121
111
  const extra = { ...(opts.headers || {}) }
122
112
  if (opts.auth) extra.authorization = String(opts.auth)
123
113
  let transport = opts.transport
@@ -137,7 +127,7 @@ export function lwsSbm11Reader (url, opts = {}) {
137
127
  let e = files.get(file)
138
128
  if (!e) {
139
129
  let url
140
- try { url = resourceUrl(base, file) } catch (error) {
130
+ try { url = resourceUrl(engine, base, file) } catch (error) {
141
131
  fail(file, error.message)
142
132
  return null
143
133
  }
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.31.1';
5
+ export const version = '0.33.0';
package/lws/server.mjs CHANGED
@@ -24,6 +24,16 @@
24
24
  // lwsFetched [handle, documentJson] -> { ok, fetch? | installed? | refused? }
25
25
  // lwsDelivered [handle, reportJson] -> { ok, changed, retry? }
26
26
  // lwsFlush [handle, { now? }] -> { ok, changed, deliveries?, wake? }
27
+ // lwsEgress [handle, { url, addresses }] -> { ok, host, address } | { ok, refused }
28
+ //
29
+ // REQUESTS TO PRIVATE ADDRESSES (https://github.com/danbri/factoidal/issues/1126)
30
+ // Just before each delivery this host resolves the name the delivery names
31
+ // (`resolve`, every address), passes the addresses to `lwsEgress`, and
32
+ // connects to the address the answer names, with no second resolution
33
+ // (`postTo`). A refused delivery is not made; it is reported to
34
+ // `lwsDelivered` with `refused: true`. Which addresses are refused (the
35
+ // IANA special-purpose ranges) and the opt-in `allowPrivateFetch` are
36
+ // decided by `L4Factoidal/Net/Egress.lean` and `Notifications.egressOf`.
27
37
  //
28
38
  // RATE LIMIT AND INBOX CHECK (https://github.com/danbri/factoidal/issues/915)
29
39
  // An answer may carry `wake: SECONDS`: deliveries wait for a rate-limit
@@ -87,7 +97,7 @@ import { loadEngine } from '../bin/engine.mjs'
87
97
  import { readSnapshot, persistIfChanged, storageKeyText, storageModeText, loadFiles, persistFilesIfChanged, readStepBodies, acquireDirLock, atRestNotice } from './persist.mjs'
88
98
 
89
99
  /** The ops this host needs from the engine. */
90
- export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched', 'lwsDelivered',
100
+ export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched', 'lwsDelivered', 'lwsEgress',
91
101
  'lwsLoad', 'lwsPersist', 'lwsRead', 'lwsDirLock', 'lwsAtRestNotice']
92
102
 
93
103
  /** How long this host waits for the answer to one delivery request before
@@ -128,6 +138,59 @@ async function resolveHost (host) {
128
138
  }
129
139
  }
130
140
 
141
+ /** The most response bytes this host reads from an inbox; the rest is
142
+ * dropped (the engine reads only the status). */
143
+ const MAX_DELIVERY_ANSWER_BYTES = 65536
144
+
145
+ /**
146
+ * POST one delivery to the address the engine chose (`lwsEgress`). Pure
147
+ * I/O: the connection goes to `address` only (the lookup answers it, so the
148
+ * name is not resolved again), TLS still checks the certificate against the
149
+ * URL's host name, redirects are not followed. The status, or 0 when no
150
+ * answer came in time.
151
+ */
152
+ async function postTo (url, address, headers, body) {
153
+ let target
154
+ try {
155
+ target = new URL(url)
156
+ } catch (_error) {
157
+ return 0
158
+ }
159
+ const module = target.protocol === 'https:' ? await import('node:https') : await import('node:http')
160
+ const family = address.indexOf(':') >= 0 ? 6 : 4
161
+ const fields = {}
162
+ for (const pair of Array.isArray(headers) ? headers : []) {
163
+ if (Array.isArray(pair) && pair.length === 2) fields[String(pair[0])] = String(pair[1])
164
+ }
165
+ const bytes = Buffer.from(typeof body === 'string' ? body : '', 'utf8')
166
+ fields['content-length'] = String(bytes.length)
167
+ return new Promise((done) => {
168
+ let finished = false
169
+ const finish = (status) => { if (!finished) { finished = true; done(status) } }
170
+ const request = module.request(target, {
171
+ method: 'POST',
172
+ headers: fields,
173
+ lookup: (_host, options, callback) => {
174
+ if (options && options.all) callback(null, [{ address, family }])
175
+ else callback(null, address, family)
176
+ },
177
+ timeout: DELIVERY_TIMEOUT_MS
178
+ }, (response) => {
179
+ let size = 0
180
+ response.on('data', (chunk) => {
181
+ size += chunk.length
182
+ if (size > MAX_DELIVERY_ANSWER_BYTES) response.destroy()
183
+ })
184
+ response.on('end', () => finish(response.statusCode))
185
+ response.on('close', () => finish(response.statusCode))
186
+ response.on('error', () => finish(response.statusCode))
187
+ })
188
+ request.on('timeout', () => request.destroy(new Error('timeout')))
189
+ request.on('error', () => finish(0))
190
+ request.end(bytes)
191
+ })
192
+ }
193
+
131
194
  /** The addresses of this machine's network interfaces. Pure I/O. */
132
195
  async function interfaceAddresses () {
133
196
  try {
@@ -323,6 +386,11 @@ export async function openLwsStorage (options = {}) {
323
386
  // engine checks and decides.
324
387
  if (typeof options.deliveryLimit === 'number') config.deliveryLimit = options.deliveryLimit
325
388
  if (typeof options.deliveryWindow === 'number') config.deliveryWindow = options.deliveryWindow
389
+ // The limit per destination and the opt-in for private and other
390
+ // special-purpose delivery targets (issue 1126): passed unread; the
391
+ // engine decides (L4Factoidal/Net/Egress.lean, Notifications.admitDest).
392
+ if (typeof options.deliveryDestLimit === 'number') config.deliveryDestLimit = options.deliveryDestLimit
393
+ if (options.allowPrivateFetch === true) config.allowPrivateFetch = true
326
394
  if (typeof options.redirectsMax === 'number') config.redirectsMax = options.redirectsMax
327
395
  if (typeof options.controllerInbox === 'string') config.controllerInbox = options.controllerInbox
328
396
  // CORS (https://github.com/danbri/factoidal/issues/923): `cors` is false or
@@ -440,22 +508,22 @@ export async function openLwsStorage (options = {}) {
440
508
  function deliver (delivery) {
441
509
  const job = (async () => {
442
510
  let status = 0
443
- try {
444
- const answer = await fetch(delivery.url, {
445
- method: 'POST',
446
- headers: Array.isArray(delivery.headers) ? delivery.headers : [],
447
- body: delivery.body,
448
- redirect: 'manual',
449
- signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS)
450
- })
451
- status = answer.status
452
- await answer.arrayBuffer()
453
- } catch (_error) {
454
- status = 0
511
+ // Resolve now, let the engine choose the address (issue 1126), and
512
+ // connect to that address only.
513
+ const addresses = typeof delivery.resolve === 'string' ? await resolveHost(delivery.resolve) : []
514
+ if (closed) return
515
+ const decided = engine.call('lwsEgress', [handle, JSON.stringify({ url: delivery.url, addresses })])
516
+ const refused = decided !== null && typeof decided === 'object' && typeof decided.refused === 'string'
517
+ ? decided.refused
518
+ : null
519
+ if (refused !== null) {
520
+ console.error(`lws-serve: delivery to ${delivery.url} not made: ${refused}`)
521
+ } else {
522
+ status = await postTo(delivery.url, String(decided.address), delivery.headers, delivery.body)
455
523
  }
456
524
  if (closed) return
457
525
  const told = engine.call('lwsDelivered', [handle, JSON.stringify({
458
- id: delivery.id, status, ...clockNow()
526
+ id: delivery.id, status, ...(refused !== null ? { refused: true } : {}), ...clockNow()
459
527
  })])
460
528
  persist(told)
461
529
  const retry = told !== null && typeof told === 'object' ? told.retry : null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.31.1",
3
+ "version": "0.33.0",
4
4
  "description": "EXPERIMENTAL UNDER-REVIEWED CODE: do not run public services on it without careful independent expert review. 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",
@@ -35,7 +35,10 @@
35
35
  "browser": "./browser.js",
36
36
  "types": "./index.d.ts",
37
37
  "bin": {
38
- "factoidal": "./bin/factoidal.mjs"
38
+ "factoidal": "./bin/factoidal.mjs",
39
+ "foafnaut": "./bin/foafnaut.mjs",
40
+ "foafnaut-latest": "./bin/foafnaut-latest.mjs",
41
+ "foafnaut-bleedingedge": "./bin/foafnaut-bleedingedge.mjs"
39
42
  },
40
43
  "exports": {
41
44
  ".": {
package/webdav/server.mjs CHANGED
@@ -186,6 +186,13 @@ export async function openWebdav (options = {}) {
186
186
  }
187
187
  // The guest user (issue 1147), passed unread; the engine checks it.
188
188
  if (typeof options.guest === 'string') login.guest = options.guest
189
+ // HTTP over XMPP (issue 1125): the transport, the account and, when the
190
+ // operator gave one, the XMPP server's address; the engine decides.
191
+ if (options.transport === 'xmpp') {
192
+ login.transport = 'xmpp'
193
+ if (typeof options.jid === 'string') login.jid = options.jid
194
+ if (typeof options.host !== 'string') delete login.host
195
+ }
189
196
  const config = { listing, entropy: randomBase64(32), now: nowSeconds(), login }
190
197
  // The Host and Origin guard of the HTTP listener (issue 1124): the bound
191
198
  // address and the operator's names and origins, passed unread
@@ -257,6 +264,9 @@ export async function openWebdav (options = {}) {
257
264
  randomBase64: randomBase64(16),
258
265
  remoteAddress: typeof request.remoteAddress === 'string' ? request.remoteAddress : ''
259
266
  }
267
+ // Over XMPP: the requester the XMPP client handle named (the sender's
268
+ // bare JID), passed unread (https://github.com/danbri/factoidal/issues/1128).
269
+ if (request.requester !== undefined && request.requester !== null) record.requester = request.requester
260
270
  let got = blobCall(engine, 'webdavStep', [handle, JSON.stringify(record)], request.body || new Uint8Array(0))
261
271
  for (let hop = 0; hop < MAX_HOPS; hop += 1) {
262
272
  const env = got.envelope
@@ -294,6 +304,8 @@ export async function openWebdav (options = {}) {
294
304
  engine,
295
305
  mounts: ready.mounts || [],
296
306
  warnings: ready.warnings || [],
307
+ /** The engine's `ready.login`: a users file is loaded. */
308
+ login: ready.login === true,
297
309
  /** The start notices of the local mounts (a stale lock taken over,
298
310
  * encryption at rest on or off), for standard error. */
299
311
  storageNotices,
package/xmpp/README.md CHANGED
@@ -66,6 +66,7 @@ mkdir -p state && printf 'juliet:r0m30\nromeo:juli3t\n' > state/accounts
66
66
  npx factoidal xmpp-serve state --domain localhost # plaintext on 5222
67
67
  npx factoidal xmpp-serve state --cert fullchain.pem --key privkey.pem # XEP-0368 direct TLS on 5223
68
68
  npx factoidal xmpp-serve state --cert fullchain.pem --key privkey.pem --starttls # STARTTLS required on 5222
69
+ npx factoidal xmpp-serve state --cert fullchain.pem --key privkey.pem --starttls --tls-port 5223 # both, one server
69
70
  ```
70
71
 
71
72
  Without `--cert` and `--key` the listener has no TLS and offers PLAIN, so
@@ -99,7 +100,8 @@ process per connection behind socat) serves; the wasm session ops are
99
100
  | `xmppClose` | `[server]` | `{ ok, sessions }` |
100
101
 
101
102
  The state directory is laid out as the native carrier's, so the two
102
- hosts can share one: `DIR/accounts` (one `user:password` per line),
103
+ hosts can share one: `DIR/accounts` (one `user:password` per line, or a line with no
104
+ password from `factoidal xmpp-serve --verifier NAME`, for SCRAM only),
103
105
  `DIR/roster/<bare jid>.tsv` (persisted on every `rostersSaved`, loaded
104
106
  on start), and `DIR/spool/<bare jid>/<file>` — a stanza written there
105
107
  by any process is delivered to that account's bound session and
@@ -129,8 +131,9 @@ RFC 6120 section 5 STARTTLS with `startTls: true` (op `xmppSessionTlsUp`;
129
131
  the engine decides, the host does the handshake; see the theorems in
130
132
  `formal/lean4/L4Factoidal/XMPP/Server.lean`).
131
133
  Not: server-to-server federation, MUC/MIX/GC3 rooms, PRECIS.
132
- Known limitation shared with the native harness: one SCRAM salt per
133
- server (`saltB64` in the config), not one per account.
134
+ Each name has its own SCRAM salt, from a seed the host gives the engine
135
+ (`saltSeed`; derived from the state key when there is one). The native
136
+ harness and a fixed `saltB64` keep one salt for all.
134
137
 
135
138
  The shared engine currently reduces routed destinations to bare JIDs and
136
139
  the Node host delivers to every bound resource. It does not implement full
@@ -191,3 +194,27 @@ node --test test/xmpp.test.mjs
191
194
  Skips wholesale when the Lean wasm assets aren't resolvable, and skips
192
195
  per-op when the resolved bundle predates a given op (see
193
196
  `xmppOpsAvailable`) — the same pattern `test/l4-core.test.js` uses.
197
+
198
+ ## Transport limits
199
+
200
+ The host closes a socket when a limit is reached. These are limits on bytes and time; every protocol decision stays in Lean.
201
+
202
+ | Option | CLI | Default | Limit |
203
+ |---|---|---|---|
204
+ | `maxConnections` | `--max-connections N` | 1024 | Open connections of all kinds |
205
+ | `loginTimeoutMs` | `--login-timeout SECONDS` | 30 s | Time to bind a resource |
206
+ | `maxQueuedBytes` | | 8 MiB | Bytes waiting for a client that is not reading |
207
+ | `maxBuffered` | | 1 MiB | Bytes of an unfinished unit |
208
+
209
+ There is no connection limit per address: behind a proxy, every client has the same one.
210
+
211
+ ## Limits the engine applies
212
+
213
+ | Option | CLI | Default | Limit |
214
+ |---|---|---|---|
215
+ | `heldLimits.perAccount`, `.perAccountBytes` | | 256 stanzas, 4 MiB | Held for one offline account |
216
+ | `heldLimits.total`, `.totalBytes` | | 10,000 stanzas, 64 MiB | Held on the server |
217
+ | `heldLimits.senderPerWindow`, `.senderWindow` | | 60 in 60 s | Stanzas one account may have held; roster writes of one account |
218
+ | `addressLimit: false` | `--no-address-limit` | on | Failed logins per client address (5 in 60 s, 20 in an hour). Turn it off behind a proxy, where every client has one address |
219
+
220
+ Fixed in the engine: 5 failed logins in 60 s per account name, 3 failed attempts on a stream, 1,000 items in a roster, 64 levels and 8,192 elements in a stanza, and room nicks of at most 64 bytes.
package/xmpp/client.mjs CHANGED
@@ -196,7 +196,9 @@ async function openStream (engine, config, options, onFeed, onClosed) {
196
196
  * @param {string} [options.resource]
197
197
  * @param {object[]} [options.mounts] the WebDAV handle's mounts, passed unread (the engine refuses writable local mounts without the opt-in)
198
198
  * @param {boolean} [options.dangerouslyAllowWritableWithoutLogin]
199
- * @param {Function} options.step `async ({method, target, headers, body}) => ({status, headers, body})`
199
+ * @param {boolean} [options.login] the WebDAV handle has a users file (its `ready.login`, passed unread)
200
+ * @param {number} [options.tickMs] how often the clock is given to the engine (default 1000)
201
+ * @param {Function} options.step `async ({method, target, headers, body, requester}) => ({status, headers, body})`
200
202
  * @param {object} [options.engine]
201
203
  * @param {Function} [options.onError]
202
204
  * @param {number} [options.maxBuffered] residual bytes the engine may hold before the socket is closed (default 16 MiB)
@@ -211,6 +213,7 @@ export async function connectHttpTarget (options = {}) {
211
213
  config.allow = Array.isArray(options.allow) ? options.allow : []
212
214
  if (Array.isArray(options.mounts)) config.mounts = options.mounts
213
215
  if (options.dangerouslyAllowWritableWithoutLogin === true) config.dangerouslyAllowWritableWithoutLogin = true
216
+ if (options.login === true) config.login = true
214
217
 
215
218
  let stream = null
216
219
  /** One request the engine named: carried to `step`, answered. */
@@ -221,7 +224,10 @@ export async function connectHttpTarget (options = {}) {
221
224
  method: request.method,
222
225
  target: request.target,
223
226
  headers: request.headers || [],
224
- body: new Uint8Array(Buffer.from(request.bodyBase64 || '', 'base64'))
227
+ body: new Uint8Array(Buffer.from(request.bodyBase64 || '', 'base64')),
228
+ // The sender's bare JID as the engine named it (issue 1128),
229
+ // passed unread to the WebDAV handle's login.
230
+ requester: request.requester
225
231
  })
226
232
  answer = {
227
233
  status: response.status,
@@ -243,11 +249,20 @@ export async function connectHttpTarget (options = {}) {
243
249
  for (const r of answer.requests || []) carry(r)
244
250
  })
245
251
  const jid = await stream.bound
252
+ // The clock for the time limit of requests that wait for chunks
253
+ // (https://github.com/danbri/factoidal/issues/1128); the engine decides.
254
+ const timer = setInterval(() => {
255
+ if (!stream.isOpen()) return
256
+ try {
257
+ stream.write(call(engine, 'xmppClientTick', [stream.handle, String(Date.now())]).send)
258
+ } catch (_error) { /* handle gone */ }
259
+ }, typeof options.tickMs === 'number' ? options.tickMs : 1000)
260
+ stream.closed.then(() => clearInterval(timer))
246
261
  return {
247
262
  jid,
248
263
  writableWithoutLogin: stream.opened.writableWithoutLogin || [],
249
264
  closed: stream.closed,
250
- close: () => stream.close()
265
+ close: () => { clearInterval(timer); return stream.close() }
251
266
  }
252
267
  }
253
268
 
package/xmpp/inpage.mjs CHANGED
@@ -53,6 +53,8 @@ export async function openInPageXmppServer (options = {}) {
53
53
  for (const k of ['domain', 'mucDomain', 'mixDomain', 'gc3Domain', 'saltB64']) {
54
54
  if (typeof options[k] === 'string') config[k] = options[k]
55
55
  }
56
+ if (typeof config.saltB64 !== 'string') config.saltSeed = randomToken()
57
+ config.occupantSeed = randomToken()
56
58
  if (Array.isArray(options.accounts)) config.accounts = options.accounts
57
59
  if (typeof options.iterationCount === 'number') config.iterationCount = options.iterationCount
58
60
  const opened = engine.call('xmppOpen', [JSON.stringify(config)])
@@ -106,7 +108,11 @@ export async function openInPageXmppServer (options = {}) {
106
108
  send (message) {
107
109
  if (!open) throw new Error('the in-page XMPP connection is closed')
108
110
  let answer
109
- try { answer = engine.call('xmppMsgFeed', [session, String(message)]) } catch (error) { onError(error); end(); return }
111
+ try {
112
+ // The rate limits read the host's clock.
113
+ engine.call('xmppClock', [handle, String(Math.floor(Date.now() / 1000))])
114
+ answer = engine.call('xmppMsgFeed', [session, String(message)])
115
+ } catch (error) { onError(error); end(); return }
110
116
  emit(answer.send || [])
111
117
  router.apply(c, answer)
112
118
  if (answer.close === true) end()