@factoidal/core 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +565 -0
- package/README.md +76 -4
- package/api.d.ts +2 -3
- package/api.mjs +2 -2
- package/authz/server.mjs +331 -0
- package/bin/factoidal.mjs +510 -8
- package/browser.d.ts +3 -2
- package/browser.js +1 -2
- package/fn.js +8 -12
- package/index.d.ts +100 -35
- package/index.mjs +1 -2
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/api.mjs +96 -205
- package/lib/dataset.mjs +40 -45
- package/lib/delta-log-hosts.mjs +9 -8
- package/lib/errors.mjs +97 -0
- package/lib/lean-entry.mjs +15 -10
- package/lib/store.mjs +4 -5
- package/lib/version.mjs +1 -1
- package/lws/README.md +70 -3
- package/lws/client.mjs +319 -0
- package/lws/persist.mjs +253 -6
- package/lws/server.mjs +294 -10
- package/package.json +10 -1
- package/rdfjs.mjs +4 -2
- package/solid/server/index.mjs +7 -1
- package/xmpp/README.md +82 -4
- package/xmpp/server.mjs +549 -0
package/lws/client.mjs
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// A `fetch` host for the Linked Web Storage 1.0 client.
|
|
2
|
+
// https://github.com/danbri/factoidal/issues/917
|
|
3
|
+
// Design record: docs/designissues/2026-10-22-lws-client.md
|
|
4
|
+
// Drafts: https://w3c.github.io/lws-protocol/lws10-core/ (and lws10-index,
|
|
5
|
+
// lws10-notifications-webhook)
|
|
6
|
+
//
|
|
7
|
+
// WHAT THIS FILE IS ALLOWED TO DO
|
|
8
|
+
// Call `lwsClientStart` with the caller's operation, then loop: when the
|
|
9
|
+
// engine's action is `fetch`, send exactly that request with the `fetch`
|
|
10
|
+
// it was given and pass the answer to `lwsClientStep`; when it is `done` or
|
|
11
|
+
// `fail`, return or throw. It is a socket and nothing else.
|
|
12
|
+
//
|
|
13
|
+
// Every decision is in formal/lean4/L4Factoidal/LWS/Client/ (iron rule 7
|
|
14
|
+
// of CLAUDE.md, anti-pattern 36): which request comes next, every header
|
|
15
|
+
// field, every URL, every media type, the authorization flow and its
|
|
16
|
+
// discovery checks, preconditions, retries, paging, and whether a
|
|
17
|
+
// notification's signature verifies. This file reads no header, composes no
|
|
18
|
+
// URL and chooses no media type. A reviewer who finds a header name, a
|
|
19
|
+
// status code, a media type or a link relation here has found a rule
|
|
20
|
+
// violation.
|
|
21
|
+
//
|
|
22
|
+
// THE WASM CONTRACT (docs/lws-solid-conformance.md, section "wasm ABI")
|
|
23
|
+
// lwsClientStart [opJson, sessionJson] -> { ok, state, action }
|
|
24
|
+
// lwsClientStep [stateJson, eventJson] -> { ok, state, action }
|
|
25
|
+
//
|
|
26
|
+
// action { fetch: { method, url, headers: [[name, value]], bodyBase64? } }
|
|
27
|
+
// | { done: RESULT, session } | { fail: { code, ... }, session }
|
|
28
|
+
// event { status, headers: [[name, value]], bodyBase64 } | { error }
|
|
29
|
+
//
|
|
30
|
+
// `state` is passed back unread. `session` (access tokens, the subject
|
|
31
|
+
// token) is kept here and given to the next operation's start, unread.
|
|
32
|
+
//
|
|
33
|
+
// `fetch` is any function with the WHATWG fetch signature: Node's, a
|
|
34
|
+
// browser's, or one that carries each request as a message over another
|
|
35
|
+
// transport (XMPP, a worker port). The client needs no global fetch.
|
|
36
|
+
|
|
37
|
+
import { loadEngine } from '../bin/engine.mjs'
|
|
38
|
+
import { FactoidalError } from '../lib/errors.mjs'
|
|
39
|
+
|
|
40
|
+
/** The ops this host needs from the engine. */
|
|
41
|
+
export const LWS_CLIENT_OPS = ['lwsClientStart', 'lwsClientStep']
|
|
42
|
+
|
|
43
|
+
/** The operation kinds of the ABI. Listed so a caller can be checked
|
|
44
|
+
* against the contract; this host attaches no meaning to any of them. */
|
|
45
|
+
export const LWS_CLIENT_KINDS = [
|
|
46
|
+
'discover', 'read', 'head', 'create', 'update', 'patch', 'delete', 'readLinkset',
|
|
47
|
+
'updateLinkset', 'move', 'list', 'typeIndex', 'search', 'subscribe', 'unsubscribe',
|
|
48
|
+
'requestAccess', 'grantAccess', 'getAccess', 'revokeAccess', 'verifyNotification'
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
/** The error an operation ends with. `reason` is the engine's code
|
|
52
|
+
* (`discoveryValidation`, `preconditionFailed`, ...); `detail` is the
|
|
53
|
+
* engine's whole `fail` document. */
|
|
54
|
+
export class LwsClientError extends FactoidalError {
|
|
55
|
+
constructor (fail, extra = {}) {
|
|
56
|
+
const reason = fail && typeof fail.code === 'string' ? fail.code : 'unknown'
|
|
57
|
+
super(`lws client: ${reason}`, { code: 'lws.client' })
|
|
58
|
+
this.name = 'LwsClientError'
|
|
59
|
+
this.reason = reason
|
|
60
|
+
this.detail = fail
|
|
61
|
+
this.unknownOp = extra.unknownOp === true
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isUnknownOp (error) {
|
|
66
|
+
const text = error && error.message ? String(error.message) : String(error)
|
|
67
|
+
return text.indexOf('unknown op') >= 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Bytes as base64 (RFC 4648 section 4): the transport encoding of the
|
|
71
|
+
* string ABI, not a reading of the bytes. */
|
|
72
|
+
function base64OfBytes (bytes) {
|
|
73
|
+
if (typeof Buffer !== 'undefined') {
|
|
74
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64')
|
|
75
|
+
}
|
|
76
|
+
let text = ''
|
|
77
|
+
for (let index = 0; index < bytes.length; index += 1) text += String.fromCharCode(bytes[index])
|
|
78
|
+
return btoa(text)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function bytesOfBase64 (text) {
|
|
82
|
+
if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(text, 'base64'))
|
|
83
|
+
const raw = atob(text)
|
|
84
|
+
const out = new Uint8Array(raw.length)
|
|
85
|
+
for (let index = 0; index < raw.length; index += 1) out[index] = raw.charCodeAt(index)
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A caller's body (string, Uint8Array, ArrayBuffer) as bytes. */
|
|
90
|
+
function bytesOf (body) {
|
|
91
|
+
if (body === undefined || body === null) return null
|
|
92
|
+
if (typeof body === 'string') return new TextEncoder().encode(body)
|
|
93
|
+
if (body instanceof Uint8Array) return body
|
|
94
|
+
if (body instanceof ArrayBuffer) return new Uint8Array(body)
|
|
95
|
+
throw new TypeError('a body is a string, a Uint8Array or an ArrayBuffer')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** An operation document: the caller's members, with a body carried as
|
|
99
|
+
* base64. */
|
|
100
|
+
function opDocument (op) {
|
|
101
|
+
const doc = { ...op }
|
|
102
|
+
if (doc.body !== undefined) {
|
|
103
|
+
const bytes = bytesOf(doc.body)
|
|
104
|
+
delete doc.body
|
|
105
|
+
if (bytes !== null) doc.bodyBase64 = base64OfBytes(bytes)
|
|
106
|
+
}
|
|
107
|
+
return doc
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether the loaded engine carries the client operations.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} engine the handle from `loadEngine()`
|
|
114
|
+
* @returns {Promise<{available: boolean, reason: string}>}
|
|
115
|
+
*/
|
|
116
|
+
export async function lwsClientOpsAvailable (engine) {
|
|
117
|
+
try {
|
|
118
|
+
engine.call('lwsClientStart', [JSON.stringify({ kind: 'head', url: 'http://localhost/' }), ''])
|
|
119
|
+
return { available: true, reason: 'lwsClientStart answered' }
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (isUnknownOp(error)) {
|
|
122
|
+
return {
|
|
123
|
+
available: false,
|
|
124
|
+
reason: 'the loaded l4factoidal WebAssembly module does not carry the LWS client ops ' +
|
|
125
|
+
'(rebuild formal/lean4/Wasm)'
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { available: true, reason: 'lwsClientStart answered with an engine error' }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* An LWS client bound to one engine and one `fetch`.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} [options]
|
|
136
|
+
* @param {object} [options.engine] an engine handle to reuse
|
|
137
|
+
* @param {Function} [options.fetch] the fetch to use (default `globalThis.fetch`)
|
|
138
|
+
* @param {string} [options.subjectToken] the authentication credential to
|
|
139
|
+
* exchange for access tokens (RFC 8693 subject_token)
|
|
140
|
+
* @param {string} [options.subjectTokenType] its token type URI
|
|
141
|
+
* @param {object} [options.selfIssued] `{ subject, keyId, jwk }`: a P-256 key
|
|
142
|
+
* to sign self-issued credentials with, instead of a subject token
|
|
143
|
+
* @param {object} [options.session] a session from an earlier client
|
|
144
|
+
* @param {number} [options.maxSteps] the loop cap of one operation
|
|
145
|
+
* @returns {Promise<object>} the client
|
|
146
|
+
*/
|
|
147
|
+
export async function createLwsClient (options = {}) {
|
|
148
|
+
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
149
|
+
const wire = typeof options.fetch === 'function' ? options.fetch : globalThis.fetch
|
|
150
|
+
if (typeof wire !== 'function') throw new TypeError('no fetch is available in this runtime')
|
|
151
|
+
const maxSteps = typeof options.maxSteps === 'number' ? options.maxSteps : 1024
|
|
152
|
+
let session = options.session !== undefined && options.session !== null
|
|
153
|
+
? { ...options.session }
|
|
154
|
+
: {}
|
|
155
|
+
if (typeof options.subjectToken === 'string') session.subjectToken = options.subjectToken
|
|
156
|
+
if (typeof options.subjectTokenType === 'string') session.subjectTokenType = options.subjectTokenType
|
|
157
|
+
// `{ subject, keyId, jwk }`: a P-256 key the engine signs self-issued
|
|
158
|
+
// credentials with (lws10-authn-ssi-cid), passed through unread.
|
|
159
|
+
if (options.selfIssued !== undefined) session.selfIssued = options.selfIssued
|
|
160
|
+
|
|
161
|
+
function call (op, args) {
|
|
162
|
+
try {
|
|
163
|
+
return engine.call(op, args)
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (isUnknownOp(error)) {
|
|
166
|
+
throw new LwsClientError({ code: 'unknownOp', op }, { unknownOp: true })
|
|
167
|
+
}
|
|
168
|
+
throw error
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Send one request exactly as the engine wrote it; the answer as an
|
|
173
|
+
* event document. */
|
|
174
|
+
async function exchange (request) {
|
|
175
|
+
const init = {
|
|
176
|
+
method: request.method,
|
|
177
|
+
headers: Array.isArray(request.headers) ? request.headers : [],
|
|
178
|
+
redirect: 'manual'
|
|
179
|
+
}
|
|
180
|
+
if (typeof request.bodyBase64 === 'string') init.body = bytesOfBase64(request.bodyBase64)
|
|
181
|
+
let reply
|
|
182
|
+
try {
|
|
183
|
+
reply = await wire(request.url, init)
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return { error: error && error.message ? String(error.message) : String(error) }
|
|
186
|
+
}
|
|
187
|
+
const headers = []
|
|
188
|
+
reply.headers.forEach((value, name) => { headers.push([name, value]) })
|
|
189
|
+
const bytes = new Uint8Array(await reply.arrayBuffer())
|
|
190
|
+
// The host's clock and random source, which the engine cannot reach: the
|
|
191
|
+
// iat, exp, nonce and jti of a self-issued credential.
|
|
192
|
+
const random = new Uint8Array(48)
|
|
193
|
+
globalThis.crypto.getRandomValues(random)
|
|
194
|
+
return {
|
|
195
|
+
status: reply.status,
|
|
196
|
+
headers,
|
|
197
|
+
bodyBase64: base64OfBytes(bytes),
|
|
198
|
+
now: Math.floor(Date.now() / 1000),
|
|
199
|
+
randomBase64: base64OfBytes(random)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Run one operation to its end.
|
|
205
|
+
*
|
|
206
|
+
* @param {object} op `{ kind, url, ... }`; `body` may be a string or bytes
|
|
207
|
+
* @returns {Promise<object>} the engine's `done` document
|
|
208
|
+
*/
|
|
209
|
+
async function run (op) {
|
|
210
|
+
let envelope = call('lwsClientStart', [JSON.stringify(opDocument(op)), JSON.stringify(session)])
|
|
211
|
+
for (let step = 0; step < maxSteps; step += 1) {
|
|
212
|
+
const action = envelope.action || {}
|
|
213
|
+
if (action.fetch !== undefined) {
|
|
214
|
+
const event = await exchange(action.fetch)
|
|
215
|
+
envelope = call('lwsClientStep', [JSON.stringify(envelope.state), JSON.stringify(event)])
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
if (action.session !== undefined) session = action.session
|
|
219
|
+
if (action.done !== undefined) return action.done
|
|
220
|
+
throw new LwsClientError(action.fail)
|
|
221
|
+
}
|
|
222
|
+
throw new LwsClientError({ code: 'hostStepLimit', steps: maxSteps })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const with_ = (kind) => (url, args = {}) => run({ ...args, kind, url })
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
engine,
|
|
229
|
+
run,
|
|
230
|
+
/** The session (access tokens and subject token), for a later client. */
|
|
231
|
+
session: () => ({ ...session }),
|
|
232
|
+
discover: with_('discover'),
|
|
233
|
+
read: with_('read'),
|
|
234
|
+
head: with_('head'),
|
|
235
|
+
/** `create(containerUrl, { body, mediaType, slug, container, types, links })` */
|
|
236
|
+
create: with_('create'),
|
|
237
|
+
/** `update(url, { body, mediaType, precondition, tag, retry412, links })` */
|
|
238
|
+
update: with_('update'),
|
|
239
|
+
patch: with_('patch'),
|
|
240
|
+
/** `delete(url, { recursive, precondition, tag })` */
|
|
241
|
+
delete: with_('delete'),
|
|
242
|
+
readLinkset: with_('readLinkset'),
|
|
243
|
+
updateLinkset: with_('updateLinkset'),
|
|
244
|
+
/** `move(url, { destination })` */
|
|
245
|
+
move: with_('move'),
|
|
246
|
+
list: with_('list'),
|
|
247
|
+
typeIndex: with_('typeIndex'),
|
|
248
|
+
/** `search(anyUrlInTheStorage, { query })` */
|
|
249
|
+
search: with_('search'),
|
|
250
|
+
subscribe: with_('subscribe'),
|
|
251
|
+
unsubscribe: with_('unsubscribe'),
|
|
252
|
+
requestAccess: with_('requestAccess'),
|
|
253
|
+
grantAccess: with_('grantAccess'),
|
|
254
|
+
getAccess: with_('getAccess'),
|
|
255
|
+
revokeAccess: with_('revokeAccess'),
|
|
256
|
+
/** `verifyNotification(baseUrl, { requestTarget, method, headers, body })`:
|
|
257
|
+
* the engine's verdict `{ accept, status, reason? }`. */
|
|
258
|
+
verifyNotification: with_('verifyNotification')
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* An HTTP receiver for webhook notifications. For each request it asks the
|
|
264
|
+
* engine (`verifyNotification`) and answers with the status of the
|
|
265
|
+
* verdict; an accepted notification goes to `onNotification`. The receiver
|
|
266
|
+
* reads no header and decides nothing.
|
|
267
|
+
*
|
|
268
|
+
* @param {object} options
|
|
269
|
+
* @param {object} options.client a client from `createLwsClient`
|
|
270
|
+
* @param {Function} [options.onNotification] `(verdict, bodyBytes, request)`
|
|
271
|
+
* @param {number} [options.port] default 0 (an ephemeral port)
|
|
272
|
+
* @param {string} [options.host] default 127.0.0.1
|
|
273
|
+
* @returns {Promise<{ url: string, close: Function, received: Function }>}
|
|
274
|
+
* `url` is the receiver's base URL; any path under it receives
|
|
275
|
+
*/
|
|
276
|
+
export async function createNotificationReceiver (options) {
|
|
277
|
+
const { createServer } = await import('node:http')
|
|
278
|
+
const client = options.client
|
|
279
|
+
const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
|
|
280
|
+
let base = ''
|
|
281
|
+
const verdicts = []
|
|
282
|
+
const server = createServer((request, response) => {
|
|
283
|
+
const chunks = []
|
|
284
|
+
request.on('data', (chunk) => chunks.push(chunk))
|
|
285
|
+
request.on('end', async () => {
|
|
286
|
+
const body = new Uint8Array(Buffer.concat(chunks))
|
|
287
|
+
const headers = []
|
|
288
|
+
for (let index = 0; index + 1 < request.rawHeaders.length; index += 2) {
|
|
289
|
+
headers.push([request.rawHeaders[index], request.rawHeaders[index + 1]])
|
|
290
|
+
}
|
|
291
|
+
let verdict
|
|
292
|
+
try {
|
|
293
|
+
verdict = await client.verifyNotification(base, {
|
|
294
|
+
requestTarget: request.url, method: request.method, headers, body
|
|
295
|
+
})
|
|
296
|
+
} catch (error) {
|
|
297
|
+
response.statusCode = 500
|
|
298
|
+
response.end()
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
verdicts.push(verdict)
|
|
302
|
+
if (verdict.accept === true && typeof options.onNotification === 'function') {
|
|
303
|
+
try { options.onNotification(verdict, body, { target: request.url, headers }) } catch (_e) { /* the caller's */ }
|
|
304
|
+
}
|
|
305
|
+
response.statusCode = verdict.status
|
|
306
|
+
response.end()
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
await new Promise((resolve, reject) => {
|
|
310
|
+
server.once('error', reject)
|
|
311
|
+
server.listen(typeof options.port === 'number' ? options.port : 0, host, resolve)
|
|
312
|
+
})
|
|
313
|
+
base = `http://${host}:${server.address().port}/`
|
|
314
|
+
return {
|
|
315
|
+
url: base,
|
|
316
|
+
received: () => verdicts.slice(),
|
|
317
|
+
close: () => new Promise((resolve) => server.close(resolve))
|
|
318
|
+
}
|
|
319
|
+
}
|
package/lws/persist.mjs
CHANGED
|
@@ -11,15 +11,83 @@
|
|
|
11
11
|
// DIR/lws-state.json the snapshot, replaced atomically
|
|
12
12
|
//
|
|
13
13
|
// No request target ever becomes a file name here, so a request cannot
|
|
14
|
-
// address a file outside DIR.
|
|
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,
|
|
15
24
|
// fsync, rename over the state file, fsync of DIR. A crash leaves the old
|
|
16
25
|
// snapshot or the new one, never a torn one.
|
|
17
26
|
|
|
18
|
-
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync, existsSync } from 'node:fs'
|
|
19
|
-
import { join, resolve } from 'node:path'
|
|
27
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, existsSync } from 'node:fs'
|
|
28
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
20
29
|
|
|
21
30
|
export const STATE_FILE = 'lws-state.json'
|
|
22
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
|
+
|
|
23
91
|
/** The absolute path of the state file of a storage directory. */
|
|
24
92
|
export function stateFileOf (dir) {
|
|
25
93
|
return join(resolve(dir), STATE_FILE)
|
|
@@ -37,7 +105,7 @@ export function readSnapshot (dir) {
|
|
|
37
105
|
try {
|
|
38
106
|
return JSON.parse(text)
|
|
39
107
|
} catch (error) {
|
|
40
|
-
throw new Error(`${file} is not JSON: ${error.message}`)
|
|
108
|
+
throw Object.assign(new Error(`${file} is not JSON: ${error.message}`), { stateFile: true })
|
|
41
109
|
}
|
|
42
110
|
}
|
|
43
111
|
|
|
@@ -65,14 +133,193 @@ export function writeSnapshot (dir, snapshot) {
|
|
|
65
133
|
|
|
66
134
|
/**
|
|
67
135
|
* After one step: when the engine says the storage changed, ask it for
|
|
68
|
-
* the snapshot (`snapshotOp`) and write it.
|
|
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.
|
|
69
138
|
*/
|
|
70
139
|
export function persistIfChanged (engine, snapshotOp, handle, envelope, dir) {
|
|
71
140
|
if (typeof dir !== 'string' || envelope === null || typeof envelope !== 'object') return
|
|
72
141
|
if (envelope.changed !== true) return
|
|
73
|
-
const answer = engine.call(snapshotOp, [handle])
|
|
142
|
+
const answer = engine.call(snapshotOp, [handle, JSON.stringify({ nonce: nonceBase64() })])
|
|
74
143
|
if (answer === null || typeof answer !== 'object' || answer.snapshot === undefined) {
|
|
75
144
|
throw new Error(`${snapshotOp} answered with no snapshot`)
|
|
76
145
|
}
|
|
77
146
|
writeSnapshot(dir, answer.snapshot)
|
|
78
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. At start (`lwsLoad`) and before a step that
|
|
159
|
+
// needs the bytes of a resource (a `read` answer of `lwsStep`, given to
|
|
160
|
+
// `lwsRead`, stage 2 of issue 918);
|
|
161
|
+
// * a plan of file operations, in order, with their bytes in one region:
|
|
162
|
+
// write {path, temp, offset, length} parent directory, temp file,
|
|
163
|
+
// fsync, rename, fsync directory
|
|
164
|
+
// append {path, offset, length} append, fsync
|
|
165
|
+
// remove {path} remove; a missing file is no error
|
|
166
|
+
// rename {from, to} rename, fsync directory; a missing
|
|
167
|
+
// file is no error
|
|
168
|
+
//
|
|
169
|
+
// A name that leaves DIR is refused as a failure of this host; the engine
|
|
170
|
+
// never names one.
|
|
171
|
+
|
|
172
|
+
/** The absolute path of a name the engine gave, inside DIR. */
|
|
173
|
+
function insideDir (root, name) {
|
|
174
|
+
if (typeof name !== 'string' || name === '') throw new Error('lws files: the engine named no file')
|
|
175
|
+
const abs = resolve(root, name)
|
|
176
|
+
if (!abs.startsWith(root + sep)) throw new Error(`lws files: ${name} is outside the storage directory`)
|
|
177
|
+
return abs
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** fsync a directory; some platforms cannot, and the rename stands. */
|
|
181
|
+
function fsyncDir (dir) {
|
|
182
|
+
try {
|
|
183
|
+
const fd = openSync(dir, 'r')
|
|
184
|
+
try { fsyncSync(fd) } finally { closeSync(fd) }
|
|
185
|
+
} catch (_error) {
|
|
186
|
+
// Some platforms cannot fsync a directory.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** `seed` for one engine call: 32 random bytes, base64. Pure I/O. */
|
|
191
|
+
function seedBase64 () {
|
|
192
|
+
const bytes = new Uint8Array(32)
|
|
193
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
194
|
+
let text = ''
|
|
195
|
+
for (const b of bytes) text += String.fromCharCode(b)
|
|
196
|
+
return btoa(text)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Read the files the engine named. Returns the `files` member of the load
|
|
201
|
+
* document and the bytes of the present files, one after the other.
|
|
202
|
+
*/
|
|
203
|
+
export function readNamedFiles (dir, names) {
|
|
204
|
+
const root = resolve(dir)
|
|
205
|
+
const files = []
|
|
206
|
+
const parts = []
|
|
207
|
+
let total = 0
|
|
208
|
+
for (const name of names) {
|
|
209
|
+
const abs = insideDir(root, name)
|
|
210
|
+
if (!existsSync(abs)) {
|
|
211
|
+
files.push({ path: name, present: false })
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
const bytes = readFileSync(abs)
|
|
215
|
+
files.push({ path: name, present: true, length: bytes.length })
|
|
216
|
+
parts.push(bytes)
|
|
217
|
+
total += bytes.length
|
|
218
|
+
}
|
|
219
|
+
const region = new Uint8Array(total)
|
|
220
|
+
let at = 0
|
|
221
|
+
for (const part of parts) { region.set(part, at); at += part.length }
|
|
222
|
+
return { files, region }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Perform a plan of file operations exactly as the engine gave it. */
|
|
226
|
+
export function performPlan (dir, plan, bytes) {
|
|
227
|
+
const root = resolve(dir)
|
|
228
|
+
mkdirSync(root, { recursive: true })
|
|
229
|
+
const slice = (op) => bytes.subarray(op.offset, op.offset + op.length)
|
|
230
|
+
for (const op of Array.isArray(plan) ? plan : []) {
|
|
231
|
+
switch (op.op) {
|
|
232
|
+
case 'write': {
|
|
233
|
+
const path = insideDir(root, op.path)
|
|
234
|
+
const temp = insideDir(root, op.temp)
|
|
235
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
236
|
+
const fd = openSync(temp, 'w', 0o600)
|
|
237
|
+
try {
|
|
238
|
+
writeSync(fd, slice(op))
|
|
239
|
+
fsyncSync(fd)
|
|
240
|
+
} finally {
|
|
241
|
+
closeSync(fd)
|
|
242
|
+
}
|
|
243
|
+
renameSync(temp, path)
|
|
244
|
+
fsyncDir(dirname(path))
|
|
245
|
+
break
|
|
246
|
+
}
|
|
247
|
+
case 'append': {
|
|
248
|
+
const path = insideDir(root, op.path)
|
|
249
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
250
|
+
const fd = openSync(path, 'a', 0o600)
|
|
251
|
+
try {
|
|
252
|
+
writeSync(fd, slice(op))
|
|
253
|
+
fsyncSync(fd)
|
|
254
|
+
} finally {
|
|
255
|
+
closeSync(fd)
|
|
256
|
+
}
|
|
257
|
+
break
|
|
258
|
+
}
|
|
259
|
+
case 'remove': {
|
|
260
|
+
try { unlinkSync(insideDir(root, op.path)) } catch (error) {
|
|
261
|
+
if (error.code !== 'ENOENT') throw error
|
|
262
|
+
}
|
|
263
|
+
break
|
|
264
|
+
}
|
|
265
|
+
case 'rename': {
|
|
266
|
+
const to = insideDir(root, op.to)
|
|
267
|
+
try { renameSync(insideDir(root, op.from), to) } catch (error) {
|
|
268
|
+
if (error.code !== 'ENOENT') throw error
|
|
269
|
+
}
|
|
270
|
+
fsyncDir(dirname(to))
|
|
271
|
+
break
|
|
272
|
+
}
|
|
273
|
+
default:
|
|
274
|
+
throw new Error(`lws files: the engine named an unknown file operation ${String(op.op)}`)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Open a storage kept in files: read what `lwsOpen` named, give it to
|
|
281
|
+
* `lwsLoad`, read what that names, until the engine is ready; then perform
|
|
282
|
+
* the plan of the start. An engine refusal is thrown as the engine's error.
|
|
283
|
+
*/
|
|
284
|
+
export function loadFiles (engine, handle, opened, dir) {
|
|
285
|
+
let want = opened.load
|
|
286
|
+
for (let round = 0; round < 8; round += 1) {
|
|
287
|
+
const { files, region } = readNamedFiles(dir, Array.isArray(want.read) ? want.read : [])
|
|
288
|
+
const answered = engine.callBlobIO('lwsLoad', [handle, JSON.stringify({ files, seed: seedBase64() })], region)
|
|
289
|
+
const envelope = answered.envelope
|
|
290
|
+
if (envelope.ready === true) {
|
|
291
|
+
performPlan(dir, envelope.plan, answered.bytes)
|
|
292
|
+
return envelope
|
|
293
|
+
}
|
|
294
|
+
if (envelope.load === undefined || envelope.load === null) {
|
|
295
|
+
throw new Error('lwsLoad answered neither load nor ready')
|
|
296
|
+
}
|
|
297
|
+
want = envelope.load
|
|
298
|
+
}
|
|
299
|
+
throw new Error('lwsLoad asked for more reads than one start may make')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Before one step of a storage kept in files (stage 2): read the body files
|
|
304
|
+
* the engine named in a `read` answer and give them to `lwsRead`. The engine
|
|
305
|
+
* checks each one (seal, length, digests); a refusal is thrown as its error.
|
|
306
|
+
*/
|
|
307
|
+
export function readStepBodies (engine, handle, names, dir) {
|
|
308
|
+
const { files, region } = readNamedFiles(dir, Array.isArray(names) ? names : [])
|
|
309
|
+
const answered = engine.callBlobIO('lwsRead', [handle, JSON.stringify({ files })], region)
|
|
310
|
+
const envelope = answered.envelope
|
|
311
|
+
if (envelope === null || typeof envelope !== 'object' || envelope.ok !== true) {
|
|
312
|
+
throw new Error(`lwsRead: ${envelope !== null && typeof envelope === 'object' ? envelope.error : 'no answer'}`)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* After one step of a storage kept in files: when the engine says the
|
|
318
|
+
* storage changed, ask it for the write plan (`lwsPersist`) and perform it.
|
|
319
|
+
*/
|
|
320
|
+
export function persistFilesIfChanged (engine, handle, envelope, dir) {
|
|
321
|
+
if (typeof dir !== 'string' || envelope === null || typeof envelope !== 'object') return
|
|
322
|
+
if (envelope.changed !== true) return
|
|
323
|
+
const answered = engine.callBlobIO('lwsPersist', [handle, JSON.stringify({ seed: seedBase64() })], new Uint8Array(0))
|
|
324
|
+
performPlan(dir, answered.envelope.plan, answered.bytes)
|
|
325
|
+
}
|