@factoidal/core 0.10.0 → 0.12.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/lws/README.md CHANGED
@@ -27,12 +27,17 @@ host or the engine failed rather than answered.
27
27
  | op | arguments | answer |
28
28
  | --- | --- | --- |
29
29
  | `lwsOpen` | `[configJson]` | `{ ok, handle, root }` |
30
- | `lwsStep` | `[handle, requestJson]` | `{ ok, response }` |
30
+ | `lwsStep` | `[handle, requestJson]` | `{ ok, response, changed, deliveries?, wake? }`, or `{ ok, resolve: { host } }` |
31
31
  | `lwsClose` | `[handle]` | `{ ok }` |
32
+ | `lwsSnapshot` | `[handle]` | `{ ok, snapshot }` |
33
+ | `lwsFetched` | `[handle, documentJson]` | `{ ok, fetch? , installed?, refused? }` |
34
+ | `lwsDelivered` | `[handle, reportJson]` | `{ ok, changed, retry? }` |
35
+ | `lwsFlush` | `[handle, { now? }]` | `{ ok, changed, deliveries?, wake? }` |
32
36
 
33
37
  ```
34
- request { method, target, headers: [[name, value]], body }
35
- response { status, headers: [[name, value]], body }
38
+ request { method, target, headers: [[name, value]] } + the content as bytes
39
+ response { status, headers: [[name, value]], bodyBytes } + the content as bytes
40
+ (through callBlobIO; https://github.com/danbri/factoidal/issues/889)
36
41
  ```
37
42
 
38
43
  `headers` is a list of pairs, not an object: HTTP allows a field name to
@@ -43,7 +48,11 @@ unchanged and this host composes no IRI.
43
48
  `configJson` members, all optional: `baseIri` (the absolute IRI the
44
49
  storage root maps to), `now` (the engine clock in seconds, which advances
45
50
  by one second per mutation so a write sequence has increasing
46
- `Last-Modified` values with no real clock), `owner`, `agent`. A request
51
+ `Last-Modified` values with no real clock), `owner`, `agent`, `pageSize`
52
+ (the container pagination threshold and page size, default 100, 0 for no
53
+ pagination), `rootAcl`, `auth` and `insecureNoAuth` (below),
54
+ `notifications` (default true) and `entropy` (random bytes, base64, which
55
+ this host makes for every open). A request
47
56
  may carry `now` and `agent` of its own to override the handle's for one
48
57
  step; `listen({realClock: true})` is how a server with a real clock feeds
49
58
  one in, and a test leaves it off to keep the engine's deterministic
@@ -55,12 +64,103 @@ A WebAssembly module built before these ops landed answers `unknown op`.
55
64
  and every test in `tests/lws/` skips with the reason printed rather than
56
65
  passing on an engine that cannot answer.
57
66
 
67
+ ## Authorization
68
+
69
+ Authorization follows the draft's "Authorization" section
70
+ (https://github.com/danbri/factoidal/issues/886) and is decided by
71
+ `formal/lean4/L4Factoidal/LWS/Authorization.lean`. It is ON by default.
72
+
73
+ ```js
74
+ const running = await listen({
75
+ port: 3000,
76
+ owner: 'https://id.example/alice', // root ACL: Read, Write, Control
77
+ auth: { issuers: [{ issuer: 'https://as.example' }] },
78
+ realClock: true
79
+ })
80
+ ```
81
+
82
+ * Clients send `Authorization: Bearer <access token>` (RFC 6750). The
83
+ token is a JWT (RFC 9068) signed with ES256, RS256 or EdDSA by a trusted
84
+ issuer; `aud` must contain the storage IRI (`baseIri`, or `auth.audience`).
85
+ * Without a valid token the answer is 401 with
86
+ `WWW-Authenticate: Bearer resource_metadata="…"`; the RFC 9728 metadata
87
+ document is served at `/.well-known/oauth-protected-resource<path>`.
88
+ * The host fetches the issuer's `/.well-known/lws-configuration` and its
89
+ `jwks_uri` when the engine asks (`lwsStep` answers `{ fetch }`), passes
90
+ the bytes to `lwsFetched`, and steps again. `auth.issuers[].jwks` gives
91
+ the keys inline instead.
92
+ * `insecureNoAuth: true` serves every request with no authorization. It is
93
+ for development only, and must be asked for.
94
+
95
+ ## Pagination and client links
96
+
97
+ Both are decided in Lean; this host passes the fields through.
98
+
99
+ * A container with more than `pageSize` members (default 100) is listed in
100
+ pages (LWS §LWS Media Type, Pagination;
101
+ https://github.com/danbri/factoidal/issues/887). Each page answers 200
102
+ with the `Link` relations `first` and `last`, `next` on every page but the
103
+ last, and `prev` on every page but the first; the page URIs are
104
+ `CONTAINER?page=N`. `totalItems` counts the full membership. The members
105
+ are in increasing path order, each once; the theorem `pageItems_concat` in
106
+ `L4Factoidal/LWS/Operations.lean` states that the pages of an unchanged
107
+ container concatenate to the full listing. Each page has its own `ETag`,
108
+ which starts with the container's clock; the clock changes when the
109
+ membership changes.
110
+ * A client sets its own links (`license`, `author`, any extension relation)
111
+ with `Link` and `Prefer: set-linkset` on PUT (replace) or PATCH (add),
112
+ with `Link` on POST, or through the linkset resource `PATH?linkset` with
113
+ JSON Patch or a PUT of `application/linkset+json` (LWS §Metadata, §Update
114
+ Resource; https://github.com/danbri/factoidal/issues/888). The relations
115
+ `linkset`, `type`, `format`, `size`, `modified`, `up`, `items` and
116
+ `lws#storage` are server-managed: a request that sets or changes one gets
117
+ 409. A malformed link gets 400. Without `Prefer: set-linkset`, a `Link`
118
+ field on PUT and PATCH is not stored.
119
+
120
+ ## Notifications
121
+
122
+ LWS notifications and the webhook notification suite
123
+ (https://github.com/danbri/factoidal/issues/890) are decided by
124
+ `formal/lean4/L4Factoidal/LWS/Notifications.lean`. They are on unless
125
+ `notifications: false` is passed.
126
+
127
+ * The storage description at `/.well-known/lws-storage` names the
128
+ `NotificationService`: endpoint `/.well-known/lws-subscriptions/`, type
129
+ `WebhookSubscription`, and the Ed25519 key that signs deliveries.
130
+ * A client POSTs `{"type":"WebhookSubscription","topic":[IRI…],"inbox":URL}`
131
+ (`application/lws+json`, with a token when authorization is on) and gets
132
+ 201 with the subscription URL. GET on the endpoint lists the agent's
133
+ subscriptions; GET and DELETE manage one.
134
+ * After a write, `lwsStep` names the deliveries: `{ id, url, headers, body }`.
135
+ This host POSTs each one exactly as given, after the response, and reports
136
+ the status to `lwsDelivered`; when the engine answers `retry`, it waits
137
+ `after` seconds and sends the delivery it names. `storage().settled()`
138
+ resolves when no delivery is in flight.
139
+ * Rate limit (https://github.com/danbri/factoidal/issues/915): at most
140
+ `deliveryLimit` deliveries (default 10) per subscription in a window of
141
+ `deliveryWindow` seconds (default 60; `deliveryLimit: 0` turns it off).
142
+ The rest wait; an answer then carries `wake: SECONDS`, and this host calls
143
+ `lwsFlush` after that many seconds and sends the one batched delivery it
144
+ names.
145
+ * Inbox check (issue 915): when an inbox host is a name, `lwsStep` answers
146
+ `resolve: { host }`; this host resolves it (`dns.lookup`, every address)
147
+ and steps the request again with `resolved: [{ host, addresses }]`.
148
+ `listen()` passes `listen: { address, port, addresses }` to `lwsOpen`. The
149
+ engine refuses a container of this storage under another name of this
150
+ server.
151
+ * `controllerInbox: URL` (https://github.com/danbri/factoidal/issues/920):
152
+ each new access request is announced there with a `Create` notification.
153
+ * After a move, a request to a previous path answers 301 with `Location`
154
+ (https://github.com/danbri/factoidal/issues/914); `redirectsMax` (default
155
+ 256) bounds the table.
156
+
58
157
  ## Use
59
158
 
60
159
  ```js
61
160
  import { listen } from '@factoidal/core/lws'
62
161
 
63
- const running = await listen({ port: 3000, baseIri: 'https://storage.example/' })
162
+ const running = await listen({ port: 3000, baseIri: 'https://storage.example/',
163
+ insecureNoAuth: true }) // development only; see Authorization
64
164
  console.log(running.origin)
65
165
  await running.close()
66
166
  ```
@@ -68,9 +168,13 @@ await running.close()
68
168
  From the command:
69
169
 
70
170
  ```
71
- factoidal lws-serve /var/lib/lws --port 3000
171
+ factoidal lws-serve /var/lib/lws --port 3000 --issuer https://as.example --owner https://id.example/alice
172
+ factoidal lws-serve /tmp/dev --port 3000 --insecure-no-auth
72
173
  ```
73
174
 
175
+ One of `--issuer`, `--auth-config FILE` or `--insecure-no-auth` is
176
+ required.
177
+
74
178
  `--port 0` takes an ephemeral port and the command prints the one it got;
75
179
  `--base IRI` sets `baseIri`, which defaults to the bound origin. `DIR`
76
180
  names the storage directory, but the first slice keeps the resource tree
@@ -86,12 +190,15 @@ in the engine handle, so nothing is read from it or written to it yet
86
190
  | `openLwsStorage(options)` | open a handle and get `step` / `close` |
87
191
  | `createLwsServer(...).setBaseIri(iri)` | set `baseIri` before the first request |
88
192
  | `lwsOpsAvailable(engine)` | probe the loaded module for the ops |
89
- | `LWS_OPS` | the three op names |
193
+ | `LWS_OPS` | the op names |
90
194
  | `LwsHostError` | a host failure, with `unknownOp` set when the op is absent |
91
195
 
92
196
  ## Tests
93
197
 
94
198
  ```
95
199
  node tests/lws/server.mjs
200
+ node tests/lws/draft-binding.mjs
201
+ node tests/lws/binary-bodies.mjs
202
+ node tests/lws/authorization.mjs
96
203
  deno run --allow-read --allow-net --allow-write --allow-env tests/lws/server.mjs
97
204
  ```
@@ -0,0 +1,309 @@
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.
15
+ //
16
+ // ENCRYPTION AT REST (https://github.com/danbri/factoidal/issues/921)
17
+ // This file also reads the environment variable FACTOIDAL_LWS_KEY and
18
+ // draws 12 random bytes for every write. Both go to the engine unread: the
19
+ // key text as the open configuration's `storageKey`, the bytes as the
20
+ // `nonce` of the snapshot call. The engine decides everything else
21
+ // (`L4Factoidal/LWS/SnapshotSeal.lean`): whether the key is valid, whether
22
+ // the file in DIR is sealed, whether it authenticates, and the bytes of
23
+ // the sealed document. This file never inspects the document it writes. The write is: a temporary file in DIR,
24
+ // fsync, rename over the state file, fsync of DIR. A crash leaves the old
25
+ // snapshot or the new one, never a torn one.
26
+
27
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, existsSync } from 'node:fs'
28
+ import { dirname, join, resolve, sep } from 'node:path'
29
+
30
+ export const STATE_FILE = 'lws-state.json'
31
+
32
+ /** The environment variable that holds the storage key. */
33
+ export const STORAGE_KEY_VARIABLE = 'FACTOIDAL_LWS_KEY'
34
+
35
+ /**
36
+ * The text of the storage key: `explicit` when the caller gave a string,
37
+ * none when the caller gave `null`, and otherwise the environment variable
38
+ * (Node `process.env`, or `Deno.env` when only Deno's API is present). An
39
+ * empty value is no key. The engine checks the text.
40
+ */
41
+ export function storageKeyText (explicit) {
42
+ if (typeof explicit === 'string') return explicit
43
+ if (explicit === null) return undefined
44
+ let value
45
+ try {
46
+ if (globalThis.process !== undefined && globalThis.process.env !== undefined) {
47
+ value = globalThis.process.env[STORAGE_KEY_VARIABLE]
48
+ } else if (globalThis.Deno !== undefined) {
49
+ value = globalThis.Deno.env.get(STORAGE_KEY_VARIABLE)
50
+ }
51
+ } catch (_error) {
52
+ value = undefined
53
+ }
54
+ return typeof value === 'string' && value !== '' ? value : undefined
55
+ }
56
+
57
+ /** The environment variable that sets where `lws-serve DIR` keeps the
58
+ * storage when the caller names no `storage` (issue 918). */
59
+ export const STORAGE_MODE_VARIABLE = 'FACTOIDAL_LWS_STORAGE'
60
+
61
+ /**
62
+ * The text of the storage mode: `explicit` when the caller gave a string,
63
+ * and otherwise the environment variable, passed unread; the engine checks
64
+ * it. An empty value is none (the engine's default, the snapshot).
65
+ */
66
+ export function storageModeText (explicit) {
67
+ if (typeof explicit === 'string') return explicit
68
+ let value
69
+ try {
70
+ if (globalThis.process !== undefined && globalThis.process.env !== undefined) {
71
+ value = globalThis.process.env[STORAGE_MODE_VARIABLE]
72
+ } else if (globalThis.Deno !== undefined) {
73
+ value = globalThis.Deno.env.get(STORAGE_MODE_VARIABLE)
74
+ }
75
+ } catch (_error) {
76
+ value = undefined
77
+ }
78
+ return typeof value === 'string' && value !== '' ? value : undefined
79
+ }
80
+
81
+ /** 12 bytes from the platform's random source, base64: the nonce of one
82
+ * snapshot write. Pure I/O. */
83
+ function nonceBase64 () {
84
+ const bytes = new Uint8Array(12)
85
+ globalThis.crypto.getRandomValues(bytes)
86
+ let text = ''
87
+ for (const b of bytes) text += String.fromCharCode(b)
88
+ return btoa(text)
89
+ }
90
+
91
+ /** The absolute path of the state file of a storage directory. */
92
+ export function stateFileOf (dir) {
93
+ return join(resolve(dir), STATE_FILE)
94
+ }
95
+
96
+ /**
97
+ * The parsed snapshot in DIR, or `null` when DIR holds none yet. The
98
+ * engine checks the document; a file that does not parse as JSON is an
99
+ * error of this host, reported as such.
100
+ */
101
+ export function readSnapshot (dir) {
102
+ const file = stateFileOf(dir)
103
+ if (!existsSync(file)) return null
104
+ const text = readFileSync(file, 'utf8')
105
+ try {
106
+ return JSON.parse(text)
107
+ } catch (error) {
108
+ throw Object.assign(new Error(`${file} is not JSON: ${error.message}`), { stateFile: true })
109
+ }
110
+ }
111
+
112
+ /** Write a snapshot document to DIR, atomically. */
113
+ export function writeSnapshot (dir, snapshot) {
114
+ const root = resolve(dir)
115
+ mkdirSync(root, { recursive: true })
116
+ const file = stateFileOf(root)
117
+ const temporary = join(root, `.${STATE_FILE}.${process.pid}.tmp`)
118
+ const fd = openSync(temporary, 'w', 0o600)
119
+ try {
120
+ writeSync(fd, JSON.stringify(snapshot))
121
+ fsyncSync(fd)
122
+ } finally {
123
+ closeSync(fd)
124
+ }
125
+ renameSync(temporary, file)
126
+ try {
127
+ const dirFd = openSync(root, 'r')
128
+ try { fsyncSync(dirFd) } finally { closeSync(dirFd) }
129
+ } catch (_error) {
130
+ // Some platforms cannot fsync a directory; the rename stands.
131
+ }
132
+ }
133
+
134
+ /**
135
+ * After one step: when the engine says the storage changed, ask it for
136
+ * the snapshot (`snapshotOp`) and write it. The call carries a fresh random
137
+ * nonce; the engine uses it when the storage is sealed under a key.
138
+ */
139
+ export function persistIfChanged (engine, snapshotOp, handle, envelope, dir) {
140
+ if (typeof dir !== 'string' || envelope === null || typeof envelope !== 'object') return
141
+ if (envelope.changed !== true) return
142
+ const answer = engine.call(snapshotOp, [handle, JSON.stringify({ nonce: nonceBase64() })])
143
+ if (answer === null || typeof answer !== 'object' || answer.snapshot === undefined) {
144
+ throw new Error(`${snapshotOp} answered with no snapshot`)
145
+ }
146
+ writeSnapshot(dir, answer.snapshot)
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // A STORAGE KEPT IN FILES (https://github.com/danbri/factoidal/issues/918)
151
+ //
152
+ // With the open configuration `storage: "files"` the engine decides the
153
+ // directory layout and every byte (formal/lean4/L4Factoidal/LWS/FileLayout.lean,
154
+ // design record docs/designissues/2026-10-22-lws-storage-on-disk.md). This
155
+ // part of the file only performs what the engine names:
156
+ //
157
+ // * reads: the engine names files (paths relative to DIR); each is read
158
+ // whole, or reported absent;
159
+ // * a plan of file operations, in order, with their bytes in one region:
160
+ // write {path, temp, offset, length} parent directory, temp file,
161
+ // fsync, rename, fsync directory
162
+ // append {path, offset, length} append, fsync
163
+ // remove {path} remove; a missing file is no error
164
+ // rename {from, to} rename, fsync directory; a missing
165
+ // file is no error
166
+ //
167
+ // A name that leaves DIR is refused as a failure of this host; the engine
168
+ // never names one.
169
+
170
+ /** The absolute path of a name the engine gave, inside DIR. */
171
+ function insideDir (root, name) {
172
+ if (typeof name !== 'string' || name === '') throw new Error('lws files: the engine named no file')
173
+ const abs = resolve(root, name)
174
+ if (!abs.startsWith(root + sep)) throw new Error(`lws files: ${name} is outside the storage directory`)
175
+ return abs
176
+ }
177
+
178
+ /** fsync a directory; some platforms cannot, and the rename stands. */
179
+ function fsyncDir (dir) {
180
+ try {
181
+ const fd = openSync(dir, 'r')
182
+ try { fsyncSync(fd) } finally { closeSync(fd) }
183
+ } catch (_error) {
184
+ // Some platforms cannot fsync a directory.
185
+ }
186
+ }
187
+
188
+ /** `seed` for one engine call: 32 random bytes, base64. Pure I/O. */
189
+ function seedBase64 () {
190
+ const bytes = new Uint8Array(32)
191
+ globalThis.crypto.getRandomValues(bytes)
192
+ let text = ''
193
+ for (const b of bytes) text += String.fromCharCode(b)
194
+ return btoa(text)
195
+ }
196
+
197
+ /**
198
+ * Read the files the engine named. Returns the `files` member of the load
199
+ * document and the bytes of the present files, one after the other.
200
+ */
201
+ export function readNamedFiles (dir, names) {
202
+ const root = resolve(dir)
203
+ const files = []
204
+ const parts = []
205
+ let total = 0
206
+ for (const name of names) {
207
+ const abs = insideDir(root, name)
208
+ if (!existsSync(abs)) {
209
+ files.push({ path: name, present: false })
210
+ continue
211
+ }
212
+ const bytes = readFileSync(abs)
213
+ files.push({ path: name, present: true, length: bytes.length })
214
+ parts.push(bytes)
215
+ total += bytes.length
216
+ }
217
+ const region = new Uint8Array(total)
218
+ let at = 0
219
+ for (const part of parts) { region.set(part, at); at += part.length }
220
+ return { files, region }
221
+ }
222
+
223
+ /** Perform a plan of file operations exactly as the engine gave it. */
224
+ export function performPlan (dir, plan, bytes) {
225
+ const root = resolve(dir)
226
+ mkdirSync(root, { recursive: true })
227
+ const slice = (op) => bytes.subarray(op.offset, op.offset + op.length)
228
+ for (const op of Array.isArray(plan) ? plan : []) {
229
+ switch (op.op) {
230
+ case 'write': {
231
+ const path = insideDir(root, op.path)
232
+ const temp = insideDir(root, op.temp)
233
+ mkdirSync(dirname(path), { recursive: true })
234
+ const fd = openSync(temp, 'w', 0o600)
235
+ try {
236
+ writeSync(fd, slice(op))
237
+ fsyncSync(fd)
238
+ } finally {
239
+ closeSync(fd)
240
+ }
241
+ renameSync(temp, path)
242
+ fsyncDir(dirname(path))
243
+ break
244
+ }
245
+ case 'append': {
246
+ const path = insideDir(root, op.path)
247
+ mkdirSync(dirname(path), { recursive: true })
248
+ const fd = openSync(path, 'a', 0o600)
249
+ try {
250
+ writeSync(fd, slice(op))
251
+ fsyncSync(fd)
252
+ } finally {
253
+ closeSync(fd)
254
+ }
255
+ break
256
+ }
257
+ case 'remove': {
258
+ try { unlinkSync(insideDir(root, op.path)) } catch (error) {
259
+ if (error.code !== 'ENOENT') throw error
260
+ }
261
+ break
262
+ }
263
+ case 'rename': {
264
+ const to = insideDir(root, op.to)
265
+ try { renameSync(insideDir(root, op.from), to) } catch (error) {
266
+ if (error.code !== 'ENOENT') throw error
267
+ }
268
+ fsyncDir(dirname(to))
269
+ break
270
+ }
271
+ default:
272
+ throw new Error(`lws files: the engine named an unknown file operation ${String(op.op)}`)
273
+ }
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Open a storage kept in files: read what `lwsOpen` named, give it to
279
+ * `lwsLoad`, read what that names, until the engine is ready; then perform
280
+ * the plan of the start. An engine refusal is thrown as the engine's error.
281
+ */
282
+ export function loadFiles (engine, handle, opened, dir) {
283
+ let want = opened.load
284
+ for (let round = 0; round < 8; round += 1) {
285
+ const { files, region } = readNamedFiles(dir, Array.isArray(want.read) ? want.read : [])
286
+ const answered = engine.callBlobIO('lwsLoad', [handle, JSON.stringify({ files, seed: seedBase64() })], region)
287
+ const envelope = answered.envelope
288
+ if (envelope.ready === true) {
289
+ performPlan(dir, envelope.plan, answered.bytes)
290
+ return envelope
291
+ }
292
+ if (envelope.load === undefined || envelope.load === null) {
293
+ throw new Error('lwsLoad answered neither load nor ready')
294
+ }
295
+ want = envelope.load
296
+ }
297
+ throw new Error('lwsLoad asked for more reads than one start may make')
298
+ }
299
+
300
+ /**
301
+ * After one step of a storage kept in files: when the engine says the
302
+ * storage changed, ask it for the write plan (`lwsPersist`) and perform it.
303
+ */
304
+ export function persistFilesIfChanged (engine, handle, envelope, dir) {
305
+ if (typeof dir !== 'string' || envelope === null || typeof envelope !== 'object') return
306
+ if (envelope.changed !== true) return
307
+ const answered = engine.callBlobIO('lwsPersist', [handle, JSON.stringify({ seed: seedBase64() })], new Uint8Array(0))
308
+ performPlan(dir, answered.envelope.plan, answered.bytes)
309
+ }