@factoidal/core 0.28.0 → 0.30.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.
@@ -0,0 +1,160 @@
1
+ // An LWS storage as an RDF dataset (https://github.com/danbri/factoidal/issues/1011,
2
+ // version 2): a table-of-contents graph named by the storage description IRI
3
+ // (the containment as lws:items, the listing members, the link from each
4
+ // resource to its linkset), one named graph per RDF document, one named graph
5
+ // per linkset, and the entity tag of every document and linkset read.
6
+ // Design record: docs/designissues/2026-10-25-lws-dataset-view.md
7
+ //
8
+ // WHAT THIS FILE IS ALLOWED TO DO
9
+ // Loop. Start at the storage root; ask the LWS client (lws/client.mjs) to
10
+ // list a container; give the engine the listing and the container IRI (op
11
+ // lwsViewPlan), which answers which members to list, which to read and whose
12
+ // linksets to read; read those with the client (read, readLinkset); give the
13
+ // engine every listing, read and linkset read (op lwsDatasetView), which
14
+ // answers the dataset as N-Quads with the evidence and the name of the
15
+ // table-of-contents graph; open that text as a Dataset. Every decision is
16
+ // Lean: which members are containers, which media types have an RDF reader
17
+ // and which resources have a linkset to read
18
+ // (L4Factoidal/LWS/DatasetViewRead.lean), the entity tag and the graph of
19
+ // each read, the links of each linkset, the storage description IRI, and the
20
+ // view itself (L4Factoidal/LWS/DatasetView.lean). This file reads no header,
21
+ // no link and chooses no media type.
22
+ //
23
+ // Limits (stated in API.md): a full crawl on every open; one HEAD and one GET
24
+ // per resource for its linkset; no search index; no caching; the snapshot is
25
+ // a sequence of GETs, not one read of the storage.
26
+
27
+ import { loadEngine } from '../bin/engine.mjs'
28
+ import { parse } from '../index.mjs'
29
+ import { createLwsClient, LwsClientError } from './client.mjs'
30
+ import { FactoidalError } from '../lib/errors.mjs'
31
+
32
+ /** The engine ops this host needs, beyond the client's. */
33
+ export const LWS_DATASET_OPS = ['lwsViewPlan', 'lwsDatasetView']
34
+
35
+ function isUnknownOp (error) {
36
+ const text = error && error.message ? String(error.message) : String(error)
37
+ return text.indexOf('unknown op') >= 0
38
+ }
39
+
40
+ /**
41
+ * Whether the loaded engine carries the dataset-view ops.
42
+ *
43
+ * @param {object} engine the handle from `loadEngine()`
44
+ * @returns {Promise<{available: boolean, reason: string}>}
45
+ */
46
+ export async function lwsDatasetOpsAvailable (engine) {
47
+ try {
48
+ const plan = engine.call('lwsViewPlan', ['{}'])
49
+ if (!plan || !Array.isArray(plan.metadata)) {
50
+ return {
51
+ available: false,
52
+ reason: 'the loaded l4factoidal WebAssembly module carries version 1 of lwsViewPlan ' +
53
+ '(no metadata member; rebuild formal/lean4/Wasm)'
54
+ }
55
+ }
56
+ return { available: true, reason: 'lwsViewPlan answered' }
57
+ } catch (error) {
58
+ if (isUnknownOp(error)) {
59
+ return {
60
+ available: false,
61
+ reason: 'the loaded l4factoidal WebAssembly module does not carry lwsViewPlan ' +
62
+ '(rebuild formal/lean4/Wasm)'
63
+ }
64
+ }
65
+ return { available: true, reason: 'lwsViewPlan answered with an engine error' }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Crawl an LWS storage and open it as a Dataset.
71
+ *
72
+ * @param {string} storageRootUrl the URL of the storage root container
73
+ * @param {object} [options]
74
+ * @param {Function} [options.fetch] the fetch to use (default `globalThis.fetch`)
75
+ * @param {object} [options.auth] passed to `createLwsClient`:
76
+ * `{ subjectToken, subjectTokenType }`, `{ selfIssued }` or `{ session }`
77
+ * @param {object} [options.engine] an engine handle to reuse
78
+ * @param {number} [options.maxContainers] stop with an error after listing
79
+ * this many containers (default 10000)
80
+ * @returns {Promise<object>} `{ dataset, toc, evidence, skipped, graphs,
81
+ * query(sparql, options), dispose() }`
82
+ */
83
+ export async function openLwsDataset (storageRootUrl, options = {}) {
84
+ if (typeof storageRootUrl !== 'string') {
85
+ throw new TypeError('openLwsDataset: storageRootUrl must be a string')
86
+ }
87
+ const engine = options.engine !== undefined ? options.engine : await loadEngine()
88
+ const probe = await lwsDatasetOpsAvailable(engine)
89
+ if (!probe.available) {
90
+ throw new FactoidalError(`openLwsDataset: ${probe.reason}`, { code: 'op.unknown' })
91
+ }
92
+ const auth = options.auth !== undefined && options.auth !== null ? options.auth : {}
93
+ const client = await createLwsClient({ ...auth, engine, fetch: options.fetch })
94
+ const maxContainers = typeof options.maxContainers === 'number' ? options.maxContainers : 10000
95
+
96
+ const queue = [storageRootUrl]
97
+ const listed = new Set()
98
+ const listings = []
99
+ const reads = []
100
+ const linksets = []
101
+ const skipped = []
102
+ while (queue.length > 0) {
103
+ const container = queue.shift()
104
+ if (listed.has(container)) continue
105
+ if (listed.size >= maxContainers) {
106
+ throw new FactoidalError(`openLwsDataset: more than ${maxContainers} containers`,
107
+ { code: 'lws.dataset.limit' })
108
+ }
109
+ listed.add(container)
110
+ const done = await client.list(container)
111
+ listings.push({ container, done })
112
+ const plan = engine.call('lwsViewPlan', [JSON.stringify(done), container])
113
+ for (const c of plan.containers) queue.push(c)
114
+ for (const s of plan.skipped) skipped.push(s)
115
+ for (const resource of plan.resources) {
116
+ try {
117
+ reads.push({ resource, done: await client.read(resource) })
118
+ } catch (error) {
119
+ if (!(error instanceof LwsClientError)) throw error
120
+ skipped.push({ resource, reason: `read: ${error.reason}` })
121
+ }
122
+ }
123
+ for (const resource of plan.metadata) {
124
+ try {
125
+ linksets.push({ resource, done: await client.readLinkset(resource) })
126
+ } catch (error) {
127
+ if (!(error instanceof LwsClientError)) throw error
128
+ skipped.push({ resource, reason: `linkset: ${error.reason}` })
129
+ }
130
+ }
131
+ }
132
+
133
+ const view = engine.call('lwsDatasetView', [JSON.stringify({ listings, reads, linksets })])
134
+ for (const s of view.skipped) skipped.push(s)
135
+ const dataset = await parse(view.nquads, { format: 'nquads' })
136
+ const evidence = view.evidence.map((e) => ({ ...e }))
137
+
138
+ return {
139
+ dataset,
140
+ /** The name of the table-of-contents graph: the storage description IRI. */
141
+ toc: view.toc,
142
+ /** Each RDF document read, `{ resource, etag, lastModified }`, then each
143
+ * linkset read, `{ resource, linkset, etag }`. */
144
+ evidence,
145
+ /** Each member or read left out of the dataset: `{ resource, reason }`. */
146
+ skipped,
147
+ /** The number of named graphs (the table of contents, the documents and
148
+ * the linksets). */
149
+ graphs: view.graphs,
150
+ /**
151
+ * Run a SPARQL query over the dataset.
152
+ * @returns {Promise<{result: *, evidence: Array<object>}>} `result` as
153
+ * `Dataset.query` answers; `evidence` the documents and linksets read
154
+ */
155
+ async query (sparql, queryOptions) {
156
+ return { result: await dataset.query(sparql, queryOptions), evidence: evidence.slice() }
157
+ },
158
+ dispose () { dataset.dispose() }
159
+ }
160
+ }
package/lws/inpage.mjs ADDED
@@ -0,0 +1,135 @@
1
+ // factoidal/lws/inpage.mjs — one Lean LWS storage with no network: it runs
2
+ // in the page (or a worker, or Node) beside the code that uses it, and is
3
+ // reached through a `fetch`-shaped function.
4
+ //
5
+ // import { loadEngine } from '@factoidal/core/browser'
6
+ // import { openInPageLwsStorage } from '@factoidal/core/lws/inpage'
7
+ // const pod = await openInPageLwsStorage({ engine: await loadEngine(),
8
+ // baseIri: 'https://pod.foafos.local/', principal: 'urn:foafos:shell',
9
+ // rootAcl, storageKey, snapshot: saved,
10
+ // onSnapshot: (s) => save(s) })
11
+ // const r = await pod.fetch('https://pod.foafos.local/notes/a.txt', { method: 'PUT', body: 'hi' })
12
+ //
13
+ // The storage has a component principal (issue 1238): every request is that
14
+ // agent's, no token is read, and Web Access Control still decides it
15
+ // (L4Factoidal/LWS/Authorization.lean). Whoever holds the object can use it,
16
+ // so the host page decides which code gets it, as with xmpp/inpage.mjs.
17
+ //
18
+ // It is kept as one snapshot (`lwsSnapshot`), which the engine seals when a
19
+ // `storageKey` is given. After each step that changed the storage this file
20
+ // asks the engine for the snapshot and gives it to `onSnapshot`; the caller
21
+ // keeps it (IndexedDB, a file) and passes it back as `snapshot` to reopen.
22
+ // The engine checks that snapshot and refuses one sealed under another key.
23
+ //
24
+ // The engine makes every protocol decision (status, headers, bodies). This
25
+ // file turns a fetch call into the request record of the wasm ABI
26
+ // (docs/lws-solid-conformance.md, "wasm ABI") and the response record into a
27
+ // Response. Notifications are off: there is no network to deliver them on.
28
+ // No Node API; `crypto.getRandomValues`, `Request` and `Response` only.
29
+
30
+ /** Random bytes for the engine, base64 (see lws/server.mjs, entropyBase64). */
31
+ function entropyBase64 (size = 32) {
32
+ const bytes = new Uint8Array(size)
33
+ globalThis.crypto.getRandomValues(bytes)
34
+ let text = ''
35
+ for (const b of bytes) text += String.fromCharCode(b)
36
+ return btoa(text)
37
+ }
38
+
39
+ function handleOf (envelope) {
40
+ if (envelope === null || typeof envelope !== 'object') return null
41
+ for (const name of ['handle', 'id']) {
42
+ const value = envelope[name]
43
+ if (typeof value === 'string' && value.length > 0) return value
44
+ if (typeof value === 'number') return String(value)
45
+ }
46
+ return null
47
+ }
48
+
49
+ /**
50
+ * @param {object} options
51
+ * @param {object} options.engine a loaded engine handle (`loadEngine()`), with callBlobIO
52
+ * @param {string} options.baseIri the absolute IRI the storage root maps to; fetch
53
+ * reaches only IRIs under it
54
+ * @param {string} options.principal the component principal (an IRI); every request is its
55
+ * @param {string} [options.rootAcl] the root ACL (Turtle) of a new storage
56
+ * @param {string} [options.owner] the WebID the storage advertises
57
+ * @param {string} [options.storageKey] key text (64 hex digits or base64url); seals the snapshot
58
+ * @param {object} [options.snapshot] a snapshot to continue from (from onSnapshot)
59
+ * @param {function} [options.onSnapshot] (snapshot) => void, after each change
60
+ * @param {number} [options.maxBody] the largest content of one write (the engine's default otherwise)
61
+ * @returns {Promise<{fetch: Function, snapshot: Function, close: Function, baseIri: string}>}
62
+ */
63
+ export async function openInPageLwsStorage (options = {}) {
64
+ const { engine } = options
65
+ if (!engine || typeof engine.callBlobIO !== 'function') throw new TypeError('openInPageLwsStorage: an engine with callBlobIO is required')
66
+ if (typeof options.baseIri !== 'string') throw new TypeError('openInPageLwsStorage: baseIri is required')
67
+ if (typeof options.principal !== 'string') throw new TypeError('openInPageLwsStorage: principal is required')
68
+ const base = new URL(options.baseIri)
69
+ const config = { baseIri: base.href, principal: options.principal, notifications: false, entropy: entropyBase64() }
70
+ for (const name of ['rootAcl', 'owner', 'storageKey']) {
71
+ if (typeof options[name] === 'string') config[name] = options[name]
72
+ }
73
+ if (typeof options.maxBody === 'number') config.maxBody = options.maxBody
74
+ if (options.snapshot !== undefined && options.snapshot !== null) config.snapshot = options.snapshot
75
+ const opened = engine.call('lwsOpen', [JSON.stringify(config)])
76
+ const handle = handleOf(opened)
77
+ if (handle === null) throw new Error('lwsOpen answered with no handle')
78
+ const onSnapshot = typeof options.onSnapshot === 'function' ? options.onSnapshot : null
79
+ let closed = false
80
+
81
+ const snapshot = () => {
82
+ const answer = engine.call('lwsSnapshot', [handle, JSON.stringify({ nonce: entropyBase64(12) })])
83
+ if (answer === null || typeof answer !== 'object' || answer.snapshot === undefined) {
84
+ throw new Error('lwsSnapshot answered with no snapshot')
85
+ }
86
+ return answer.snapshot
87
+ }
88
+
89
+ /** A fetch to this storage: the same arguments as `fetch`, a real Response back. */
90
+ async function podFetch (input, init) {
91
+ if (closed) throw new TypeError('this storage is closed')
92
+ const request = new Request(input, init)
93
+ const url = new URL(request.url)
94
+ if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) {
95
+ throw new TypeError('the IRI is not in this storage')
96
+ }
97
+ const target = '/' + url.pathname.slice(base.pathname.length) + url.search
98
+ const content = request.method === 'GET' || request.method === 'HEAD'
99
+ ? new Uint8Array(0)
100
+ : new Uint8Array(await request.arrayBuffer())
101
+ const record = {
102
+ method: request.method,
103
+ target,
104
+ headers: [...request.headers],
105
+ now: Math.floor(Date.now() / 1000),
106
+ random: entropyBase64(16)
107
+ }
108
+ const got = engine.callBlobIO('lwsStep', [handle, JSON.stringify(record)], content)
109
+ const envelope = got.envelope
110
+ if (envelope === null || typeof envelope !== 'object') throw new Error('lwsStep answered with no envelope')
111
+ // A storage with a component principal and no files has nothing to
112
+ // fetch, resolve or read; an engine that asks is not one this host runs.
113
+ for (const ask of ['fetch', 'resolve', 'read']) {
114
+ if (envelope[ask] !== undefined && envelope[ask] !== null) throw new Error(`lwsStep asked this host to ${ask}, which an in-page storage cannot`)
115
+ }
116
+ const answer = (envelope.response !== null && typeof envelope.response === 'object') ? envelope.response : envelope
117
+ if (typeof answer.status !== 'number') throw new Error('lwsStep answered with no status in its response record')
118
+ if (envelope.changed === true && onSnapshot !== null) onSnapshot(snapshot())
119
+ const headers = new Headers()
120
+ for (const [name, value] of Array.isArray(answer.headers) ? answer.headers : []) headers.append(name, value)
121
+ // No content is a null body: a Response refuses an empty one with 204 or 304.
122
+ const body = got.bytes instanceof Uint8Array && got.bytes.length > 0 ? got.bytes : null
123
+ return new Response(body, { status: answer.status, headers })
124
+ }
125
+
126
+ return {
127
+ baseIri: base.href,
128
+ fetch: podFetch,
129
+ snapshot,
130
+ close () {
131
+ closed = true
132
+ try { engine.call('lwsClose', [handle]) } catch (_error) { /* already gone */ }
133
+ }
134
+ }
135
+ }
package/lws/persist.mjs CHANGED
@@ -64,7 +64,7 @@ export const STORAGE_MODE_VARIABLE = 'FACTOIDAL_LWS_STORAGE'
64
64
  /**
65
65
  * The text of the storage mode: `explicit` when the caller gave a string,
66
66
  * and otherwise the environment variable, passed unread; the engine checks
67
- * it. An empty value is none (the engine's default, the snapshot).
67
+ * it. An empty value is none (the host's default with a DIR, "files").
68
68
  */
69
69
  export function storageModeText (explicit) {
70
70
  if (typeof explicit === 'string') return explicit
package/lws/server.mjs CHANGED
@@ -250,8 +250,9 @@ function contentBytes (request) {
250
250
  * @param {boolean} [options.realClock] stamp each request with this host's clock
251
251
  * @param {string|null} [options.storageKey] the storage key text (64 hex digits or base64url);
252
252
  * default the environment variable FACTOIDAL_LWS_KEY; `null` reads no key
253
- * @param {string} [options.storage] where DIR keeps the storage: "snapshot" (default, one
254
- * file) or "files" (one file per body and a journal, issue 918); passed unread
253
+ * @param {string} [options.storage] where DIR keeps the storage: "files" (default, one
254
+ * file per body and a journal, issue 918) or "snapshot" (one file written whole after
255
+ * each change); passed unread
255
256
  * @param {boolean|string[]} [options.cors] CORS (issue 923): false for none, a list of
256
257
  * origins to answer; default: echo any valid request Origin. Passed unread
257
258
  * @param {string} [options.lockCommand] the command named in the lock file of DIR
@@ -270,12 +271,20 @@ export async function openLwsStorage (options = {}) {
270
271
  // The pagination threshold and page size of container listings; the
271
272
  // engine decides what it means (L4Factoidal/LWS/Pagination.lean).
272
273
  if (typeof options.pageSize === 'number') config.pageSize = options.pageSize
274
+ // The largest content of one write; the engine answers 413 above it
275
+ // (L4Factoidal/LWS/Operations.lean, step_tooLarge; issue 1240).
276
+ if (typeof options.maxBody === 'number') config.maxBody = options.maxBody
273
277
  // Authorization (L4Factoidal/LWS/Authorization.lean). `auth` is passed
274
278
  // through unread; `insecureNoAuth` is the explicit development mode.
275
279
  // With neither, the engine enforces authorization with no trusted
276
280
  // issuer.
277
281
  if (options.auth !== undefined && options.auth !== null) config.auth = options.auth
278
282
  if (options.insecureNoAuth === true) config.insecureNoAuth = true
283
+ // A component principal (issue 1238): a storage a component opens
284
+ // in-process. No token is read; every request is this agent's, and WAC
285
+ // decides it (Authorization.decide_principal_agents). Never served over
286
+ // HTTP: createLwsServer refuses it.
287
+ if (typeof options.principal === 'string') config.principal = options.principal
279
288
  if (typeof options.rootAcl === 'string') config.rootAcl = options.rootAcl
280
289
  // Notifications are on unless the caller turns them off; the engine
281
290
  // decides everything else about them.
@@ -339,10 +348,13 @@ export async function openLwsStorage (options = {}) {
339
348
  // With "files" the engine names the files to read (`load`) and gives the
340
349
  // plan of every write (`lwsPersist`), and this host reads no snapshot.
341
350
  // With a DIR and no `storage` option, the environment variable
342
- // FACTOIDAL_LWS_STORAGE is the value; without a DIR there is nothing to
351
+ // FACTOIDAL_LWS_STORAGE is the value, and without it "files" (2026-11-03):
352
+ // a write then costs what it changes, not the size of the storage. A DIR
353
+ // that holds a snapshot is migrated at the first start (lws-state.json is
354
+ // renamed lws-state.json.migrated). Without a DIR there is nothing to
343
355
  // keep, and only an explicit option is passed.
344
356
  const dir = typeof options.dir === 'string' ? options.dir : null
345
- const storage = dir !== null ? storageModeText(options.storage) : options.storage
357
+ const storage = dir !== null ? (storageModeText(options.storage) ?? 'files') : options.storage
346
358
  if (typeof storage === 'string') config.storage = storage
347
359
  // One writing process per storage directory
348
360
  // (https://github.com/danbri/factoidal/issues/1135): the lock is taken
@@ -589,13 +601,37 @@ export async function openLwsStorage (options = {}) {
589
601
  }
590
602
  }
591
603
 
592
- /** Read a Node request body as its bytes. */
593
- function readBody (request) {
604
+ /** The engine's default `maxBody` (L4Factoidal/LWS/Operations.lean). */
605
+ export const DEFAULT_MAX_BODY = 16 * 1024 * 1024
606
+
607
+ /**
608
+ * Read a Node request body as its bytes, at most `limit + 1` of them. Past
609
+ * the limit it keeps the first `limit + 1` bytes, stops reading and says so
610
+ * (`over`), so an oversized upload never reaches memory whole (issue 1240).
611
+ * The engine decides the answer: content over its `maxBody` is refused with
612
+ * 413 and changes nothing (`step_tooLarge`, L4Factoidal/LWS/Operations.lean).
613
+ */
614
+ function readBody (request, limit = DEFAULT_MAX_BODY) {
594
615
  return new Promise((resolve, reject) => {
595
616
  const chunks = []
596
- request.on('data', (chunk) => chunks.push(chunk))
617
+ let size = 0
618
+ let over = false
619
+ const done = () => resolve({ bytes: new Uint8Array(Buffer.concat(chunks)), over })
620
+ request.on('data', (chunk) => {
621
+ if (over) return
622
+ if (size + chunk.length > limit) {
623
+ chunks.push(chunk.subarray(0, limit + 1 - size))
624
+ size = limit + 1
625
+ over = true
626
+ done()
627
+ request.resume()
628
+ return
629
+ }
630
+ size += chunk.length
631
+ chunks.push(chunk)
632
+ })
597
633
  request.on('error', reject)
598
- request.on('end', () => resolve(new Uint8Array(Buffer.concat(chunks))))
634
+ request.on('end', () => { if (!over) done() })
599
635
  })
600
636
  }
601
637
 
@@ -623,6 +659,11 @@ function headerPairs (raw) {
623
659
  * setBaseIri: Function}>}
624
660
  */
625
661
  export async function createLwsServer (options = {}) {
662
+ if (options.principal !== undefined) {
663
+ // Served over HTTP, a principal storage would let every client act as
664
+ // the component. It is for in-process use only (issue 1238).
665
+ throw new LwsHostError('a storage with a component principal is in-process only; it is not served over HTTP')
666
+ }
626
667
  const http = await import('node:http')
627
668
  const onError = typeof options.onError === 'function'
628
669
  ? options.onError
@@ -675,8 +716,13 @@ export async function createLwsServer (options = {}) {
675
716
  method: request.method,
676
717
  target: request.url,
677
718
  headers: headerPairs(request.rawHeaders),
678
- body: await readBody(request)
719
+ body: new Uint8Array(0)
679
720
  }
721
+ const read = await readBody(request, typeof settings.maxBody === 'number' ? settings.maxBody : DEFAULT_MAX_BODY)
722
+ record.body = read.bytes
723
+ // The rest of an oversized body is not read: the connection closes
724
+ // after the answer. The answer is the engine's.
725
+ if (read.over) response.shouldKeepAlive = false
680
726
  } catch (error) {
681
727
  onError(error)
682
728
  response.writeHead(500, { 'content-type': 'text/plain' })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.28.0",
4
- "description": "EXPERIMENTAL UNREVIEWED 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.",
3
+ "version": "0.30.0",
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",
7
7
  "rdf",
@@ -70,6 +70,7 @@
70
70
  "./store-host/paths": "./store-host/paths.mjs",
71
71
  "./xmpp": "./xmpp/index.mjs",
72
72
  "./xmpp/server": "./xmpp/server.mjs",
73
+ "./xmpp/inpage": "./xmpp/inpage.mjs",
73
74
  "./cipher-host": "./lib/cipher-host.mjs",
74
75
  "./store": {
75
76
  "default": "./bin/store.mjs"
@@ -92,9 +93,15 @@
92
93
  "./webdav": {
93
94
  "default": "./webdav/server.mjs"
94
95
  },
96
+ "./lws/inpage": {
97
+ "default": "./lws/inpage.mjs"
98
+ },
95
99
  "./lws/client": {
96
100
  "default": "./lws/client.mjs"
97
101
  },
102
+ "./lws/dataset": {
103
+ "default": "./lws/dataset.mjs"
104
+ },
98
105
  "./authz": {
99
106
  "default": "./authz/server.mjs"
100
107
  },
package/xmpp/README.md CHANGED
@@ -65,8 +65,12 @@ against an older committed bundle instead of getting a confusing
65
65
  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
+ npx factoidal xmpp-serve state --cert fullchain.pem --key privkey.pem --starttls # STARTTLS required on 5222
68
69
  ```
69
70
 
71
+ Without `--cert` and `--key` the listener has no TLS and offers PLAIN, so
72
+ the command binds it to a loopback address only.
73
+
70
74
  ```js
71
75
  import { listen } from '@factoidal/core/xmpp/server'
72
76
  const running = await listen({ dir: 'state', domain: 'localhost', port: 0 })
@@ -90,6 +94,7 @@ process per connection behind socat) serves; the wasm session ops are
90
94
  | `xmppSessionFeed` | `[session, text]` | `{ ok, send: [...], route: [{to, text}], presence: [...], rostersSaved: [...], close, stage, bare, full, bufferedBytes }` |
91
95
  | `xmppSessionDeliver` | `[session, text]` | `{ ok, delivered, send }` — `delivered:false` for an unbound session; nothing is lost |
92
96
  | `xmppSessionClose` | `[session]` | `{ ok, send }` |
97
+ | `xmppSessionTlsUp` | `[session]` | `{ ok, stage }` — after a feed answered `startTls: true` and the host finished the TLS handshake; refused otherwise |
93
98
  | `xmppRosterGet` / `xmppRosterSet` | `[server, bare]` / `[server, bare, encoded]` | the roster in the engine's own on-disk encoding |
94
99
  | `xmppClose` | `[server]` | `{ ok, sessions }` |
95
100
 
@@ -120,7 +125,10 @@ What it speaks: RFC 6120 stream negotiation, stream errors, SASL PLAIN
120
125
  and SCRAM-SHA-256, resource binding; RFC 6121 roster, presence
121
126
  subscription and broadcast, message delivery with the server-stamped
122
127
  `from`; XEP-0030 `disco#info`; XEP-0368 direct TLS with `--cert`/`--key`.
123
- Not: STARTTLS, server-to-server federation, MUC/MIX/GC3 rooms, PRECIS.
128
+ RFC 6120 section 5 STARTTLS with `startTls: true` (op `xmppSessionTlsUp`;
129
+ the engine decides, the host does the handshake; see the theorems in
130
+ `formal/lean4/L4Factoidal/XMPP/Server.lean`).
131
+ Not: server-to-server federation, MUC/MIX/GC3 rooms, PRECIS.
124
132
  Known limitation shared with the native harness: one SCRAM salt per
125
133
  server (`saltB64` in the config), not one per account.
126
134