@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/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
|
|
@@ -70,6 +71,62 @@ Formats (generated from `formats()`):
|
|
|
70
71
|
<!-- formats-table:end -->
|
|
71
72
|
|
|
72
73
|
Missing writers (HDT, N3, RDFa): https://github.com/danbri/factoidal/issues/883.
|
|
74
|
+
|
|
75
|
+
## Errors
|
|
76
|
+
|
|
77
|
+
Every function rejects with one class, `FactoidalError`, except that an
|
|
78
|
+
argument of the wrong JavaScript type is a `TypeError`. The engine decides
|
|
79
|
+
three fields: `code` (below), `position` (`{line, column, offset,
|
|
80
|
+
byteOffset}` where the input defines one: `offset` is a UTF-16 index into
|
|
81
|
+
your string, `byteOffset` counts UTF-8 bytes) and `spec` (the specification
|
|
82
|
+
section of the condition). `cause` holds the underlying error of `host.io`.
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
import { parse, FactoidalError } from '@factoidal/core';
|
|
86
|
+
try { await parse('<http://e/a> <http://e/p> .', 'turtle'); }
|
|
87
|
+
catch (e) { if (e instanceof FactoidalError) console.log(e.code, e.position.line, e.spec); }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Codes (generated from `engineTables().errorCodes`):
|
|
91
|
+
|
|
92
|
+
<!-- errors-table:start (generated by test/errors.test.mjs) -->
|
|
93
|
+
| code | specification | meaning |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `parse.syntax` | <https://www.w3.org/TR/rdf12-turtle/#sec-grammar> | The input text does not match the grammar of its RDF syntax. |
|
|
96
|
+
| `parse.rdf12-required` | <https://www.w3.org/TR/rdf12-concepts/#section-triple-terms> | The text uses an RDF 1.2 form (triple term, reified triple, base direction) and was read as RDF 1.1. |
|
|
97
|
+
| `term.invalid-iri` | <https://www.w3.org/TR/rdf12-concepts/#section-IRIs> | A term that must be an IRI is not one. |
|
|
98
|
+
| `term.invalid-langtag` | <https://www.w3.org/TR/rdf12-concepts/#section-Graph-Literal> | A language tag is not well-formed (BCP 47). |
|
|
99
|
+
| `term.invalid` | <https://www.w3.org/TR/sparql11-results-json/#select-encode-terms> | A term or quad given as JSON is not an RDF term or quad. |
|
|
100
|
+
| `query.syntax` | <https://www.w3.org/TR/sparql11-query/#grammar> | The SPARQL query does not match the SPARQL grammar. |
|
|
101
|
+
| `query.failed` | <https://www.w3.org/TR/sparql11-query/#sparqlDefinition> | The SPARQL query parsed but its evaluation was refused. |
|
|
102
|
+
| `update.syntax` | <https://www.w3.org/TR/sparql11-update/#grammar> | The SPARQL update does not match the SPARQL Update grammar. |
|
|
103
|
+
| `update.failed` | <https://www.w3.org/TR/sparql11-update/#updateLanguage> | The SPARQL update parsed but was refused. |
|
|
104
|
+
| `format.unknown` | - | The format name is not one the engine reads or writes. |
|
|
105
|
+
| `serialize.unsupported-rdf12` | <https://www.w3.org/TR/rdf12-concepts/#section-triple-terms> | The data holds an RDF 1.2 term and the requested RDF 1.1 output has no form for it. |
|
|
106
|
+
| `serialize.failed` | - | The writer refused the data. |
|
|
107
|
+
| `canon.unsupported-rdf12` | <https://www.w3.org/TR/rdf-canon/#canon-algorithm> | RDFC-1.0 defines no canonical labelling for RDF 1.2 triple terms or directional strings. |
|
|
108
|
+
| `canon.failed` | <https://www.w3.org/TR/rdf-canon/#canon-algorithm> | Canonicalization was refused. |
|
|
109
|
+
| `owl.unsupported-rdf12` | <https://www.w3.org/TR/owl2-mapping-to-rdf/#Mapping_from_RDF_Graphs_to_the_Structural_Specification> | OWL 2 Direct Semantics has no rule for RDF 1.2 triple terms or directional strings. |
|
|
110
|
+
| `reason.failed` | - | A closure or entailment check was refused. |
|
|
111
|
+
| `regime.refused` | <https://www.w3.org/TR/sparql11-entailment/> | The entailment regime is not implemented. |
|
|
112
|
+
| `validate.failed` | - | A validation (SHACL, ShEx, JSON Schema, Schematron) could not run on its inputs. |
|
|
113
|
+
| `xml.failed` | <https://www.w3.org/TR/xml/#sec-well-formed> | An XML, XPath, XSLT, MathML or XForms input was refused. |
|
|
114
|
+
| `crypto.failed` | - | A cryptographic or Verifiable Credentials operation refused its inputs. |
|
|
115
|
+
| `store.failed` | - | A persisted-store, pack or HDT operation was refused. |
|
|
116
|
+
| `registry.failed` | <https://www.w3.org/TR/sparql11-query/#extensionFunctions> | An extension-function or SERVICE registration was refused. |
|
|
117
|
+
| `handle.unknown` | - | The handle was never issued by this engine. |
|
|
118
|
+
| `handle.disposed` | - | The handle was issued and then closed. |
|
|
119
|
+
| `option.unknown` | - | The options object has a key the function does not take. |
|
|
120
|
+
| `option.invalid-value` | - | An option or mode argument has a value outside its table. |
|
|
121
|
+
| `option.conflict` | - | Two options were given that cannot be combined. |
|
|
122
|
+
| `capacity.exceeded` | - | The input is above a fixed size limit of the engine. |
|
|
123
|
+
| `call.arity` | - | The op was called with the wrong number of arguments. |
|
|
124
|
+
| `call.arguments` | - | The arguments document is not a JSON array of strings. |
|
|
125
|
+
| `op.unknown` | - | The loaded engine has no op of this name. |
|
|
126
|
+
| `op.failed` | - | Any other refusal of an op. |
|
|
127
|
+
| `host.io` | - | The host could not read or write a file, a store or a log (no engine decision). |
|
|
128
|
+
<!-- errors-table:end -->
|
|
129
|
+
|
|
73
130
|
Full API: [API.md](https://github.com/danbri/factoidal-core/blob/main/npm/factoidal/API.md).
|
|
74
131
|
Changes: [CHANGELOG.md](CHANGELOG.md).
|
|
75
132
|
|
|
@@ -92,15 +149,27 @@ $ npx factoidal solid-serve DIR # Solid Protocol
|
|
|
92
149
|
|
|
93
150
|
Official test suites, pass / fail / total, 2026-10-09 [B] unless marked.
|
|
94
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
|
+
|
|
95
163
|
| Suite | Result |
|
|
96
164
|
|---|---|
|
|
97
165
|
| SPARQL 1.1 Query, Update, Protocol | 631 / 0 / 631 |
|
|
98
166
|
| SPARQL 1.2 | 254 / 0 / 254 |
|
|
99
167
|
| SPARQL Protocol, Graph Store, Service Description against `serve` | 50 / 0 / 50 [P] |
|
|
168
|
+
| Linked Web Storage 1.0, Touchstone (another implementer's harness), `lws-serve` | 215 / 0 / 221 (6 not applicable) [L] |
|
|
100
169
|
| RDF 1.1 N-Triples, N-Quads, Turtle, TriG, RDF/XML | 992 / 0 / 992 |
|
|
101
170
|
| RDF 1.2 syntax suites | all pass |
|
|
102
171
|
| RDF 1.1 Semantics | 39 / 0 / 39 |
|
|
103
|
-
| RDF 1.2 Semantics |
|
|
172
|
+
| RDF 1.2 Semantics | 29 / 0 / 29 [S] |
|
|
104
173
|
| JSON-LD 1.1 toRdf, expand, flatten | 910 / 0 / 910 |
|
|
105
174
|
| JSON-LD 1.1 compact, fromRdf | 298 / 0 / 300 (2 not run) |
|
|
106
175
|
| JSON-LD 1.1 Framing | 29 / 63 / 92 |
|
|
@@ -111,7 +180,7 @@ Official test suites, pass / fail / total, 2026-10-09 [B] unless marked.
|
|
|
111
180
|
| SHACL 1.0 Core, SHACL-SPARQL | 120 / 0 / 120 |
|
|
112
181
|
| SHACL 1.2 Core | 103 / 30 / 133 |
|
|
113
182
|
| ShEx 2 validation, ShExC | 1624 / 0 / 1624 |
|
|
114
|
-
| CSVW | 821 /
|
|
183
|
+
| CSVW | 821 / 0 / 821 (1 defective W3C test excluded) [C] |
|
|
115
184
|
| RIF Core | 43 / 0 / 46 (3 undecided) |
|
|
116
185
|
| RML Core | 60 / 0 / 76 (16 not attempted) |
|
|
117
186
|
| XML 1.0 (xmlconf) | 1861 / 0 (516 not run) |
|
|
@@ -124,10 +193,13 @@ Official test suites, pass / fail / total, 2026-10-09 [B] unless marked.
|
|
|
124
193
|
[B] https://github.com/danbri/factoidal-core/blob/03e2e6deee8146ce7e6fdc3f8f34b8245af5ab54/docs/designissues/2026-10-09-lean-conformance-baseline.md
|
|
125
194
|
[P] https://github.com/danbri/factoidal-core/blob/9a4045f1572a032b53ccb867f8395fe0c8d97bb9/docs/designissues/2026-10-10-store-serve.md
|
|
126
195
|
[X] https://github.com/danbri/factoidal-core/blob/cb871c2de8354362ae6226eb57fb6c549624ea1a/docs/designissues/2026-10-10-xml-profile-widened.md
|
|
196
|
+
[L] 2026-10-22, https://github.com/danbri/factoidal-core/blob/2b9d2e39af53be31ed92463b58d3e132e65c4fe4/docs/designissues/2026-10-21-lws-touchstone-run.md (with server options, 220 of 221 pass in at least one configuration)
|
|
197
|
+
[S] 2026-10-21, the current W3C manifest (29 active tests), https://github.com/danbri/factoidal-core/blob/2b9d2e39af53be31ed92463b58d3e132e65c4fe4/docs/claude-rules/current-state.md
|
|
198
|
+
[C] 2026-10-21, validation test308, https://github.com/danbri/factoidal/issues/700
|
|
127
199
|
|
|
128
200
|
## Limits
|
|
129
201
|
|
|
130
|
-
- Size: 2.
|
|
202
|
+
- Size: 2.9 MB packed, 14.0 MB unpacked; one 13.2 MB wasm module.
|
|
131
203
|
- `factoidal update` and `compact` exit 3; updates work through `serve`
|
|
132
204
|
(https://github.com/danbri/factoidal/issues/641).
|
|
133
205
|
- Fuseki: read-only administration API, one dataset per process, no
|
package/api.d.ts
CHANGED
|
@@ -42,11 +42,10 @@ export function buildApi(driver: {
|
|
|
42
42
|
export function entryFromEngine(engine: object): Record<string, unknown>;
|
|
43
43
|
|
|
44
44
|
export const Dataset: typeof import('./index').Dataset;
|
|
45
|
-
export const DatasetHandle: typeof import('./index').DatasetHandle;
|
|
46
45
|
export const Store: typeof import('./index').Store;
|
|
47
46
|
export const dataFactory: import('./index').DataFactory;
|
|
48
|
-
/**
|
|
49
|
-
export const
|
|
47
|
+
/** The one error class; see index.d.ts's FactoidalError. */
|
|
48
|
+
export const FactoidalError: typeof import('./index').FactoidalError;
|
|
50
49
|
/** Package version string, e.g. '0.9.0'. */
|
|
51
50
|
export const version: string;
|
|
52
51
|
|
package/api.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
//
|
|
19
19
|
// openStore() here takes a reader function only: there is no file system.
|
|
20
20
|
|
|
21
|
-
import { buildApi,
|
|
21
|
+
import { buildApi, FactoidalError, Store } from './lib/api.mjs';
|
|
22
22
|
import { entryFromEngine } from './lib/lean-entry.mjs';
|
|
23
23
|
import { dataFactory } from './rdfjs.mjs';
|
|
24
24
|
import { Dataset } from './lib/dataset.mjs';
|
|
@@ -52,5 +52,5 @@ export function createApi(engine, options) {
|
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export { buildApi, entryFromEngine,
|
|
55
|
+
export { buildApi, entryFromEngine, FactoidalError, Store, Dataset, dataFactory, version };
|
|
56
56
|
export default createApi;
|
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
|
+
}
|