@factoidal/core 0.12.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 CHANGED
@@ -1,5 +1,188 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0
4
+
5
+ **A parsed document is a set of quads** (RDF 1.1 Concepts §3 and §4;
6
+ https://github.com/danbri/factoidal/issues/972). Up to 0.12.0 a document
7
+ that stated the same triple twice gave a graph with two copies: `size`
8
+ counted 2 and `SELECT (COUNT(*) AS ?n)` answered 2. Every parser, the
9
+ streamed parse, the command's `query` and the stores now keep one copy.
10
+ Language tags compare case-insensitively (RDF 1.2 Concepts §3.4), so
11
+ `"a"@en` and `"a"@EN` are one term in a graph and in a store dictionary
12
+ (https://github.com/danbri/factoidal/issues/977).
13
+
14
+ SPARQL and RDF semantics fixes:
15
+
16
+ - `=` and `!=` follow RDFterm-equal (SPARQL 1.1 §17.4.1.7). Terms of
17
+ different kinds (IRI, blank node, literal, triple term) give `=` false.
18
+ Two literals that are different terms give a type error, except where
19
+ the §17.3.1 extension of the W3C tests gives false: `rdf:langString`
20
+ against another datatype, and `xsd:dateTime` against `xsd:date`. The
21
+ SPARQL 1.0 open-world tests: 18 pass, 0 fail (out of 18); were 13 pass,
22
+ 5 fail.
23
+ - `rdf:XMLLiteral` values compare by RDF term equality; SPARQL `=` on two
24
+ different XMLLiteral terms is a type error
25
+ (https://github.com/danbri/factoidal/issues/963). The RDF/XML parser's
26
+ exclusive XML canonicalization is repaired.
27
+ - A query with `FROM` clauses queries the RDF merge of those graphs
28
+ (SPARQL 1.1 §13.2.1): blank nodes from different `FROM` graphs are no
29
+ longer joined (https://github.com/danbri/factoidal/issues/979).
30
+ - RDFS closure rule rdfs9 accepts a blank node as the sub-class
31
+ (RDF 1.1 Semantics §9.2; https://github.com/danbri/factoidal/issues/982).
32
+ - The RDF/XML writer keeps the base direction of a directional language
33
+ string (`its:dir` with `rdf:version="1.2"`). The JSON-LD writer still
34
+ drops it: https://github.com/danbri/factoidal/issues/984.
35
+ - Opening a dataset renames blank nodes inside triple terms as well as at
36
+ the top level.
37
+ - `factoidal sample-store` under Deno printed its path three times: the
38
+ pack and the activate each re-executed the whole command. It now
39
+ re-executes once, before any work.
40
+
41
+ **`proofCheck` no longer joins blank nodes across artifacts**
42
+ (https://github.com/danbri/factoidal/issues/976). Up to 0.12.0 a bundle
43
+ could take `:A rdfs:subClassOf _:x` from one graph artifact and
44
+ `_:x rdfs:subClassOf :C` from another with the same RDFC-1.0 digest, and
45
+ conclude `:A rdfs:subClassOf :C` with `valid: true`. Blank nodes are now
46
+ scoped to the artifact or assumption they come from (RDF 1.1 Semantics
47
+ section 5.2), so that bundle is refused. A chain inside one artifact, and
48
+ a chain across artifacts through triples with no blank node, are still
49
+ accepted. The report has a new member `rejection`: `null` for a valid
50
+ bundle, else the first failed check in words. The bundle format and the
51
+ profile `fpp0/1` are unchanged.
52
+
53
+ **XMPP server from wasm.** `factoidal xmpp-serve DIR` and
54
+ `@factoidal/core/xmpp/server` run the Lean XMPP server
55
+ (`L4Factoidal.XMPP.Server` — RFC 6120 negotiation, SASL PLAIN and
56
+ SCRAM-SHA-256, binding, RFC 6121 roster/presence/messages) in the
57
+ WebAssembly engine, one engine session per TCP or direct-TLS (XEP-0368)
58
+ connection. New engine ops `xmppOpen`, `xmppSessionOpen`,
59
+ `xmppSessionFeed`, `xmppSessionDeliver`, `xmppSessionClose`,
60
+ `xmppRosterGet`, `xmppRosterSet`, `xmppClose`
61
+ (`formal/lean4/Wasm/Ops/XmppSession.lean`). The state directory is the
62
+ native carrier's layout (`accounts`, `roster/`, `spool/`), so a stanza
63
+ written as a file under `spool/<bare jid>/` is delivered to that
64
+ account. See `xmpp/README.md`.
65
+
66
+ Linked Web Storage kept in files, stage 2 (https://github.com/danbri/factoidal/issues/918;
67
+ design record `docs/designissues/2026-10-22-lws-storage-on-disk.md`):
68
+
69
+ - Stage 2 (`L4Factoidal/LWS/Bodies.lean`): with `--storage files` the bodies
70
+ stay in their body files. An entry keeps its length, its FNV-1a and SHA-256
71
+ digests and the types of a Turtle body, and no bytes (ACL resources keep
72
+ theirs). A step that needs bytes (GET body or range, JSON Patch, a PUT a
73
+ subscription may hear of, a move of a Turtle resource) answers
74
+ `read: [PATH, ...]`; the host reads those files and gives them to the new op
75
+ `lwsRead` (byte-region entry only), which checks them, and steps again. A
76
+ start reads no body file unless the metadata lacks a fact; the peak RSS of a
77
+ start and one GET of 1,000 and 4,000 resources of 16 KiB is 230 and 211 MiB
78
+ (stage 1: 258 and 515 MiB). A changed body file is refused when it is read
79
+ (the request answers 500), no longer at start.
80
+ - The LWS handles run a tree store (`TreeStore`, theorem `treeStoreLaws`) and
81
+ a type index in persistent trees: the engine's step and persist of a PUT
82
+ take 0.30, 0.27, 0.28 and 0.29 ms in a storage of 1,000, 2,000, 4,000 and
83
+ 16,000 resources (stage 1: 0.21 ms at 1,000, about 3 ms at 16,000). A POST
84
+ no longer lists every path; a DELETE lists paths only for a container.
85
+ - The order of the entries of a snapshot and of `lws-files.json`, and of the
86
+ activities of a recursive DELETE in one notification, is path order (was
87
+ newest first). `lwsSnapshot` of a storage kept in files is refused.
88
+ - `solid-serve` keeps the snapshot: an expected failure naming issue 918.
89
+
90
+ An authorization server as a component (https://github.com/danbri/factoidal/issues/916;
91
+ design record `docs/designissues/2026-10-22-authorization-server.md`; skill
92
+ `skills/authz-server/SKILL.md`), decided in Lean (`L4Factoidal/Authz/`):
93
+
94
+ - `factoidal authz-serve DIR --resource IRI[,IRI...]` and
95
+ `import { listen } from '@factoidal/core/authz'`: RFC 8414 metadata at
96
+ `/.well-known/lws-configuration` (and `/.well-known/oauth-authorization-server`),
97
+ a JWK Set, and an RFC 8693 token endpoint that exchanges a subject token for
98
+ an RFC 9068 access token whose `aud` is exactly the requested resource
99
+ (lws10-core "Authorization"). `lws-serve --issuer <its issuer>` accepts its
100
+ tokens with no other setting. `factoidal authz-keys DIR` prints the JWK Set.
101
+ - Subject tokens: OpenID Connect ID tokens (lws10-authn-openid) and
102
+ self-issued JWTs for https and did:key subjects (lws10-authn-ssi-cid; P-256
103
+ and Ed25519 did:keys). SAML 2.0 is refused and not advertised: there is no
104
+ XML Signature verifier (https://github.com/danbri/factoidal/issues/926).
105
+ solid-serve does not accept these tokens yet
106
+ (https://github.com/danbri/factoidal/issues/928).
107
+ - Documents the server fetches for a subject token: `https` only (`--allow-http`
108
+ for tests), never from a loopback, link-local or private address
109
+ (`--allow-private-fetch` for tests), from the address the engine checked,
110
+ with no redirect.
111
+ - Keys: ES256 by default (`--alg EdDSA`), signed by HACL\* through three new
112
+ P-256 externs (sign, secret-to-public, point decompression; RFC 6979 and
113
+ Wycheproof vectors in `lake exe l4crypto-probe`); `kid` is the RFC 7638
114
+ thumbprint; `--rotate-after` rotates and keeps the old key published until
115
+ its tokens expire; `DIR/authz-keys.json` (mode 0600) is sealed under
116
+ `FACTOIDAL_LWS_KEY` when it is set.
117
+ - New ops `authzOpen`, `authzStep`, `authzFetched`, `authzKeys`, `authzRotate`,
118
+ `authzClose`.
119
+ - Theorems: `issueToken_verifies_published` and `issueToken_audience_exact`
120
+ hold because of a run-time check of every issued token (the proof that
121
+ removes the check: https://github.com/danbri/factoidal/issues/927);
122
+ `exchange_refused_no_token` (a subject token that fails validation never
123
+ yields an access token), `cid_valid_verified`, `didKey_valid_verified`,
124
+ `oidc_valid_named_provider`, `chooseAddress_refuses_private` and others.
125
+ - Touchstone with this server in place of its reference one
126
+ (`tools/lws-touchstone.sh --config auth --authz factoidal`, 2026-10-22):
127
+ 212 passed, 0 failed, 9 inapplicable (out of 221); of these
128
+ core/authorization_server 9 pass, 0 fail (out of 9), auth/oidc 6 pass,
129
+ 0 fail (out of 6), auth/cid 22 pass, 0 fail (out of 22), auth/saml 3
130
+ inapplicable (issue 926).
131
+
132
+ CORS in the LWS HTTP binding (https://github.com/danbri/factoidal/issues/923),
133
+ decided in Lean (`L4Factoidal/HTTP/Cors.lean`, `L4Factoidal/LWS/Cors.lean`), so
134
+ a page on another origin (for example GitHub Pages) can call `lws-serve`:
135
+
136
+ - A CORS-preflight request (`OPTIONS` with `Access-Control-Request-Method`
137
+ and `Origin`) is answered 204 before any other decision, with no token and
138
+ no access check: `Access-Control-Allow-Methods` (with `QUERY` and `PATCH`),
139
+ `-Allow-Headers` (`Authorization`, `Content-Type`, `If-Match`,
140
+ `If-None-Match`, `If-Modified-Since`, `If-Unmodified-Since`, `Link`,
141
+ `Prefer`, `Slug`, `Range`, `DPoP`, `Accept`), `-Max-Age` 7200. An `OPTIONS`
142
+ without `Access-Control-Request-Method` is answered as before.
143
+ - Every answer carries `Vary: Origin` (one `Vary` field with the answer's
144
+ own), and, for an answered origin, `Access-Control-Allow-Origin` and
145
+ `Access-Control-Expose-Headers` (`ETag`, `Link`, `Location`, `Allow`,
146
+ `Accept-Patch`, `Accept-Post`, `Accept-Put`, `Accept-Query`,
147
+ `Accept-Ranges`, `Content-Location`, `Content-Range`, `WWW-Authenticate`,
148
+ `Preference-Applied`, `Repr-Digest`, `Last-Modified`,
149
+ `Factoidal-Merkle-Root`). No `Access-Control-Allow-Credentials`: the token
150
+ goes in `Authorization`.
151
+ - Default: echo a valid request `Origin` (Solid Protocol §8.1), never `null`.
152
+ `lws-serve --cors-origin ORIGIN` (repeatable) answers only those origins;
153
+ `--no-cors` sends no CORS field; config member `cors` (`false` or a list).
154
+ A malformed origin stops the start.
155
+ - `authz-serve` gets the same (`Authz.stepCors`; methods `GET, HEAD, OPTIONS,
156
+ POST`), with the same flags. `solid-serve` keeps its own Solid §8.1 CORS;
157
+ its preflight is now answered before Web Access Control (it was 401 for a
158
+ resource that is not public).
159
+
160
+ An LWS client (https://github.com/danbri/factoidal/issues/917; design record
161
+ `docs/designissues/2026-10-22-lws-client.md`):
162
+
163
+ - `@factoidal/core/lws/client`: `createLwsClient({ fetch, subjectToken,
164
+ subjectTokenType })` with `discover`, `read`, `head`, `create`, `update`,
165
+ `patch`, `delete`, `readLinkset`, `updateLinkset`, `move`, `list`,
166
+ `typeIndex`, `search`, `subscribe`, `unsubscribe`, `requestAccess`,
167
+ `grantAccess`, `getAccess`, `revokeAccess`, `verifyNotification`; and
168
+ `createNotificationReceiver`, a webhook receiver that answers each delivery
169
+ with the engine's verdict. `fetch` is injected (Node, a browser, or a
170
+ transport such as XMPP).
171
+ - Every decision is in Lean (`L4Factoidal/LWS/Client/`, wasm ops
172
+ `lwsClientStart` and `lwsClientStep`): the 401 flow with the three checks of
173
+ lws10-core "Discovery Validation by a Client", RFC 8414 metadata and RFC 8693
174
+ token exchange; conditional writes (the current ETag by default for
175
+ `update`, `patch`, `updateLinkset`, `move`); no repeat after 405 or 415, no
176
+ POST again after a 5xx or a lost answer; paging by `rel="next"` only;
177
+ linkset writes only in advertised methods and formats; the RFC 9421 check of
178
+ a notification. Theorems: `walk_concat`, `walk_members_once`,
179
+ `step_write_precondition`, `step_no_token_request_without_validation`,
180
+ `step_no_blind_create_retry`, `step_no_repeat_after_refusal`.
181
+ - CLI `factoidal lws-client <op> URL`.
182
+ - Self-issued credentials (lws10-authn-ssi-cid): `createLwsClient({ selfIssued:
183
+ { subject, keyId, jwk } })`; the engine signs the ES256 JWT with HACL*.
184
+ - Expected failure: OpenID sign-in is not built (row names issue 917).
185
+
3
186
  ## 0.12.0 — 2026-10-22
4
187
 
5
188
  One error model for the whole package
package/README.md CHANGED
@@ -35,7 +35,8 @@ RDF 1.2 input uses `turtle12`, `ntriples12`, `nquads12` or `trig12`.
35
35
  - Syntaxes: N-Triples, N-Quads, Turtle, TriG, RDF/XML, JSON-LD; RDF 1.1 and 1.2
36
36
  - SPARQL 1.1 and 1.2 query and update; GeoSPARQL functions, text search,
37
37
  JavaScript extension functions, SERVICE
38
- - Reasoning: RDFS, OWL 2 RL closure, OWL 2 DL consistency and entailment, RIF Core
38
+ - Reasoning: RDFS, OWL 2 RL closure, restricted checked OWL consistency,
39
+ experimental OWL entailment, RIF Core
39
40
  - Validation: SHACL, ShEx, JSON Schema, Schematron
40
41
  - Mapping: CSVW, RML, JSON-LD
41
42
  - Canonicalization: RDFC-1.0; Verifiable Credentials Data Integrity (eddsa-rdfc-2022), did:key
@@ -148,6 +149,17 @@ $ npx factoidal solid-serve DIR # Solid Protocol
148
149
 
149
150
  Official test suites, pass / fail / total, 2026-10-09 [B] unless marked.
150
151
 
152
+ The OWL DL rows below are historical measurements of the legacy harness;
153
+ they do not measure the current `owlIsConsistent` API. That API now checks
154
+ only an admitted atomic fragment of the default graph. It returns `false`
155
+ for a checked contradiction and `null` for unsupported input or an incomplete
156
+ check; it never returns `true`. Restrictions, negative property assertions,
157
+ imports and equivalentClass are unsupported, and `fuel` does not change its
158
+ fixed 64-round checker bound. `owlEntails` still uses a separate legacy path
159
+ whose positive verdicts do not have this source-to-verdict proof. See the
160
+ [OWL API contract](https://github.com/danbri/factoidal-core/blob/main/npm/factoidal/API.md)
161
+ and the [assurance record](https://github.com/danbri/factoidal-core/blob/main/docs/20261022-owl-pr-reconciliation.md).
162
+
151
163
  | Suite | Result |
152
164
  |---|---|
153
165
  | SPARQL 1.1 Query, Update, Protocol | 631 / 0 / 631 |
@@ -0,0 +1,331 @@
1
+ // A Node HTTP host for the authorization server component.
2
+ // https://github.com/danbri/factoidal/issues/916
3
+ // Design record: docs/designissues/2026-10-22-authorization-server.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 `authzStep` on a handle, and write the JSON response
8
+ // record back. Read the clock, draw random bytes, resolve host names, fetch
9
+ // the documents the engine names, and read and write the key file. That is
10
+ // the whole contract (iron rule 7 of CLAUDE.md, anti-pattern 36).
11
+ //
12
+ // It makes NO protocol or token decision: no status code, no OAuth error
13
+ // code, no claim, no key choice, no address check. Every one of those lives
14
+ // in `formal/lean4/L4Factoidal/Authz/`. The one status code written here is
15
+ // 500, for a failure of this host itself.
16
+ //
17
+ // THE WASM CONTRACT (Wasm/Ops/Authz.lean)
18
+ // authzOpen [configJson] -> { ok, handle, issuer, kid, changed }
19
+ // authzStep [handle, requestJson] -> { ok, response, changed }
20
+ // | { ok, resolve: { host }, changed }
21
+ // | { ok, fetch: { url, accept, address }, changed }
22
+ // authzFetched [handle, { url, status, body, now } | { host, addresses, now }] -> { ok }
23
+ // authzKeys [handle, { nonce?, private? }] -> { ok, keys } | { ok, jwks }
24
+ // authzRotate [handle, { now, random }] -> { ok, changed, kid }
25
+ // authzClose [handle] -> { ok }
26
+ //
27
+ // FETCHES. `resolve` names a host: this host resolves it (`dns.lookup`, every
28
+ // address) and passes the addresses back; the engine decides whether they may
29
+ // be reached (loopback, link-local and private addresses are refused unless
30
+ // the configuration says `allowPrivateFetch`). `fetch` names a URL and the
31
+ // address to connect to: this host connects to exactly that address (the name
32
+ // is not resolved again), sends no cookie or credential, follows no redirect,
33
+ // reads at most MAX_DOCUMENT_BYTES, and passes the status and the text back.
34
+
35
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync } from 'node:fs'
36
+ import { join, resolve } from 'node:path'
37
+ import { loadEngine } from '../bin/engine.mjs'
38
+ import { storageKeyText } from '../lws/persist.mjs'
39
+
40
+ /** The ops this host needs from the engine. */
41
+ export const AUTHZ_OPS = ['authzOpen', 'authzStep', 'authzFetched', 'authzKeys', 'authzRotate', 'authzClose']
42
+
43
+ /** The key file in DIR. */
44
+ export const KEY_FILE = 'authz-keys.json'
45
+
46
+ /** Bounds on what one request may make this host do. */
47
+ const MAX_NEEDS = 16
48
+ const FETCH_TIMEOUT_MS = 10000
49
+ const MAX_DOCUMENT_BYTES = 1048576
50
+
51
+ /** Random bytes for the engine, base64. Pure I/O. */
52
+ function randomBase64 (size) {
53
+ const bytes = new Uint8Array(size)
54
+ globalThis.crypto.getRandomValues(bytes)
55
+ return Buffer.from(bytes).toString('base64')
56
+ }
57
+
58
+ function nowSeconds () {
59
+ return Math.floor(Date.now() / 1000)
60
+ }
61
+
62
+ /** An error raised by this host, as opposed to one the engine reported. */
63
+ export class AuthzHostError extends Error {
64
+ constructor (message) {
65
+ super(message)
66
+ this.name = 'AuthzHostError'
67
+ }
68
+ }
69
+
70
+ /** Read the key file of DIR, or null. */
71
+ export function readKeyFile (dir) {
72
+ const file = join(resolve(dir), KEY_FILE)
73
+ if (!existsSync(file)) return null
74
+ const text = readFileSync(file, 'utf8')
75
+ try {
76
+ return JSON.parse(text)
77
+ } catch (error) {
78
+ throw new AuthzHostError(`${file} is not JSON: ${error.message}`)
79
+ }
80
+ }
81
+
82
+ /** Write the key file of DIR: temporary file, fsync, rename, mode 0600. */
83
+ export function writeKeyFile (dir, doc) {
84
+ const root = resolve(dir)
85
+ mkdirSync(root, { recursive: true })
86
+ const file = join(root, KEY_FILE)
87
+ const temporary = join(root, `.${KEY_FILE}.${process.pid}.tmp`)
88
+ const fd = openSync(temporary, 'w', 0o600)
89
+ try {
90
+ writeSync(fd, JSON.stringify(doc))
91
+ fsyncSync(fd)
92
+ } finally {
93
+ closeSync(fd)
94
+ }
95
+ renameSync(temporary, file)
96
+ try {
97
+ const dirFd = openSync(root, 'r')
98
+ try { fsyncSync(dirFd) } finally { closeSync(dirFd) }
99
+ } catch (_error) {
100
+ // Some platforms cannot fsync a directory; the rename stands.
101
+ }
102
+ }
103
+
104
+ /** Every address of a host name. Pure I/O. */
105
+ async function resolveHost (host) {
106
+ try {
107
+ const dns = await import('node:dns')
108
+ const found = await dns.promises.lookup(host, { all: true, verbatim: true })
109
+ return found.map((a) => a.address)
110
+ } catch (_error) {
111
+ return []
112
+ }
113
+ }
114
+
115
+ /** GET one URL from the address the engine named. Pure I/O: status 0 and a
116
+ * message when there is no answer. */
117
+ async function fetchFrom (url, accept, address) {
118
+ let target
119
+ try {
120
+ target = new URL(url)
121
+ } catch (error) {
122
+ return { status: 0, body: `not a URL: ${url}` }
123
+ }
124
+ const module = target.protocol === 'https:' ? await import('node:https') : await import('node:http')
125
+ const family = address.indexOf(':') >= 0 ? 6 : 4
126
+ return new Promise((done) => {
127
+ const request = module.request(target, {
128
+ method: 'GET',
129
+ headers: { accept },
130
+ lookup: (_host, options, callback) => {
131
+ if (options && options.all) callback(null, [{ address, family }])
132
+ else callback(null, address, family)
133
+ },
134
+ timeout: FETCH_TIMEOUT_MS
135
+ }, (response) => {
136
+ const chunks = []
137
+ let size = 0
138
+ response.on('data', (chunk) => {
139
+ size += chunk.length
140
+ if (size > MAX_DOCUMENT_BYTES) {
141
+ response.destroy()
142
+ done({ status: 0, body: `the document at ${url} is larger than ${MAX_DOCUMENT_BYTES} bytes` })
143
+ return
144
+ }
145
+ chunks.push(chunk)
146
+ })
147
+ response.on('end', () => done({ status: response.statusCode, body: Buffer.concat(chunks).toString('utf8') }))
148
+ response.on('error', (error) => done({ status: 0, body: String(error.message) }))
149
+ })
150
+ request.on('timeout', () => request.destroy(new Error('timeout')))
151
+ request.on('error', (error) => done({ status: 0, body: String(error.message) }))
152
+ request.end()
153
+ })
154
+ }
155
+
156
+ /**
157
+ * Open one authorization server on the engine.
158
+ *
159
+ * @param {object} options
160
+ * @param {string} options.issuer the issuer identifier (http or https, no query)
161
+ * @param {string[]} options.resources the resource IRIs tokens may be issued for
162
+ * @param {string} [options.dir] the directory of the key file
163
+ * @param {string} [options.alg] "ES256" (default) or "EdDSA"
164
+ * @param {number} [options.tokenLifetime] seconds, 1 to 300 (default 300)
165
+ * @param {number} [options.rotateAfter] seconds; 0 (default) never
166
+ * @param {boolean} [options.allowHttp] accept http subjects (default false)
167
+ * @param {boolean} [options.allowPrivateFetch] fetch from loopback, link-local and private addresses (default false)
168
+ * @param {string|null} [options.storageKey] the key text; default FACTOIDAL_LWS_KEY; null reads none
169
+ * @param {boolean|string[]} [options.cors] false for no CORS fields, or the origins to answer;
170
+ * default: echo any valid request Origin (issue 923)
171
+ * @param {object} [options.engine] an engine to reuse
172
+ */
173
+ export async function openAuthorizationServer (options = {}) {
174
+ const engine = options.engine !== undefined ? options.engine : await loadEngine()
175
+ const dir = typeof options.dir === 'string' ? options.dir : null
176
+ const config = {
177
+ issuer: options.issuer,
178
+ resources: Array.isArray(options.resources) ? options.resources : [],
179
+ now: nowSeconds(),
180
+ entropy: randomBase64(64)
181
+ }
182
+ for (const name of ['alg', 'tokenLifetime', 'leeway', 'rotateAfter', 'docTtl']) {
183
+ if (options[name] !== undefined) config[name] = options[name]
184
+ }
185
+ if (options.allowHttp === true) config.allowHttp = true
186
+ if (options.allowPrivateFetch === true) config.allowPrivateFetch = true
187
+ // CORS (issue 923): false or a list of origins, passed unread; absent, the
188
+ // engine echoes the request Origin (L4Factoidal/Authz/Server.lean stepCors).
189
+ if (options.cors !== undefined) config.cors = options.cors
190
+ const key = storageKeyText(options.storageKey)
191
+ if (key !== undefined) config.storageKey = key
192
+ if (dir !== null) {
193
+ const keys = readKeyFile(dir)
194
+ if (keys !== null) config.keys = keys
195
+ }
196
+ const opened = engine.call('authzOpen', [JSON.stringify(config)])
197
+ const handle = opened.handle
198
+ if (typeof handle !== 'string') throw new AuthzHostError('authzOpen answered with no handle')
199
+
200
+ const persist = (envelope) => {
201
+ if (dir === null || envelope === null || typeof envelope !== 'object' || envelope.changed !== true) return
202
+ const told = engine.call('authzKeys', [handle, JSON.stringify({ nonce: randomBase64(12) })])
203
+ writeKeyFile(dir, told.keys)
204
+ }
205
+ persist(opened)
206
+
207
+ return {
208
+ engine,
209
+ handle,
210
+ issuer: opened.issuer,
211
+ /**
212
+ * One request.
213
+ * @param {{method: string, target: string, headers: Array<[string, string]>, body: string}} request
214
+ * @returns {Promise<{status: number, headers: Array<[string, string]>, body: string}>}
215
+ */
216
+ async step (request) {
217
+ let needs = 0
218
+ for (;;) {
219
+ const now = nowSeconds()
220
+ const record = { ...request, now, random: randomBase64(112) }
221
+ const envelope = engine.call('authzStep', [handle, JSON.stringify(record)])
222
+ persist(envelope)
223
+ if (envelope.response !== undefined && envelope.response !== null) {
224
+ const r = envelope.response
225
+ return { status: r.status, headers: Array.isArray(r.headers) ? r.headers : [], body: typeof r.body === 'string' ? r.body : '' }
226
+ }
227
+ needs += 1
228
+ if (needs > MAX_NEEDS) throw new AuthzHostError('the engine asked for more than one request may fetch')
229
+ if (envelope.resolve !== undefined && envelope.resolve !== null) {
230
+ const host = String(envelope.resolve.host)
231
+ const addresses = await resolveHost(host)
232
+ engine.call('authzFetched', [handle, JSON.stringify({ host, addresses, now: nowSeconds() })])
233
+ } else if (envelope.fetch !== undefined && envelope.fetch !== null) {
234
+ const f = envelope.fetch
235
+ const got = await fetchFrom(String(f.url), String(f.accept), String(f.address))
236
+ engine.call('authzFetched', [handle, JSON.stringify({ url: f.url, status: got.status, body: got.body, now: nowSeconds() })])
237
+ } else {
238
+ throw new AuthzHostError('authzStep answered with no response, resolve or fetch')
239
+ }
240
+ }
241
+ },
242
+ /** Make a new signing key now (the old one stays published). Returns
243
+ * the engine's envelope unread. */
244
+ rotate () {
245
+ const told = engine.call('authzRotate', [handle, JSON.stringify({ now: nowSeconds(), random: randomBase64(64) })])
246
+ persist(told)
247
+ return told
248
+ },
249
+ /** The private JWKs of the published keys: for a test harness only. */
250
+ privateJwks () {
251
+ return engine.call('authzKeys', [handle, JSON.stringify({ private: true })]).jwks
252
+ },
253
+ close () {
254
+ try { engine.call('authzClose', [handle]) } catch (_error) { /* already gone */ }
255
+ }
256
+ }
257
+ }
258
+
259
+ function headerPairs (raw) {
260
+ const pairs = []
261
+ for (let index = 0; index + 1 < raw.length; index += 2) pairs.push([raw[index], raw[index + 1]])
262
+ return pairs
263
+ }
264
+
265
+ function readBody (request) {
266
+ return new Promise((resolveBody, reject) => {
267
+ const chunks = []
268
+ request.on('data', (chunk) => chunks.push(chunk))
269
+ request.on('error', reject)
270
+ request.on('end', () => resolveBody(Buffer.concat(chunks).toString('utf8')))
271
+ })
272
+ }
273
+
274
+ /**
275
+ * Start an authorization server: listen, then open the engine handle. With
276
+ * no `issuer` the issuer is the bound origin.
277
+ *
278
+ * @param {object} options as `openAuthorizationServer`, plus `port`, `host`
279
+ * @returns {Promise<{server: object, port: number, origin: string, issuer: string,
280
+ * authz: object, close: Function}>}
281
+ */
282
+ export async function listen (options = {}) {
283
+ const http = await import('node:http')
284
+ const port = typeof options.port === 'number' ? options.port : 0
285
+ const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
286
+ let authz = null
287
+ const server = http.createServer(async (request, response) => {
288
+ let answer
289
+ try {
290
+ const record = {
291
+ method: request.method,
292
+ target: request.url,
293
+ headers: headerPairs(request.rawHeaders),
294
+ body: await readBody(request)
295
+ }
296
+ answer = await authz.step(record)
297
+ } catch (error) {
298
+ console.error(`authz-serve: ${error && error.message ? error.message : error}`)
299
+ response.writeHead(500, { 'content-type': 'text/plain' })
300
+ response.end('authz-serve: host failure\n')
301
+ return
302
+ }
303
+ for (const [name, value] of answer.headers) response.setHeader(name, String(value))
304
+ response.statusCode = answer.status
305
+ response.end(request.method === 'HEAD' ? undefined : answer.body)
306
+ })
307
+ await new Promise((resolveListen, reject) => {
308
+ server.once('error', reject)
309
+ server.listen(port, host, resolveListen)
310
+ })
311
+ const bound = server.address()
312
+ const origin = `http://${host.indexOf(':') >= 0 ? `[${host}]` : host}:${bound.port}`
313
+ try {
314
+ authz = await openAuthorizationServer({ ...options, issuer: typeof options.issuer === 'string' ? options.issuer : origin })
315
+ } catch (error) {
316
+ await new Promise((resolveClose) => server.close(resolveClose))
317
+ throw error
318
+ }
319
+ return {
320
+ server,
321
+ port: bound.port,
322
+ origin,
323
+ issuer: authz.issuer,
324
+ authz,
325
+ close () {
326
+ return new Promise((resolveClose) => {
327
+ server.close(() => { authz.close(); resolveClose() })
328
+ })
329
+ }
330
+ }
331
+ }