@factoidal/core 0.7.1 → 0.11.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.
Files changed (195) hide show
  1. package/CHANGELOG.md +616 -1
  2. package/NOTICE +2 -21
  3. package/README.md +123 -993
  4. package/api.d.ts +53 -0
  5. package/api.js +5 -0
  6. package/api.mjs +56 -0
  7. package/bin/engine.mjs +38 -19
  8. package/bin/ext.mjs +16 -20
  9. package/bin/factoidal.mjs +667 -118
  10. package/bin/pack.mjs +37 -0
  11. package/bin/serve-worker.mjs +32 -0
  12. package/bin/serve.mjs +423 -0
  13. package/bin/store-worker-host.mjs +1 -1
  14. package/bin/store.mjs +1 -1
  15. package/browser.d.ts +114 -507
  16. package/browser.js +155 -2257
  17. package/docs/fuseki-replacement.md +108 -0
  18. package/fn.d.ts +25 -96
  19. package/fn.js +131 -240
  20. package/index.d.ts +620 -260
  21. package/index.js +4 -234
  22. package/index.mjs +180 -84
  23. package/l4-assets/l4factoidal.js +47 -7
  24. package/l4-assets/l4factoidal.mjs +1 -1
  25. package/l4-assets/l4factoidal.wasm +0 -0
  26. package/l4-assets/version.json +11 -7
  27. package/lib/api.mjs +2337 -0
  28. package/lib/browser-engine.mjs +69 -0
  29. package/lib/dataset.mjs +471 -0
  30. package/lib/delta-log-hosts.mjs +191 -0
  31. package/lib/lean-entry.mjs +213 -0
  32. package/lib/pure.mjs +98 -0
  33. package/lib/store.mjs +115 -0
  34. package/lib/version.mjs +5 -0
  35. package/lws/README.md +137 -0
  36. package/lws/persist.mjs +78 -0
  37. package/lws/server.mjs +416 -0
  38. package/package.json +59 -61
  39. package/rdfjs.js +5 -536
  40. package/rdfjs.mjs +517 -0
  41. package/sample-store.d.ts +8 -16
  42. package/sample-store.mjs +39 -29
  43. package/solid/client/README.md +100 -0
  44. package/solid/client/index.mjs +293 -0
  45. package/solid/server/README.md +96 -0
  46. package/solid/server/index.mjs +395 -0
  47. package/store-host/deno.mjs +8 -0
  48. package/store-host/index.mjs +12 -0
  49. package/store-host/node.mjs +8 -0
  50. package/xmpp/README.md +107 -0
  51. package/xmpp/index.mjs +229 -0
  52. package/browser-wasm.js +0 -872
  53. package/factoidal-npm-entry.js +0 -15477
  54. package/factoidal-npm-entry.wasm.assets/code-5a7fc68f2ab1323718b8.wasm +0 -0
  55. package/factoidal-npm-entry.wasm.js +0 -455
  56. package/factoidal.js +0 -13118
  57. package/factoidal.wasm.assets/code-bbe6099bfb5b10c4c3ab.wasm +0 -0
  58. package/factoidal.wasm.js +0 -457
  59. package/hacl-init.js +0 -92
  60. package/hacl-wasm/FStar.wasm +0 -0
  61. package/hacl-wasm/Hacl_Bignum.wasm +0 -0
  62. package/hacl-wasm/Hacl_Bignum25519_51.wasm +0 -0
  63. package/hacl-wasm/Hacl_Bignum_Base.wasm +0 -0
  64. package/hacl-wasm/Hacl_Curve25519_51.wasm +0 -0
  65. package/hacl-wasm/Hacl_Ed25519.wasm +0 -0
  66. package/hacl-wasm/Hacl_Ed25519_PrecompTable.wasm +0 -0
  67. package/hacl-wasm/Hacl_Hash_Base.wasm +0 -0
  68. package/hacl-wasm/Hacl_Hash_SHA2.wasm +0 -0
  69. package/hacl-wasm/Hacl_IntTypes_Intrinsics.wasm +0 -0
  70. package/hacl-wasm/LowStar_Endianness.wasm +0 -0
  71. package/hacl-wasm/WasmSupport.wasm +0 -0
  72. package/hacl-wasm/api.js +0 -775
  73. package/hacl-wasm/api.json +0 -3787
  74. package/hacl-wasm/layouts.json +0 -1
  75. package/hacl-wasm/loader.js +0 -568
  76. package/hacl-wasm/shell.js +0 -12
  77. package/l4-core.d.ts +0 -159
  78. package/l4-core.js +0 -188
  79. package/l4.d.ts +0 -50
  80. package/l4.js +0 -104
  81. package/lib/api.js +0 -2300
  82. package/lib/engine-js.js +0 -165
  83. package/lib/engine-wasm.js +0 -300
  84. package/sample-store/CURRENT +0 -1
  85. package/sample-store/gen-1/manifest.sbm2 +0 -0
  86. package/sample-store/gen-1/manifest.tsv +0 -14
  87. package/sample-store/gen-1/predicate-0.ibk3 +0 -0
  88. package/sample-store/gen-1/predicate-0.ibk3.merkle +0 -1
  89. package/sample-store/gen-1/predicate-0.ibk3.oli2 +0 -0
  90. package/sample-store/gen-1/predicate-0.ibk3.oli2.merkle +0 -1
  91. package/sample-store/gen-1/predicate-0.ibk3.sri2 +0 -0
  92. package/sample-store/gen-1/predicate-0.ibk3.sri2.merkle +0 -1
  93. package/sample-store/gen-1/predicate-0.ibk3.tli1 +0 -0
  94. package/sample-store/gen-1/predicate-0.ibk3.tli1.merkle +0 -0
  95. package/sample-store/gen-1/predicate-1.ibk3 +0 -0
  96. package/sample-store/gen-1/predicate-1.ibk3.merkle +0 -0
  97. package/sample-store/gen-1/predicate-1.ibk3.oli2 +0 -0
  98. package/sample-store/gen-1/predicate-1.ibk3.oli2.merkle +0 -0
  99. package/sample-store/gen-1/predicate-1.ibk3.sri2 +0 -0
  100. package/sample-store/gen-1/predicate-1.ibk3.sri2.merkle +0 -1
  101. package/sample-store/gen-1/predicate-1.ibk3.tli1 +0 -0
  102. package/sample-store/gen-1/predicate-1.ibk3.tli1.merkle +0 -1
  103. package/sample-store/gen-1/predicate-10.ibk3 +0 -0
  104. package/sample-store/gen-1/predicate-10.ibk3.merkle +0 -1
  105. package/sample-store/gen-1/predicate-10.ibk3.oli2 +0 -0
  106. package/sample-store/gen-1/predicate-10.ibk3.oli2.merkle +0 -0
  107. package/sample-store/gen-1/predicate-10.ibk3.sri2 +0 -0
  108. package/sample-store/gen-1/predicate-10.ibk3.sri2.merkle +0 -1
  109. package/sample-store/gen-1/predicate-10.ibk3.tli1 +0 -0
  110. package/sample-store/gen-1/predicate-10.ibk3.tli1.merkle +0 -1
  111. package/sample-store/gen-1/predicate-11.ibk3 +0 -0
  112. package/sample-store/gen-1/predicate-11.ibk3.merkle +0 -0
  113. package/sample-store/gen-1/predicate-11.ibk3.oli2 +0 -0
  114. package/sample-store/gen-1/predicate-11.ibk3.oli2.merkle +0 -1
  115. package/sample-store/gen-1/predicate-11.ibk3.sri2 +0 -0
  116. package/sample-store/gen-1/predicate-11.ibk3.sri2.merkle +0 -1
  117. package/sample-store/gen-1/predicate-11.ibk3.tli1 +0 -0
  118. package/sample-store/gen-1/predicate-11.ibk3.tli1.merkle +0 -1
  119. package/sample-store/gen-1/predicate-12.ibk3 +0 -0
  120. package/sample-store/gen-1/predicate-12.ibk3.merkle +0 -1
  121. package/sample-store/gen-1/predicate-12.ibk3.oli2 +0 -0
  122. package/sample-store/gen-1/predicate-12.ibk3.oli2.merkle +0 -1
  123. package/sample-store/gen-1/predicate-12.ibk3.sri2 +0 -0
  124. package/sample-store/gen-1/predicate-12.ibk3.sri2.merkle +0 -1
  125. package/sample-store/gen-1/predicate-12.ibk3.tli1 +0 -0
  126. package/sample-store/gen-1/predicate-12.ibk3.tli1.merkle +0 -1
  127. package/sample-store/gen-1/predicate-2.ibk3 +0 -0
  128. package/sample-store/gen-1/predicate-2.ibk3.merkle +0 -1
  129. package/sample-store/gen-1/predicate-2.ibk3.oli2 +0 -0
  130. package/sample-store/gen-1/predicate-2.ibk3.oli2.merkle +0 -1
  131. package/sample-store/gen-1/predicate-2.ibk3.sri2 +0 -0
  132. package/sample-store/gen-1/predicate-2.ibk3.sri2.merkle +0 -0
  133. package/sample-store/gen-1/predicate-2.ibk3.tli1 +0 -0
  134. package/sample-store/gen-1/predicate-2.ibk3.tli1.merkle +0 -0
  135. package/sample-store/gen-1/predicate-3.ibk3 +0 -0
  136. package/sample-store/gen-1/predicate-3.ibk3.merkle +0 -1
  137. package/sample-store/gen-1/predicate-3.ibk3.oli2 +0 -0
  138. package/sample-store/gen-1/predicate-3.ibk3.oli2.merkle +0 -1
  139. package/sample-store/gen-1/predicate-3.ibk3.sri2 +0 -0
  140. package/sample-store/gen-1/predicate-3.ibk3.sri2.merkle +0 -1
  141. package/sample-store/gen-1/predicate-3.ibk3.tli1 +0 -0
  142. package/sample-store/gen-1/predicate-3.ibk3.tli1.merkle +0 -1
  143. package/sample-store/gen-1/predicate-4.ibk3 +0 -0
  144. package/sample-store/gen-1/predicate-4.ibk3.merkle +0 -0
  145. package/sample-store/gen-1/predicate-4.ibk3.oli2 +0 -0
  146. package/sample-store/gen-1/predicate-4.ibk3.oli2.merkle +0 -1
  147. package/sample-store/gen-1/predicate-4.ibk3.sri2 +0 -0
  148. package/sample-store/gen-1/predicate-4.ibk3.sri2.merkle +0 -1
  149. package/sample-store/gen-1/predicate-4.ibk3.tli1 +0 -0
  150. package/sample-store/gen-1/predicate-4.ibk3.tli1.merkle +0 -2
  151. package/sample-store/gen-1/predicate-5.ibk3 +0 -0
  152. package/sample-store/gen-1/predicate-5.ibk3.merkle +0 -1
  153. package/sample-store/gen-1/predicate-5.ibk3.oli2 +0 -0
  154. package/sample-store/gen-1/predicate-5.ibk3.oli2.merkle +0 -1
  155. package/sample-store/gen-1/predicate-5.ibk3.sri2 +0 -0
  156. package/sample-store/gen-1/predicate-5.ibk3.sri2.merkle +0 -1
  157. package/sample-store/gen-1/predicate-5.ibk3.tli1 +0 -0
  158. package/sample-store/gen-1/predicate-5.ibk3.tli1.merkle +0 -1
  159. package/sample-store/gen-1/predicate-6.ibk3 +0 -0
  160. package/sample-store/gen-1/predicate-6.ibk3.merkle +0 -1
  161. package/sample-store/gen-1/predicate-6.ibk3.oli2 +0 -0
  162. package/sample-store/gen-1/predicate-6.ibk3.oli2.merkle +0 -1
  163. package/sample-store/gen-1/predicate-6.ibk3.sri2 +0 -0
  164. package/sample-store/gen-1/predicate-6.ibk3.sri2.merkle +0 -1
  165. package/sample-store/gen-1/predicate-6.ibk3.tli1 +0 -0
  166. package/sample-store/gen-1/predicate-6.ibk3.tli1.merkle +0 -0
  167. package/sample-store/gen-1/predicate-7.ibk3 +0 -0
  168. package/sample-store/gen-1/predicate-7.ibk3.merkle +0 -0
  169. package/sample-store/gen-1/predicate-7.ibk3.oli2 +0 -0
  170. package/sample-store/gen-1/predicate-7.ibk3.oli2.merkle +0 -2
  171. package/sample-store/gen-1/predicate-7.ibk3.sri2 +0 -0
  172. package/sample-store/gen-1/predicate-7.ibk3.sri2.merkle +0 -1
  173. package/sample-store/gen-1/predicate-7.ibk3.tli1 +0 -0
  174. package/sample-store/gen-1/predicate-7.ibk3.tli1.merkle +0 -1
  175. package/sample-store/gen-1/predicate-8.ibk3 +0 -0
  176. package/sample-store/gen-1/predicate-8.ibk3.merkle +0 -1
  177. package/sample-store/gen-1/predicate-8.ibk3.oli2 +0 -0
  178. package/sample-store/gen-1/predicate-8.ibk3.oli2.merkle +0 -1
  179. package/sample-store/gen-1/predicate-8.ibk3.sri2 +0 -0
  180. package/sample-store/gen-1/predicate-8.ibk3.sri2.merkle +0 -1
  181. package/sample-store/gen-1/predicate-8.ibk3.tli1 +0 -0
  182. package/sample-store/gen-1/predicate-8.ibk3.tli1.merkle +0 -1
  183. package/sample-store/gen-1/predicate-9.ibk3 +0 -0
  184. package/sample-store/gen-1/predicate-9.ibk3.merkle +0 -1
  185. package/sample-store/gen-1/predicate-9.ibk3.oli2 +0 -0
  186. package/sample-store/gen-1/predicate-9.ibk3.oli2.merkle +0 -1
  187. package/sample-store/gen-1/predicate-9.ibk3.sri2 +0 -0
  188. package/sample-store/gen-1/predicate-9.ibk3.sri2.merkle +0 -3
  189. package/sample-store/gen-1/predicate-9.ibk3.tli1 +0 -0
  190. package/sample-store/gen-1/predicate-9.ibk3.tli1.merkle +0 -2
  191. package/select.d.ts +0 -116
  192. package/select.js +0 -492
  193. package/version.json +0 -102
  194. package/wasm.d.ts +0 -130
  195. package/wasm.js +0 -158
package/lws/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # `@factoidal/core/lws` — a Node host for Linked Web Storage 1.0
2
+
3
+ The W3C Linked Web Storage Protocol 1.0 core
4
+ (<https://w3c.github.io/lws-protocol/lws10-core/>, Linked Web Storage
5
+ Working Group editor's draft) served over Node's `http` module.
6
+
7
+ Tracking issue: <https://github.com/danbri/factoidal/issues/659>.
8
+ Design record:
9
+ [`docs/designissues/2026-09-06-lws-and-solid-protocols.md`](../../../docs/designissues/2026-09-06-lws-and-solid-protocols.md).
10
+ Conformance ledger:
11
+ [`docs/lws-solid-conformance.md`](../../../docs/lws-solid-conformance.md).
12
+
13
+ ## What is here and what is not
14
+
15
+ `server.mjs` is a socket, a body reader and a header writer. Every
16
+ protocol decision — status codes, `Link` relations, `Last-Modified`,
17
+ containment, the blank-node refusal in PATCH — is made by
18
+ `formal/lean4/L4Factoidal/LWS/` and reaches this file only as a
19
+ `{status, headers, body}` record. Iron rule 7 of `CLAUDE.md`: no
20
+ protocol logic in a host.
21
+
22
+ The one status code this file writes itself is `500`, and only when the
23
+ host or the engine failed rather than answered.
24
+
25
+ ## The wasm contract
26
+
27
+ | op | arguments | answer |
28
+ | --- | --- | --- |
29
+ | `lwsOpen` | `[configJson]` | `{ ok, handle, root }` |
30
+ | `lwsStep` | `[handle, requestJson]` | `{ ok, response }` |
31
+ | `lwsClose` | `[handle]` | `{ ok }` |
32
+ | `lwsSnapshot` | `[handle]` | `{ ok, snapshot }` |
33
+ | `lwsFetched` | `[handle, documentJson]` | `{ ok, fetch? , installed?, refused? }` |
34
+
35
+ ```
36
+ request { method, target, headers: [[name, value]] } + the content as bytes
37
+ response { status, headers: [[name, value]], bodyBytes } + the content as bytes
38
+ (through callBlobIO; https://github.com/danbri/factoidal/issues/889)
39
+ ```
40
+
41
+ `headers` is a list of pairs, not an object: HTTP allows a field name to
42
+ repeat and the order of repeated fields carries meaning. `target` is a
43
+ path within the storage, so Node's `request.url` is passed through
44
+ unchanged and this host composes no IRI.
45
+
46
+ `configJson` members, all optional: `baseIri` (the absolute IRI the
47
+ storage root maps to), `now` (the engine clock in seconds, which advances
48
+ by one second per mutation so a write sequence has increasing
49
+ `Last-Modified` values with no real clock), `owner`, `agent`, `rootAcl`,
50
+ `auth` and `insecureNoAuth` (below). A request
51
+ may carry `now` and `agent` of its own to override the handle's for one
52
+ step; `listen({realClock: true})` is how a server with a real clock feeds
53
+ one in, and a test leaves it off to keep the engine's deterministic
54
+ clock. The full contract is `docs/lws-solid-conformance.md`, section
55
+ "wasm ABI".
56
+
57
+ A WebAssembly module built before these ops landed answers `unknown op`.
58
+ `lwsOpsAvailable(engine)` reports that as `{available: false, reason}`,
59
+ and every test in `tests/lws/` skips with the reason printed rather than
60
+ passing on an engine that cannot answer.
61
+
62
+ ## Authorization
63
+
64
+ Authorization follows the draft's "Authorization" section
65
+ (https://github.com/danbri/factoidal/issues/886) and is decided by
66
+ `formal/lean4/L4Factoidal/LWS/Authorization.lean`. It is ON by default.
67
+
68
+ ```js
69
+ const running = await listen({
70
+ port: 3000,
71
+ owner: 'https://id.example/alice', // root ACL: Read, Write, Control
72
+ auth: { issuers: [{ issuer: 'https://as.example' }] },
73
+ realClock: true
74
+ })
75
+ ```
76
+
77
+ * Clients send `Authorization: Bearer <access token>` (RFC 6750). The
78
+ token is a JWT (RFC 9068) signed with ES256, RS256 or EdDSA by a trusted
79
+ issuer; `aud` must contain the storage IRI (`baseIri`, or `auth.audience`).
80
+ * Without a valid token the answer is 401 with
81
+ `WWW-Authenticate: Bearer resource_metadata="…"`; the RFC 9728 metadata
82
+ document is served at `/.well-known/oauth-protected-resource<path>`.
83
+ * The host fetches the issuer's `/.well-known/lws-configuration` and its
84
+ `jwks_uri` when the engine asks (`lwsStep` answers `{ fetch }`), passes
85
+ the bytes to `lwsFetched`, and steps again. `auth.issuers[].jwks` gives
86
+ the keys inline instead.
87
+ * `insecureNoAuth: true` serves every request with no authorization. It is
88
+ for development only, and must be asked for.
89
+
90
+ ## Use
91
+
92
+ ```js
93
+ import { listen } from '@factoidal/core/lws'
94
+
95
+ const running = await listen({ port: 3000, baseIri: 'https://storage.example/',
96
+ insecureNoAuth: true }) // development only; see Authorization
97
+ console.log(running.origin)
98
+ await running.close()
99
+ ```
100
+
101
+ From the command:
102
+
103
+ ```
104
+ factoidal lws-serve /var/lib/lws --port 3000 --issuer https://as.example --owner https://id.example/alice
105
+ factoidal lws-serve /tmp/dev --port 3000 --insecure-no-auth
106
+ ```
107
+
108
+ One of `--issuer`, `--auth-config FILE` or `--insecure-no-auth` is
109
+ required.
110
+
111
+ `--port 0` takes an ephemeral port and the command prints the one it got;
112
+ `--base IRI` sets `baseIri`, which defaults to the bound origin. `DIR`
113
+ names the storage directory, but the first slice keeps the resource tree
114
+ in the engine handle, so nothing is read from it or written to it yet
115
+ (design record, section 2).
116
+
117
+ ## Exports
118
+
119
+ | name | what it is |
120
+ | --- | --- |
121
+ | `listen(options)` | start a server, resolve when it is listening |
122
+ | `createLwsServer(options)` | build the server without listening |
123
+ | `openLwsStorage(options)` | open a handle and get `step` / `close` |
124
+ | `createLwsServer(...).setBaseIri(iri)` | set `baseIri` before the first request |
125
+ | `lwsOpsAvailable(engine)` | probe the loaded module for the ops |
126
+ | `LWS_OPS` | the op names |
127
+ | `LwsHostError` | a host failure, with `unknownOp` set when the op is absent |
128
+
129
+ ## Tests
130
+
131
+ ```
132
+ node tests/lws/server.mjs
133
+ node tests/lws/draft-binding.mjs
134
+ node tests/lws/binary-bodies.mjs
135
+ node tests/lws/authorization.mjs
136
+ deno run --allow-read --allow-net --allow-write --allow-env tests/lws/server.mjs
137
+ ```
@@ -0,0 +1,78 @@
1
+ // Durable storage for `factoidal lws-serve DIR` and `solid-serve DIR`.
2
+ // https://github.com/danbri/factoidal/issues/659
3
+ //
4
+ // WHAT THIS FILE IS ALLOWED TO DO
5
+ // Move bytes between one file and the engine. The engine decides what
6
+ // the bytes are (`L4Factoidal/LWS/Snapshot.lean`): the snapshot document,
7
+ // which paths it may hold (no `..`, no absolute or encoded traversal), and
8
+ // when the storage changed (the `changed` member of the step envelope).
9
+ // This file decides only where the one file lives and how it is written:
10
+ //
11
+ // DIR/lws-state.json the snapshot, replaced atomically
12
+ //
13
+ // No request target ever becomes a file name here, so a request cannot
14
+ // address a file outside DIR. The write is: a temporary file in DIR,
15
+ // fsync, rename over the state file, fsync of DIR. A crash leaves the old
16
+ // snapshot or the new one, never a torn one.
17
+
18
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync, existsSync } from 'node:fs'
19
+ import { join, resolve } from 'node:path'
20
+
21
+ export const STATE_FILE = 'lws-state.json'
22
+
23
+ /** The absolute path of the state file of a storage directory. */
24
+ export function stateFileOf (dir) {
25
+ return join(resolve(dir), STATE_FILE)
26
+ }
27
+
28
+ /**
29
+ * The parsed snapshot in DIR, or `null` when DIR holds none yet. The
30
+ * engine checks the document; a file that does not parse as JSON is an
31
+ * error of this host, reported as such.
32
+ */
33
+ export function readSnapshot (dir) {
34
+ const file = stateFileOf(dir)
35
+ if (!existsSync(file)) return null
36
+ const text = readFileSync(file, 'utf8')
37
+ try {
38
+ return JSON.parse(text)
39
+ } catch (error) {
40
+ throw new Error(`${file} is not JSON: ${error.message}`)
41
+ }
42
+ }
43
+
44
+ /** Write a snapshot document to DIR, atomically. */
45
+ export function writeSnapshot (dir, snapshot) {
46
+ const root = resolve(dir)
47
+ mkdirSync(root, { recursive: true })
48
+ const file = stateFileOf(root)
49
+ const temporary = join(root, `.${STATE_FILE}.${process.pid}.tmp`)
50
+ const fd = openSync(temporary, 'w', 0o600)
51
+ try {
52
+ writeSync(fd, JSON.stringify(snapshot))
53
+ fsyncSync(fd)
54
+ } finally {
55
+ closeSync(fd)
56
+ }
57
+ renameSync(temporary, file)
58
+ try {
59
+ const dirFd = openSync(root, 'r')
60
+ try { fsyncSync(dirFd) } finally { closeSync(dirFd) }
61
+ } catch (_error) {
62
+ // Some platforms cannot fsync a directory; the rename stands.
63
+ }
64
+ }
65
+
66
+ /**
67
+ * After one step: when the engine says the storage changed, ask it for
68
+ * the snapshot (`snapshotOp`) and write it.
69
+ */
70
+ export function persistIfChanged (engine, snapshotOp, handle, envelope, dir) {
71
+ if (typeof dir !== 'string' || envelope === null || typeof envelope !== 'object') return
72
+ if (envelope.changed !== true) return
73
+ const answer = engine.call(snapshotOp, [handle])
74
+ if (answer === null || typeof answer !== 'object' || answer.snapshot === undefined) {
75
+ throw new Error(`${snapshotOp} answered with no snapshot`)
76
+ }
77
+ writeSnapshot(dir, answer.snapshot)
78
+ }
package/lws/server.mjs ADDED
@@ -0,0 +1,416 @@
1
+ // A Node HTTP host for the Linked Web Storage Protocol 1.0 core.
2
+ // https://github.com/danbri/factoidal/issues/659
3
+ // Design record: docs/designissues/2026-09-06-lws-and-solid-protocols.md
4
+ //
5
+ // WHAT THIS FILE IS ALLOWED TO DO
6
+ // Accept a socket, turn one HTTP request into the JSON request record,
7
+ // call the engine's `lwsStep` on a handle, and write the JSON response
8
+ // record back onto the socket. That is the whole contract.
9
+ //
10
+ // It makes NO protocol decision. It does not choose a status code, does
11
+ // not build a `Link` header, does not read or write a resource, does not
12
+ // decide whether a PATCH is refused, and does not know what a container
13
+ // is. Every one of those lives in `formal/lean4/L4Factoidal/LWS/`
14
+ // (iron rule 7 of CLAUDE.md). A reviewer who finds a status code, a
15
+ // media type decision or an RDF term in this file has found a rule
16
+ // violation. The one status code written here is 500, and only for a
17
+ // failure of this host itself.
18
+ //
19
+ // THE WASM CONTRACT
20
+ // Fixed in docs/lws-solid-conformance.md, section "wasm ABI".
21
+ // lwsOpen [configJson] -> { ok, handle, root }
22
+ // lwsStep [handle, requestJson] -> { ok, response }
23
+ // lwsClose [handle] -> { ok }
24
+ // lwsFetched [handle, documentJson] -> { ok, fetch? | installed? | refused? }
25
+ //
26
+ // `lwsStep` may answer `{ ok, fetch: { issuer, url } }` instead of a
27
+ // response: the engine needs an authorization server's metadata or key
28
+ // set before it can decide. This host fetches that URL, passes
29
+ // `{ issuer, url, status, body, now }` to `lwsFetched`, follows any
30
+ // further `fetch` it names, and steps the request again.
31
+ //
32
+ // request { method, target, headers: [[name, value]] } + content bytes
33
+ // response { status, headers: [[name, value]], bodyBytes } + content bytes
34
+ //
35
+ // Bodies are BYTES (https://github.com/danbri/factoidal/issues/889). The
36
+ // step goes through the engine's byte-region entry, `callBlobIO`: the
37
+ // request content is the IN region and the response content is the OUT
38
+ // region, with no encoding and no decoding here. This host never reads a
39
+ // body as text.
40
+ //
41
+ // The config members are `baseIri`, `now`, `owner`, `agent`, `rootAcl`,
42
+ // `auth` and `insecureNoAuth`, all optional. `target` is a path within the storage, so this host passes
43
+ // Node's `request.url` through unchanged and never composes an absolute
44
+ // IRI: `baseIri` tells the engine what the root maps to.
45
+ //
46
+ // A request may carry `now` and `agent` beside `method` to override the
47
+ // handle's clock and agent for one step. That is how a host with a real
48
+ // clock and a real token verifier feeds both in; this host sets `now`
49
+ // from its own clock only when the caller asked for it, so a test can
50
+ // keep the engine's deterministic clock.
51
+ //
52
+ // `headers` is a list of pairs rather than an object because HTTP allows
53
+ // a field name to repeat and the order of repeated fields is
54
+ // significant; an object would lose both.
55
+ //
56
+ // The reader of the response envelope accepts the response record either
57
+ // under `response` or spread onto the envelope itself, and the handle
58
+ // either as `handle` or as `id`. This is deliberate tolerance while the
59
+ // Lean side of the contract lands, not two supported shapes: when
60
+ // `docs/lws-solid-conformance.md` fixes one, the other stops being
61
+ // produced and this code keeps working unchanged.
62
+
63
+ import { loadEngine } from '../bin/engine.mjs'
64
+ import { readSnapshot, persistIfChanged } from './persist.mjs'
65
+
66
+ /** The ops this host needs from the engine. */
67
+ export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched']
68
+
69
+ /** The most documents one request may make this host fetch before the
70
+ * engine has answered it. The engine names at most two per issuer (its
71
+ * metadata, then its key set); this bound only stops a defective module
72
+ * from looping the host. */
73
+ const MAX_FETCHES = 8
74
+
75
+ /**
76
+ * Fetch one document the engine named, for `lwsFetched`. Pure I/O: the
77
+ * engine decides what the bytes mean. A network failure is reported as
78
+ * status 0, which the engine refuses like any other failed fetch.
79
+ */
80
+ async function fetchDocument (url) {
81
+ try {
82
+ const answer = await fetch(url, { redirect: 'error' })
83
+ return { status: answer.status, body: await answer.text() }
84
+ } catch (error) {
85
+ return { status: 0, body: String(error && error.message ? error.message : error) }
86
+ }
87
+ }
88
+
89
+ /** An error raised by the LWS host, as opposed to one the engine
90
+ * reported. `unknownOp` is set when the loaded WebAssembly module does
91
+ * not carry the operation at all, which is what a test skips on. */
92
+ export class LwsHostError extends Error {
93
+ constructor (message, detail = {}) {
94
+ super(message)
95
+ this.name = 'LwsHostError'
96
+ this.unknownOp = detail.unknownOp === true
97
+ }
98
+ }
99
+
100
+ function isUnknownOp (error) {
101
+ const text = error && error.message ? String(error.message) : String(error)
102
+ return text.indexOf('unknown op') >= 0
103
+ }
104
+
105
+ /**
106
+ * Whether the loaded engine carries the LWS operations.
107
+ *
108
+ * One call is made and its refusal read. A module built before the LWS
109
+ * ops landed answers "unknown op"; anything else -- including an engine
110
+ * complaint about the configuration -- means the op is present.
111
+ *
112
+ * @param {object} engine the handle from `loadEngine()`
113
+ * @returns {Promise<{available: boolean, reason: string}>}
114
+ */
115
+ export async function lwsOpsAvailable (engine) {
116
+ try {
117
+ const opened = engine.call('lwsOpen', [JSON.stringify({ probe: true })])
118
+ const handle = handleOf(opened)
119
+ if (handle !== null) {
120
+ try { engine.call('lwsClose', [handle]) } catch (_error) { /* probe only */ }
121
+ }
122
+ return { available: true, reason: 'lwsOpen answered' }
123
+ } catch (error) {
124
+ if (isUnknownOp(error)) {
125
+ return {
126
+ available: false,
127
+ reason: 'the loaded l4factoidal WebAssembly module does not carry ' +
128
+ 'the lws ops (rebuild formal/lean4/Wasm)'
129
+ }
130
+ }
131
+ return { available: true, reason: 'lwsOpen answered with an engine error' }
132
+ }
133
+ }
134
+
135
+ function handleOf (envelope) {
136
+ if (envelope === null || typeof envelope !== 'object') return null
137
+ for (const name of ['handle', 'id']) {
138
+ const value = envelope[name]
139
+ if (typeof value === 'string' && value.length > 0) return value
140
+ if (typeof value === 'number') return String(value)
141
+ }
142
+ return null
143
+ }
144
+
145
+ function responseOf (envelope, bytes) {
146
+ if (envelope === null || typeof envelope !== 'object') {
147
+ throw new LwsHostError('lwsStep answered with no envelope')
148
+ }
149
+ const record = (envelope.response !== null && typeof envelope.response === 'object')
150
+ ? envelope.response
151
+ : envelope
152
+ if (typeof record.status !== 'number') {
153
+ throw new LwsHostError('lwsStep answered with no status in its response record')
154
+ }
155
+ const headers = Array.isArray(record.headers) ? record.headers : []
156
+ const body = bytes instanceof Uint8Array ? bytes : new Uint8Array(0)
157
+ return { status: record.status, headers, body }
158
+ }
159
+
160
+ /** The request content as bytes: a byte array is passed as it is, and a
161
+ * string (a JavaScript caller's convenience) as its UTF-8 bytes. */
162
+ function contentBytes (request) {
163
+ if (request.body instanceof Uint8Array) return request.body
164
+ if (typeof request.body === 'string') return new TextEncoder().encode(request.body)
165
+ return new Uint8Array(0)
166
+ }
167
+
168
+ /**
169
+ * Open one LWS storage on the engine and return the handle plus the two
170
+ * calls a host makes against it.
171
+ *
172
+ * The config members are the ABI's own: `baseIri`, `now`, `owner`,
173
+ * `agent`. Nothing else is sent.
174
+ *
175
+ * @param {object} options
176
+ * @param {string} [options.baseIri] the absolute IRI the storage root maps to
177
+ * @param {number} [options.now] the engine clock, seconds since the epoch
178
+ * @param {string} [options.owner] the WebID the storage advertises
179
+ * @param {string} [options.agent] the WebID of the requesting agent
180
+ * @param {boolean} [options.realClock] stamp each request with this host's clock
181
+ * @param {object} [options.engine] an engine handle to reuse
182
+ * @returns {Promise<{step: Function, close: Function, handle: string, engine: object}>}
183
+ */
184
+ export async function openLwsStorage (options = {}) {
185
+ const engine = options.engine !== undefined ? options.engine : await loadEngine()
186
+ const config = {}
187
+ if (typeof options.baseIri === 'string') config.baseIri = options.baseIri
188
+ if (typeof options.now === 'number') config.now = options.now
189
+ if (typeof options.owner === 'string') config.owner = options.owner
190
+ if (typeof options.agent === 'string') config.agent = options.agent
191
+ // Authorization (L4Factoidal/LWS/Authorization.lean). `auth` is passed
192
+ // through unread; `insecureNoAuth` is the explicit development mode.
193
+ // With neither, the engine enforces authorization with no trusted
194
+ // issuer.
195
+ if (options.auth !== undefined && options.auth !== null) config.auth = options.auth
196
+ if (options.insecureNoAuth === true) config.insecureNoAuth = true
197
+ if (typeof options.rootAcl === 'string') config.rootAcl = options.rootAcl
198
+ // `dir` names the storage directory. The snapshot found there, if any,
199
+ // is the storage to continue from; the engine checks it.
200
+ const dir = typeof options.dir === 'string' ? options.dir : null
201
+ if (dir !== null) {
202
+ const snapshot = readSnapshot(dir)
203
+ if (snapshot !== null) config.snapshot = snapshot
204
+ }
205
+ let opened
206
+ try {
207
+ opened = engine.call('lwsOpen', [JSON.stringify(config)])
208
+ } catch (error) {
209
+ if (isUnknownOp(error)) {
210
+ throw new LwsHostError(
211
+ 'the loaded l4factoidal WebAssembly module does not carry lwsOpen',
212
+ { unknownOp: true })
213
+ }
214
+ throw error
215
+ }
216
+ const handle = handleOf(opened)
217
+ if (handle === null) {
218
+ throw new LwsHostError('lwsOpen answered with no handle')
219
+ }
220
+ return {
221
+ engine,
222
+ handle,
223
+ /**
224
+ * One protocol step.
225
+ * @param {{method: string, target: string,
226
+ * headers: Array<[string, string]>, body: Uint8Array|string}} request
227
+ * @returns {{status: number, headers: Array<[string, string]>, body: Uint8Array}}
228
+ */
229
+ async step (request) {
230
+ const content = contentBytes(request)
231
+ const { body: _body, ...fields } = request
232
+ const record = options.realClock === true
233
+ ? { ...fields, now: Math.floor(Date.now() / 1000) }
234
+ : fields
235
+ const callStep = () => engine.callBlobIO('lwsStep', [handle, JSON.stringify(record)], content)
236
+ let answered = callStep()
237
+ let envelope = answered.envelope
238
+ // The engine may answer "fetch this document first" (an issuer's
239
+ // metadata or key set). The host fetches exactly what it is told,
240
+ // hands the bytes back, and steps the request again.
241
+ let fetches = 0
242
+ while (envelope !== null && typeof envelope === 'object' &&
243
+ envelope.fetch !== undefined && envelope.fetch !== null) {
244
+ let wanted = envelope.fetch
245
+ while (wanted !== undefined && wanted !== null) {
246
+ fetches += 1
247
+ if (fetches > MAX_FETCHES) {
248
+ throw new LwsHostError('the engine asked for more documents than one request may fetch')
249
+ }
250
+ const got = await fetchDocument(wanted.url)
251
+ const told = engine.call('lwsFetched', [handle, JSON.stringify({
252
+ issuer: wanted.issuer, url: wanted.url, status: got.status, body: got.body,
253
+ ...(typeof record.now === 'number' ? { now: record.now } : {})
254
+ })])
255
+ wanted = told !== null && typeof told === 'object' ? told.fetch : null
256
+ }
257
+ answered = callStep()
258
+ envelope = answered.envelope
259
+ }
260
+ const answer = responseOf(envelope, answered.bytes)
261
+ if (dir !== null) persistIfChanged(engine, 'lwsSnapshot', handle, envelope, dir)
262
+ return answer
263
+ },
264
+ close () {
265
+ try { engine.call('lwsClose', [handle]) } catch (_error) { /* already gone */ }
266
+ }
267
+ }
268
+ }
269
+
270
+ /** Read a Node request body as its bytes. */
271
+ function readBody (request) {
272
+ return new Promise((resolve, reject) => {
273
+ const chunks = []
274
+ request.on('data', (chunk) => chunks.push(chunk))
275
+ request.on('error', reject)
276
+ request.on('end', () => resolve(new Uint8Array(Buffer.concat(chunks))))
277
+ })
278
+ }
279
+
280
+ /** Node's flat `rawHeaders` array as the pairs the contract wants. */
281
+ function headerPairs (raw) {
282
+ const pairs = []
283
+ for (let index = 0; index + 1 < raw.length; index += 2) {
284
+ pairs.push([raw[index], raw[index + 1]])
285
+ }
286
+ return pairs
287
+ }
288
+
289
+ /**
290
+ * A Node `http.Server` that answers every request through `lwsStep`.
291
+ *
292
+ * The server is returned unlistened: the caller chooses the port, which
293
+ * is what lets a test take an ephemeral one. The storage handle is
294
+ * opened on demand rather than here, because with an ephemeral port the
295
+ * origin `baseIri` maps the storage root to is not known until the
296
+ * socket is bound.
297
+ *
298
+ * @param {object} options as `openLwsStorage`, plus `onError`
299
+ * @returns {Promise<{server: import('node:http').Server,
300
+ * storage: Function, close: Function,
301
+ * setBaseIri: Function}>}
302
+ */
303
+ export async function createLwsServer (options = {}) {
304
+ const http = await import('node:http')
305
+ const onError = typeof options.onError === 'function'
306
+ ? options.onError
307
+ : (error) => { console.error(`lws-serve: ${error.message}`) }
308
+
309
+ let settings = { ...options }
310
+ let opening = null
311
+ let opened = null
312
+
313
+ /** The storage handle, opened once. */
314
+ function storage () {
315
+ if (opening === null) {
316
+ opening = openLwsStorage(settings).then((value) => { opened = value; return value })
317
+ }
318
+ return opening
319
+ }
320
+
321
+ const server = http.createServer(async (request, response) => {
322
+ let record
323
+ try {
324
+ record = {
325
+ method: request.method,
326
+ target: request.url,
327
+ headers: headerPairs(request.rawHeaders),
328
+ body: await readBody(request)
329
+ }
330
+ } catch (error) {
331
+ onError(error)
332
+ response.writeHead(500, { 'content-type': 'text/plain' })
333
+ response.end('lws-serve: could not read the request body\n')
334
+ return
335
+ }
336
+ let answer
337
+ try {
338
+ answer = await (await storage()).step(record)
339
+ } catch (error) {
340
+ // A failure of this host or of the engine, not a protocol answer.
341
+ // The engine's own words are kept; nothing is invented.
342
+ onError(error)
343
+ response.writeHead(500, { 'content-type': 'text/plain' })
344
+ response.end(`lws-serve: ${error.message}\n`)
345
+ return
346
+ }
347
+ writeHeaders(response, answer.headers)
348
+ response.statusCode = answer.status
349
+ response.end(answer.body)
350
+ })
351
+
352
+ return {
353
+ server,
354
+ storage,
355
+ /** Set the storage root's absolute IRI, before the first request. */
356
+ setBaseIri (value) {
357
+ if (opening !== null) {
358
+ throw new LwsHostError('the storage is already open; set baseIri first')
359
+ }
360
+ settings = { ...settings, baseIri: value }
361
+ },
362
+ close () {
363
+ return new Promise((resolve) => {
364
+ server.close(() => {
365
+ if (opened !== null) opened.close()
366
+ resolve()
367
+ })
368
+ })
369
+ }
370
+ }
371
+ }
372
+
373
+ /** Write one response record's header pairs, keeping repeats. */
374
+ function writeHeaders (response, pairs) {
375
+ for (const pair of pairs) {
376
+ if (!Array.isArray(pair) || pair.length < 2) continue
377
+ const existing = response.getHeader(pair[0])
378
+ if (existing === undefined) {
379
+ response.setHeader(pair[0], String(pair[1]))
380
+ } else if (Array.isArray(existing)) {
381
+ response.setHeader(pair[0], existing.concat([String(pair[1])]))
382
+ } else {
383
+ response.setHeader(pair[0], [String(existing), String(pair[1])])
384
+ }
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Start a server and resolve once it is listening and the storage is
390
+ * open. The storage is opened here rather than on the first request so
391
+ * that a module without the LWS ops is reported by the command that
392
+ * started the server, not by a later request.
393
+ *
394
+ * @param {object} options as `createLwsServer`, plus `port` and `host`
395
+ * @returns {Promise<{server: object, storage: Function, port: number,
396
+ * origin: string, close: Function}>}
397
+ */
398
+ export async function listen (options = {}) {
399
+ const port = typeof options.port === 'number' ? options.port : 0
400
+ const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
401
+ const created = await createLwsServer(options)
402
+ await new Promise((resolve, reject) => {
403
+ created.server.once('error', reject)
404
+ created.server.listen(port, host, resolve)
405
+ })
406
+ const bound = created.server.address()
407
+ const origin = `http://${host}:${bound.port}`
408
+ if (typeof options.baseIri !== 'string') created.setBaseIri(`${origin}/`)
409
+ try {
410
+ await created.storage()
411
+ } catch (error) {
412
+ await new Promise((resolve) => created.server.close(resolve))
413
+ throw error
414
+ }
415
+ return { ...created, port: bound.port, origin }
416
+ }