@factoidal/core 0.12.0 → 0.14.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 +198 -0
- package/README.md +60 -2
- package/authz/server.mjs +331 -0
- package/bin/factoidal.mjs +378 -12
- package/browser.d.ts +7 -0
- package/browser.js +8 -0
- package/index.d.ts +221 -8
- package/index.mjs +8 -0
- 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 +57 -5
- package/lib/dataset.mjs +8 -0
- package/lib/evidence.mjs +84 -0
- package/lib/nodes.mjs +215 -0
- package/lib/sbm11-node.mjs +61 -0
- package/lib/sbm11.mjs +148 -0
- package/lib/version.mjs +1 -1
- package/lws/client.mjs +319 -0
- package/lws/persist.mjs +17 -1
- package/lws/server.mjs +25 -3
- package/package.json +9 -1
- package/sample-store.mjs +1 -1
- package/xmpp/README.md +82 -4
- package/xmpp/server.mjs +549 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,203 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
|
|
5
|
+
**Checked pipelines.** Each component (reader, store, query evaluator, writer, reasoner, validator, proof checker) has one interface type whose contract against the W3C specifications is part of the type. A component cannot be added without a contract; a contract that is not proved yet is a named assumption, listed and counted in CI. A theorem states that the SPARQL answer agrees with the SPARQL 1.1 semantics for data from any source (a parsed document, a Dataset after edits, an update, a reasoner's output, a union of stores), given the listed assumptions. Status and gaps: https://github.com/danbri/factoidal/issues/975.
|
|
6
|
+
|
|
7
|
+
- `explain(sources, description, { profile: "rdfs" })`: whether the sources RDFS-entail a description graph (its blank nodes are variables), with a checked derivation (`bundle`) when it does and the reason (`rejection`) when not (https://github.com/danbri/factoidal/issues/1002).
|
|
8
|
+
- `run(pipeline, input, { unchecked: true })`: runs nodes whose stage is not proved; each evidence record has `checked`, and the result has `proved` and `uncheckedSteps`. Without the option a stage with no contract does not run.
|
|
9
|
+
- `Dataset.warnings`: a language tag that is not well-formed BCP 47 is kept and reported with its line, column and offset; the writers write it unchanged (https://github.com/danbri/factoidal/issues/1004).
|
|
10
|
+
- SPARQL `SELECT *` on the store path returns the variables in query order, the same header as the in-memory path (https://github.com/danbri/factoidal/issues/1014).
|
|
11
|
+
- SBM11 in npm (`openSbm11`) is **not** in this release. Its wasm op `sbm11Open` traps (`unreachable`) in the release build, so the ops are not dispatched and the functions are not exported; the native `l4sbm11` commands are unchanged (https://github.com/danbri/factoidal/issues/1009).
|
|
12
|
+
- `factoidal pack`, `factoidal sample-store` and `sampleStorePath()` write the IBK5 layout (wire version 10, manifest layout `quad-ibk5-ptd2-lgi4-gbi1-merkle-v0`) when no `--layout` is given; the default was IBK3. Owner, 2026-10-25: "yes IBK5 not IBK3 as default sounds sensible, assuming no major downside" (https://github.com/danbri/factoidal/issues/1009). `factoidal serve` over such a store takes changes to every graph; over an IBK3 store a change to a named graph answered 501. `--layout ibk3` still writes IBK3. `sampleStoreFacts.layout` is now `'ibk5'`.
|
|
13
|
+
- `stages()`, `node()`, `pipe()`, `run()`: the registered Lean stages as nodes (https://github.com/danbri/factoidal/issues/1002, step 2). `stages()` is the engine's op `stageList`, read from the Lean stage registry: name, interface type, wire types, status, contract and named assumptions of each stage. A value on a wire is `{graph, evidence}`; each node adds one evidence record `{stage, status, contract, assumptions, artefact}`, and `run()` reports the union of the named assumptions of the nodes. A stage with status `missing` does not run. Needs a wasm build with the op `stageList`.
|
|
14
|
+
- `factoidal pack` writes a set of rows in every layout (https://github.com/danbri/factoidal/issues/1006). Correction to 0.13.0: its entry says that the stores keep one copy of a repeated triple, but in 0.13.0 that was true for the IBK4 and IBK5 layouts only, and only on read. The default layout (IBK3) kept a repeated triple, and kept `"a"@en` and `"a"@EN` as two rows, so `COUNT(*)` counted both; the IBK4 and IBK5 packers also wrote the repeated rows. Now each packer keeps the distinct triples it has written and writes a triple only once (per graph on the quad layouts). A source with no repeated triple gives the same generation as before. The packer's memory now grows with the number of distinct triples of the source. Proved: `Dataflow.packedRows_keyNodup`; tests: `test/pack-is-a-set-1006.test.mjs`.
|
|
15
|
+
- The N-Triples, N-Quads, Turtle and TriG writers give a fresh valid label to a blank-node label outside BLANK_NODE_LABEL (the same label for each occurrence, never a label of another blank node), and refuse a language tag outside LANGTAG with an error that names the tag (https://github.com/danbri/factoidal/issues/986).
|
|
16
|
+
- A language tag is kept in lower case (RDF 1.2 Concepts §3.4: `"x"@en` and `"x"@EN` are one RDF term). Every reader, `STRLANG`, `STRLANGDIR` and the term constructors write the tag in lower case, so `LANG()` returns it in lower case, and a SPARQL answer no longer depends on the order of the triple patterns when the data spells one tag in two ways (https://github.com/danbri/factoidal/issues/1001).
|
|
17
|
+
|
|
18
|
+
## 0.13.0
|
|
19
|
+
|
|
20
|
+
**A parsed document is a set of quads** (RDF 1.1 Concepts §3 and §4;
|
|
21
|
+
https://github.com/danbri/factoidal/issues/972). Up to 0.12.0 a document
|
|
22
|
+
that stated the same triple twice gave a graph with two copies: `size`
|
|
23
|
+
counted 2 and `SELECT (COUNT(*) AS ?n)` answered 2. Every parser, the
|
|
24
|
+
streamed parse, the command's `query` and the stores now keep one copy.
|
|
25
|
+
Language tags compare case-insensitively (RDF 1.2 Concepts §3.4), so
|
|
26
|
+
`"a"@en` and `"a"@EN` are one term in a graph and in a store dictionary
|
|
27
|
+
(https://github.com/danbri/factoidal/issues/977).
|
|
28
|
+
|
|
29
|
+
SPARQL and RDF semantics fixes:
|
|
30
|
+
|
|
31
|
+
- `=` and `!=` follow RDFterm-equal (SPARQL 1.1 §17.4.1.7). Terms of
|
|
32
|
+
different kinds (IRI, blank node, literal, triple term) give `=` false.
|
|
33
|
+
Two literals that are different terms give a type error, except where
|
|
34
|
+
the §17.3.1 extension of the W3C tests gives false: `rdf:langString`
|
|
35
|
+
against another datatype, and `xsd:dateTime` against `xsd:date`. The
|
|
36
|
+
SPARQL 1.0 open-world tests: 18 pass, 0 fail (out of 18); were 13 pass,
|
|
37
|
+
5 fail.
|
|
38
|
+
- `rdf:XMLLiteral` values compare by RDF term equality; SPARQL `=` on two
|
|
39
|
+
different XMLLiteral terms is a type error
|
|
40
|
+
(https://github.com/danbri/factoidal/issues/963). The RDF/XML parser's
|
|
41
|
+
exclusive XML canonicalization is repaired.
|
|
42
|
+
- A query with `FROM` clauses queries the RDF merge of those graphs
|
|
43
|
+
(SPARQL 1.1 §13.2.1): blank nodes from different `FROM` graphs are no
|
|
44
|
+
longer joined (https://github.com/danbri/factoidal/issues/979).
|
|
45
|
+
- RDFS closure rule rdfs9 accepts a blank node as the sub-class
|
|
46
|
+
(RDF 1.1 Semantics §9.2; https://github.com/danbri/factoidal/issues/982).
|
|
47
|
+
- The RDF/XML writer keeps the base direction of a directional language
|
|
48
|
+
string (`its:dir` with `rdf:version="1.2"`). The JSON-LD writer still
|
|
49
|
+
drops it: https://github.com/danbri/factoidal/issues/984.
|
|
50
|
+
- Opening a dataset renames blank nodes inside triple terms as well as at
|
|
51
|
+
the top level.
|
|
52
|
+
- `factoidal sample-store` under Deno printed its path three times: the
|
|
53
|
+
pack and the activate each re-executed the whole command. It now
|
|
54
|
+
re-executes once, before any work.
|
|
55
|
+
|
|
56
|
+
**`proofCheck` no longer joins blank nodes across artifacts**
|
|
57
|
+
(https://github.com/danbri/factoidal/issues/976). Up to 0.12.0 a bundle
|
|
58
|
+
could take `:A rdfs:subClassOf _:x` from one graph artifact and
|
|
59
|
+
`_:x rdfs:subClassOf :C` from another with the same RDFC-1.0 digest, and
|
|
60
|
+
conclude `:A rdfs:subClassOf :C` with `valid: true`. Blank nodes are now
|
|
61
|
+
scoped to the artifact or assumption they come from (RDF 1.1 Semantics
|
|
62
|
+
section 5.2), so that bundle is refused. A chain inside one artifact, and
|
|
63
|
+
a chain across artifacts through triples with no blank node, are still
|
|
64
|
+
accepted. The report has a new member `rejection`: `null` for a valid
|
|
65
|
+
bundle, else the first failed check in words. The bundle format and the
|
|
66
|
+
profile `fpp0/1` are unchanged.
|
|
67
|
+
|
|
68
|
+
**XMPP server from wasm.** `factoidal xmpp-serve DIR` and
|
|
69
|
+
`@factoidal/core/xmpp/server` run the Lean XMPP server
|
|
70
|
+
(`L4Factoidal.XMPP.Server` — RFC 6120 negotiation, SASL PLAIN and
|
|
71
|
+
SCRAM-SHA-256, binding, RFC 6121 roster/presence/messages) in the
|
|
72
|
+
WebAssembly engine, one engine session per TCP or direct-TLS (XEP-0368)
|
|
73
|
+
connection. New engine ops `xmppOpen`, `xmppSessionOpen`,
|
|
74
|
+
`xmppSessionFeed`, `xmppSessionDeliver`, `xmppSessionClose`,
|
|
75
|
+
`xmppRosterGet`, `xmppRosterSet`, `xmppClose`
|
|
76
|
+
(`formal/lean4/Wasm/Ops/XmppSession.lean`). The state directory is the
|
|
77
|
+
native carrier's layout (`accounts`, `roster/`, `spool/`), so a stanza
|
|
78
|
+
written as a file under `spool/<bare jid>/` is delivered to that
|
|
79
|
+
account. See `xmpp/README.md`.
|
|
80
|
+
|
|
81
|
+
Linked Web Storage kept in files, stage 2 (https://github.com/danbri/factoidal/issues/918;
|
|
82
|
+
design record `docs/designissues/2026-10-22-lws-storage-on-disk.md`):
|
|
83
|
+
|
|
84
|
+
- Stage 2 (`L4Factoidal/LWS/Bodies.lean`): with `--storage files` the bodies
|
|
85
|
+
stay in their body files. An entry keeps its length, its FNV-1a and SHA-256
|
|
86
|
+
digests and the types of a Turtle body, and no bytes (ACL resources keep
|
|
87
|
+
theirs). A step that needs bytes (GET body or range, JSON Patch, a PUT a
|
|
88
|
+
subscription may hear of, a move of a Turtle resource) answers
|
|
89
|
+
`read: [PATH, ...]`; the host reads those files and gives them to the new op
|
|
90
|
+
`lwsRead` (byte-region entry only), which checks them, and steps again. A
|
|
91
|
+
start reads no body file unless the metadata lacks a fact; the peak RSS of a
|
|
92
|
+
start and one GET of 1,000 and 4,000 resources of 16 KiB is 230 and 211 MiB
|
|
93
|
+
(stage 1: 258 and 515 MiB). A changed body file is refused when it is read
|
|
94
|
+
(the request answers 500), no longer at start.
|
|
95
|
+
- The LWS handles run a tree store (`TreeStore`, theorem `treeStoreLaws`) and
|
|
96
|
+
a type index in persistent trees: the engine's step and persist of a PUT
|
|
97
|
+
take 0.30, 0.27, 0.28 and 0.29 ms in a storage of 1,000, 2,000, 4,000 and
|
|
98
|
+
16,000 resources (stage 1: 0.21 ms at 1,000, about 3 ms at 16,000). A POST
|
|
99
|
+
no longer lists every path; a DELETE lists paths only for a container.
|
|
100
|
+
- The order of the entries of a snapshot and of `lws-files.json`, and of the
|
|
101
|
+
activities of a recursive DELETE in one notification, is path order (was
|
|
102
|
+
newest first). `lwsSnapshot` of a storage kept in files is refused.
|
|
103
|
+
- `solid-serve` keeps the snapshot: an expected failure naming issue 918.
|
|
104
|
+
|
|
105
|
+
An authorization server as a component (https://github.com/danbri/factoidal/issues/916;
|
|
106
|
+
design record `docs/designissues/2026-10-22-authorization-server.md`; skill
|
|
107
|
+
`skills/authz-server/SKILL.md`), decided in Lean (`L4Factoidal/Authz/`):
|
|
108
|
+
|
|
109
|
+
- `factoidal authz-serve DIR --resource IRI[,IRI...]` and
|
|
110
|
+
`import { listen } from '@factoidal/core/authz'`: RFC 8414 metadata at
|
|
111
|
+
`/.well-known/lws-configuration` (and `/.well-known/oauth-authorization-server`),
|
|
112
|
+
a JWK Set, and an RFC 8693 token endpoint that exchanges a subject token for
|
|
113
|
+
an RFC 9068 access token whose `aud` is exactly the requested resource
|
|
114
|
+
(lws10-core "Authorization"). `lws-serve --issuer <its issuer>` accepts its
|
|
115
|
+
tokens with no other setting. `factoidal authz-keys DIR` prints the JWK Set.
|
|
116
|
+
- Subject tokens: OpenID Connect ID tokens (lws10-authn-openid) and
|
|
117
|
+
self-issued JWTs for https and did:key subjects (lws10-authn-ssi-cid; P-256
|
|
118
|
+
and Ed25519 did:keys). SAML 2.0 is refused and not advertised: there is no
|
|
119
|
+
XML Signature verifier (https://github.com/danbri/factoidal/issues/926).
|
|
120
|
+
solid-serve does not accept these tokens yet
|
|
121
|
+
(https://github.com/danbri/factoidal/issues/928).
|
|
122
|
+
- Documents the server fetches for a subject token: `https` only (`--allow-http`
|
|
123
|
+
for tests), never from a loopback, link-local or private address
|
|
124
|
+
(`--allow-private-fetch` for tests), from the address the engine checked,
|
|
125
|
+
with no redirect.
|
|
126
|
+
- Keys: ES256 by default (`--alg EdDSA`), signed by HACL\* through three new
|
|
127
|
+
P-256 externs (sign, secret-to-public, point decompression; RFC 6979 and
|
|
128
|
+
Wycheproof vectors in `lake exe l4crypto-probe`); `kid` is the RFC 7638
|
|
129
|
+
thumbprint; `--rotate-after` rotates and keeps the old key published until
|
|
130
|
+
its tokens expire; `DIR/authz-keys.json` (mode 0600) is sealed under
|
|
131
|
+
`FACTOIDAL_LWS_KEY` when it is set.
|
|
132
|
+
- New ops `authzOpen`, `authzStep`, `authzFetched`, `authzKeys`, `authzRotate`,
|
|
133
|
+
`authzClose`.
|
|
134
|
+
- Theorems: `issueToken_verifies_published` and `issueToken_audience_exact`
|
|
135
|
+
hold because of a run-time check of every issued token (the proof that
|
|
136
|
+
removes the check: https://github.com/danbri/factoidal/issues/927);
|
|
137
|
+
`exchange_refused_no_token` (a subject token that fails validation never
|
|
138
|
+
yields an access token), `cid_valid_verified`, `didKey_valid_verified`,
|
|
139
|
+
`oidc_valid_named_provider`, `chooseAddress_refuses_private` and others.
|
|
140
|
+
- Touchstone with this server in place of its reference one
|
|
141
|
+
(`tools/lws-touchstone.sh --config auth --authz factoidal`, 2026-10-22):
|
|
142
|
+
212 passed, 0 failed, 9 inapplicable (out of 221); of these
|
|
143
|
+
core/authorization_server 9 pass, 0 fail (out of 9), auth/oidc 6 pass,
|
|
144
|
+
0 fail (out of 6), auth/cid 22 pass, 0 fail (out of 22), auth/saml 3
|
|
145
|
+
inapplicable (issue 926).
|
|
146
|
+
|
|
147
|
+
CORS in the LWS HTTP binding (https://github.com/danbri/factoidal/issues/923),
|
|
148
|
+
decided in Lean (`L4Factoidal/HTTP/Cors.lean`, `L4Factoidal/LWS/Cors.lean`), so
|
|
149
|
+
a page on another origin (for example GitHub Pages) can call `lws-serve`:
|
|
150
|
+
|
|
151
|
+
- A CORS-preflight request (`OPTIONS` with `Access-Control-Request-Method`
|
|
152
|
+
and `Origin`) is answered 204 before any other decision, with no token and
|
|
153
|
+
no access check: `Access-Control-Allow-Methods` (with `QUERY` and `PATCH`),
|
|
154
|
+
`-Allow-Headers` (`Authorization`, `Content-Type`, `If-Match`,
|
|
155
|
+
`If-None-Match`, `If-Modified-Since`, `If-Unmodified-Since`, `Link`,
|
|
156
|
+
`Prefer`, `Slug`, `Range`, `DPoP`, `Accept`), `-Max-Age` 7200. An `OPTIONS`
|
|
157
|
+
without `Access-Control-Request-Method` is answered as before.
|
|
158
|
+
- Every answer carries `Vary: Origin` (one `Vary` field with the answer's
|
|
159
|
+
own), and, for an answered origin, `Access-Control-Allow-Origin` and
|
|
160
|
+
`Access-Control-Expose-Headers` (`ETag`, `Link`, `Location`, `Allow`,
|
|
161
|
+
`Accept-Patch`, `Accept-Post`, `Accept-Put`, `Accept-Query`,
|
|
162
|
+
`Accept-Ranges`, `Content-Location`, `Content-Range`, `WWW-Authenticate`,
|
|
163
|
+
`Preference-Applied`, `Repr-Digest`, `Last-Modified`,
|
|
164
|
+
`Factoidal-Merkle-Root`). No `Access-Control-Allow-Credentials`: the token
|
|
165
|
+
goes in `Authorization`.
|
|
166
|
+
- Default: echo a valid request `Origin` (Solid Protocol §8.1), never `null`.
|
|
167
|
+
`lws-serve --cors-origin ORIGIN` (repeatable) answers only those origins;
|
|
168
|
+
`--no-cors` sends no CORS field; config member `cors` (`false` or a list).
|
|
169
|
+
A malformed origin stops the start.
|
|
170
|
+
- `authz-serve` gets the same (`Authz.stepCors`; methods `GET, HEAD, OPTIONS,
|
|
171
|
+
POST`), with the same flags. `solid-serve` keeps its own Solid §8.1 CORS;
|
|
172
|
+
its preflight is now answered before Web Access Control (it was 401 for a
|
|
173
|
+
resource that is not public).
|
|
174
|
+
|
|
175
|
+
An LWS client (https://github.com/danbri/factoidal/issues/917; design record
|
|
176
|
+
`docs/designissues/2026-10-22-lws-client.md`):
|
|
177
|
+
|
|
178
|
+
- `@factoidal/core/lws/client`: `createLwsClient({ fetch, subjectToken,
|
|
179
|
+
subjectTokenType })` with `discover`, `read`, `head`, `create`, `update`,
|
|
180
|
+
`patch`, `delete`, `readLinkset`, `updateLinkset`, `move`, `list`,
|
|
181
|
+
`typeIndex`, `search`, `subscribe`, `unsubscribe`, `requestAccess`,
|
|
182
|
+
`grantAccess`, `getAccess`, `revokeAccess`, `verifyNotification`; and
|
|
183
|
+
`createNotificationReceiver`, a webhook receiver that answers each delivery
|
|
184
|
+
with the engine's verdict. `fetch` is injected (Node, a browser, or a
|
|
185
|
+
transport such as XMPP).
|
|
186
|
+
- Every decision is in Lean (`L4Factoidal/LWS/Client/`, wasm ops
|
|
187
|
+
`lwsClientStart` and `lwsClientStep`): the 401 flow with the three checks of
|
|
188
|
+
lws10-core "Discovery Validation by a Client", RFC 8414 metadata and RFC 8693
|
|
189
|
+
token exchange; conditional writes (the current ETag by default for
|
|
190
|
+
`update`, `patch`, `updateLinkset`, `move`); no repeat after 405 or 415, no
|
|
191
|
+
POST again after a 5xx or a lost answer; paging by `rel="next"` only;
|
|
192
|
+
linkset writes only in advertised methods and formats; the RFC 9421 check of
|
|
193
|
+
a notification. Theorems: `walk_concat`, `walk_members_once`,
|
|
194
|
+
`step_write_precondition`, `step_no_token_request_without_validation`,
|
|
195
|
+
`step_no_blind_create_retry`, `step_no_repeat_after_refusal`.
|
|
196
|
+
- CLI `factoidal lws-client <op> URL`.
|
|
197
|
+
- Self-issued credentials (lws10-authn-ssi-cid): `createLwsClient({ selfIssued:
|
|
198
|
+
{ subject, keyId, jwk } })`; the engine signs the ES256 JWT with HACL*.
|
|
199
|
+
- Expected failure: OpenID sign-in is not built (row names issue 917).
|
|
200
|
+
|
|
3
201
|
## 0.12.0 — 2026-10-22
|
|
4
202
|
|
|
5
203
|
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,
|
|
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
|
|
@@ -51,6 +52,50 @@ RDF 1.2 input uses `turtle12`, `ntriples12`, `nquads12` or `trig12`.
|
|
|
51
52
|
"Terms given as strings".
|
|
52
53
|
- RDF/JS DataFactory and DatasetCore (`/rdfjs`), a functional API (`/fn`)
|
|
53
54
|
|
|
55
|
+
## Checked pipelines
|
|
56
|
+
|
|
57
|
+
Each component kind has one interface type whose contract against the W3C
|
|
58
|
+
specifications is part of the type: readers, stores, the SPARQL evaluator,
|
|
59
|
+
writers, reasoners, validators and the proof checker. A contract that is not
|
|
60
|
+
proved yet is a named assumption; CI fails when the list of assumptions grows
|
|
61
|
+
without a stated reason. A theorem states that the SPARQL answer agrees with
|
|
62
|
+
the SPARQL 1.1 semantics for data from any source (a parsed document, an
|
|
63
|
+
edited Dataset, an update, a reasoner's output, a union of stores), given the
|
|
64
|
+
listed assumptions. No new command-line verbs. Status:
|
|
65
|
+
https://github.com/danbri/factoidal/issues/975.
|
|
66
|
+
|
|
67
|
+
The same components are nodes you can compose. A value on a wire is
|
|
68
|
+
`{graph, evidence}`; each node adds one evidence record; the result lists the
|
|
69
|
+
union of the named assumptions of the nodes, so a pipeline says exactly what
|
|
70
|
+
its answer depends on.
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
import { stages, node, pipe, run } from "@factoidal/core";
|
|
74
|
+
|
|
75
|
+
const sources = `@prefix ex: <http://example.org/> .
|
|
76
|
+
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
|
77
|
+
ex:alice ex:worksFor ex:acme .
|
|
78
|
+
ex:worksFor rdfs:domain ex:Employee .
|
|
79
|
+
ex:Employee rdfs:subClassOf ex:Person .`;
|
|
80
|
+
const description = `@prefix ex: <http://example.org/> .
|
|
81
|
+
_:x a ex:Person . _:x ex:worksFor ex:acme .`;
|
|
82
|
+
|
|
83
|
+
console.log((await stages()).length); // registered components, each with status and contract
|
|
84
|
+
const p = pipe(
|
|
85
|
+
await node("turtleDocumentReader"),
|
|
86
|
+
await node("rdfsReasoner"),
|
|
87
|
+
await node("rdfsExplainGraphStage", { description }),
|
|
88
|
+
);
|
|
89
|
+
const r = await run(p, sources);
|
|
90
|
+
console.log(r.evidence.map((e) => e.stage)); // one record per node
|
|
91
|
+
console.log(r.evidence[2].artefact.entailed); // true, with a checked derivation
|
|
92
|
+
console.log(r.assumptions, r.proved); // what the answer depends on
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`run(p, input, { unchecked: true })` also runs components that are not proved,
|
|
96
|
+
and marks those steps in the evidence. Details: API.md, "Stages as nodes" and
|
|
97
|
+
"Explaining an entailment" (`explain()` is the same check as one call).
|
|
98
|
+
|
|
54
99
|
Formats (generated from `formats()`):
|
|
55
100
|
|
|
56
101
|
<!-- formats-table:start (generated by test/formats-table.test.mjs) -->
|
|
@@ -134,7 +179,7 @@ Changes: [CHANGELOG.md](CHANGELOG.md).
|
|
|
134
179
|
```console
|
|
135
180
|
$ npx factoidal serve --file data.ttl /ds # SPARQL endpoint at /ds/sparql
|
|
136
181
|
$ npx factoidal serve --tdb2 DIR /ds # serve a Jena TDB2 database
|
|
137
|
-
$ npx factoidal pack data.ttl store/gen-1 && npx factoidal activate store gen-1
|
|
182
|
+
$ npx factoidal pack data.ttl store/gen-1 && npx factoidal activate store gen-1 # IBK5 quad blocks
|
|
138
183
|
$ npx factoidal query store 'SELECT * WHERE { ?s ?p ?o } LIMIT 5'
|
|
139
184
|
$ npx factoidal query "$(npx factoidal sample-store)" 'SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }'
|
|
140
185
|
$ npx factoidal lws-serve DIR --issuer IRI # Linked Web Storage
|
|
@@ -148,6 +193,19 @@ $ npx factoidal solid-serve DIR # Solid Protocol
|
|
|
148
193
|
|
|
149
194
|
Official test suites, pass / fail / total, 2026-10-09 [B] unless marked.
|
|
150
195
|
|
|
196
|
+
The OWL DL rows below are historical measurements of the legacy harness;
|
|
197
|
+
they do not measure the current `owlIsConsistent` API. That API checks only
|
|
198
|
+
an admitted fragment of the default graph (atomic classes,
|
|
199
|
+
`owl:someValuesFrom`, `owl:TransitiveProperty`, and annotations, axiom
|
|
200
|
+
annotations included). It returns `true` for a checked finite model, `false`
|
|
201
|
+
for a checked contradiction and `null` for unsupported input, exhausted
|
|
202
|
+
`fuel` or an incomplete check. Other restrictions, negative property
|
|
203
|
+
assertions, imports and equivalentClass are unsupported. `owlEntails`
|
|
204
|
+
answers `false` only with a checked countermodel; its positive verdicts use a
|
|
205
|
+
separate legacy path that does not have this source-to-verdict proof. See the
|
|
206
|
+
[OWL API contract](https://github.com/danbri/factoidal-core/blob/main/npm/factoidal/API.md)
|
|
207
|
+
and the [assurance record](https://github.com/danbri/factoidal-core/blob/main/docs/20261022-owl-pr-reconciliation.md).
|
|
208
|
+
|
|
151
209
|
| Suite | Result |
|
|
152
210
|
|---|---|
|
|
153
211
|
| SPARQL 1.1 Query, Update, Protocol | 631 / 0 / 631 |
|
package/authz/server.mjs
ADDED
|
@@ -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
|
+
}
|