quario 0.0.1 → 0.2.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 +156 -0
- package/LICENSE +219 -0
- package/README.md +237 -1
- package/lib/index.d.ts +617 -0
- package/lib/index.js +290 -0
- package/lib/license.js +141 -0
- package/lib/locate.js +67 -0
- package/lib/names.js +19 -0
- package/lib/plan.js +1388 -0
- package/lib/scope.js +162 -0
- package/lib/stream.js +177 -0
- package/lib/style.js +81 -0
- package/package.json +55 -2
package/lib/index.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quario — a tiny, CSP-safe report engine. A report is a JSON document; text
|
|
3
|
+
* cells are sjabloon templates and every other dynamic value is an xprsn
|
|
4
|
+
* expression, so definitions are data, never code. Nothing here turns text into
|
|
5
|
+
* runnable code, so a report renders under a strict Content Security Policy.
|
|
6
|
+
* (The safety suite greps every source file for the forbidden string-to-code
|
|
7
|
+
* constructs, so they must not appear even in a comment.)
|
|
8
|
+
*
|
|
9
|
+
* This module is the public surface of the markup-unaware core: the instance
|
|
10
|
+
* factory, the one compile behind it, and `events()`, whose stream is the seam
|
|
11
|
+
* every render target consumes. The schema traversal that compiles a report
|
|
12
|
+
* definition lives in `./plan.js`; what the two of them share lives in the
|
|
13
|
+
* siblings beside it, one seam each. See SCHEMA.md for the document shape.
|
|
14
|
+
*/
|
|
15
|
+
import { relocate as relocateQuery } from "padvinder";
|
|
16
|
+
import { MARKING, verify } from "./license.js";
|
|
17
|
+
import { err, locate } from "./locate.js";
|
|
18
|
+
import { BLOCKED, NAME, record } from "./names.js";
|
|
19
|
+
import { plan } from "./plan.js";
|
|
20
|
+
import { aggregateValue, startRunners, withPage, withRow } from "./scope.js";
|
|
21
|
+
import { opt } from "./stream.js";
|
|
22
|
+
|
|
23
|
+
// The event stream's public seam, single-sourced in ./stream.js, and the
|
|
24
|
+
// diagnostic predicate in ./locate.js. Re-exported here because a consumer
|
|
25
|
+
// imports them from the package, not from a file inside it.
|
|
26
|
+
export { breathe, display, isReportBand, text, typed, walk } from "./stream.js";
|
|
27
|
+
export { isDiagnostic } from "./locate.js";
|
|
28
|
+
|
|
29
|
+
/** @typedef {import("./scope.js").Scope} Scope */
|
|
30
|
+
|
|
31
|
+
/** @type {(data: any) => any} */
|
|
32
|
+
let inputOf = (data) => data ?? {};
|
|
33
|
+
// Every aggregate is computed before any is injected, so results never depend
|
|
34
|
+
// on declaration order; a sibling read inside an aggregate expression is always
|
|
35
|
+
// the pre-injection null (see SCHEMA.md, deliberate asymmetries). Hence the two
|
|
36
|
+
// passes: one to fold, one to write the values onto the report root.
|
|
37
|
+
/** @type {(aggs: any, rows: any[], base: any, root: any) => Scope} */
|
|
38
|
+
let foldAggs = (aggs, rows, base, root) => {
|
|
39
|
+
/** @type {Scope} */
|
|
40
|
+
let aggregates = {};
|
|
41
|
+
for (let [name, fold] of aggs) aggregates[name] = aggregateValue(fold, rows, base);
|
|
42
|
+
for (let [name] of aggs) root[name] = aggregates[name];
|
|
43
|
+
return aggregates;
|
|
44
|
+
};
|
|
45
|
+
/** @type {(band: any, perPage: (items: any) => any) => any} */
|
|
46
|
+
let maybePage = (band, perPage) => band && perPage(band);
|
|
47
|
+
/** @type {(pageHeader: any, pageFooter: any, perPage: (items: any) => any) => any} */
|
|
48
|
+
let pageOf = (pageHeader, pageFooter, perPage) => {
|
|
49
|
+
if (!(pageHeader || pageFooter)) return null;
|
|
50
|
+
return opt(
|
|
51
|
+
{},
|
|
52
|
+
{ header: maybePage(pageHeader, perPage), footer: maybePage(pageFooter, perPage) },
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** @type {(name: any) => void} */
|
|
57
|
+
let checkTargetName = (name) => {
|
|
58
|
+
if (typeof name !== "string" || !NAME.test(name) || BLOCKED.has(name))
|
|
59
|
+
err("target.name: expected a plain identifier");
|
|
60
|
+
};
|
|
61
|
+
/** @type {(target: any) => void} */
|
|
62
|
+
let checkTarget = (target) => {
|
|
63
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
64
|
+
record(target) || err("target: expected a { name, compile } target");
|
|
65
|
+
checkTargetName(target.name);
|
|
66
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
67
|
+
typeof target.compile === "function" || err("target.compile: expected a function");
|
|
68
|
+
};
|
|
69
|
+
/** @type {(compile: Function, stream: any, data: any) => any} */
|
|
70
|
+
let runTarget = (compile, stream, data) => {
|
|
71
|
+
let render = compile(stream);
|
|
72
|
+
// oxlint-disable-next-line no-unused-expressions
|
|
73
|
+
typeof render === "function" ||
|
|
74
|
+
err("target.compile: expected compile(stream) to return a renderer");
|
|
75
|
+
return render(data);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check a report definition without rendering it.
|
|
80
|
+
*
|
|
81
|
+
* @param {any} schema The report document.
|
|
82
|
+
* @param {Record<string, Function>} [funcs] Functions callable from expressions.
|
|
83
|
+
* @returns {string[]} All definition problems, in document order.
|
|
84
|
+
*/
|
|
85
|
+
export function validate(schema, funcs) {
|
|
86
|
+
return plan(schema, funcs).problems.map((problem) => problem.message);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The structured problems, frozen for handing out: the traversal's own objects
|
|
90
|
+
// go to exactly one caller, so freezing here cannot disturb a second reader.
|
|
91
|
+
// The diagnostic stays an engine-minted error and is deliberately not frozen.
|
|
92
|
+
/** @type {(problems: any[]) => readonly any[]} */
|
|
93
|
+
let freezeProblems = (problems) => Object.freeze(problems.map((problem) => Object.freeze(problem)));
|
|
94
|
+
|
|
95
|
+
// The per-node anchor sets, as frozen host data (CONTEXT.md, "Freeze"): which
|
|
96
|
+
// anchors and group handles each compiled source reads, keyed by schema path.
|
|
97
|
+
/** @type {(anchors: Map<string, Set<string>>) => any} */
|
|
98
|
+
let freezeAnchors = (anchors) =>
|
|
99
|
+
Object.freeze(
|
|
100
|
+
Object.fromEntries(Array.from(anchors, ([path, set]) => [path, Object.freeze([...set])])),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Compile a report once, stream structured render events against data many times.
|
|
105
|
+
*
|
|
106
|
+
* The render seam every target consumes — markup, spreadsheet, and document
|
|
107
|
+
* exporters all read the same stream. The instance owns the public API; this
|
|
108
|
+
* is where `report()` does its one compile. The returned stream, called with
|
|
109
|
+
* data, generates the banded walk; the data pre-pass runs eagerly per call
|
|
110
|
+
* and event emission is lazy. See SCHEMA.md ("Event stream").
|
|
111
|
+
*
|
|
112
|
+
* @param {any} schema The report document (see SCHEMA.md).
|
|
113
|
+
* @param {Record<string, Function> | undefined} funcs Functions callable from expressions.
|
|
114
|
+
* @param {{query?: {maxNodes?: number, maxDepth?: number, maxResults?: number}} | undefined}
|
|
115
|
+
* options Host controls.
|
|
116
|
+
* @param {() => string | false} marking This render's unlicensed marking — its
|
|
117
|
+
* wording, or false when the render is covered. A thunk, so what it answers
|
|
118
|
+
* is this render's answer and not the compile's.
|
|
119
|
+
* @returns {{(data?: any): Generator<object, void, undefined>, names: string[],
|
|
120
|
+
* functions: { name: string, arity: number, doc?: string }[],
|
|
121
|
+
* paths: readonly any[]}} Event stream factory.
|
|
122
|
+
*/
|
|
123
|
+
function events(schema, funcs, options, marking) {
|
|
124
|
+
// The traversal collects every problem, so a thrown error is the first of the
|
|
125
|
+
// same list `validate()` would return.
|
|
126
|
+
let planned = plan(schema, funcs, options);
|
|
127
|
+
if (planned.problems.length)
|
|
128
|
+
throw planned.diagnostic() || SyntaxError(planned.problems[0].message);
|
|
129
|
+
return assemble(planned, schema, marking);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build the event stream factory from a settled traversal — the half of the
|
|
134
|
+
* one compile that runs only when the plan carried no problems, shared by
|
|
135
|
+
* `report()`'s throwing path and the instance's `plan()`.
|
|
136
|
+
*
|
|
137
|
+
* @param {ReturnType<typeof plan>} planned The finished traversal.
|
|
138
|
+
* @param {any} schema The report document, for locating `data` faults.
|
|
139
|
+
* @param {() => string | false} marking This render's unlicensed marking.
|
|
140
|
+
* @returns {{(data?: any): Generator<object, void, undefined>, names: string[],
|
|
141
|
+
* functions: { name: string, arity: number, doc?: string }[],
|
|
142
|
+
* paths: readonly any[]}} Event stream factory.
|
|
143
|
+
*/
|
|
144
|
+
function assemble(planned, schema, marking) {
|
|
145
|
+
// Null only for a non-object schema, which is itself a collected problem.
|
|
146
|
+
// `select` and `params` come from the traversal too: it checked them, so
|
|
147
|
+
// nothing here compiles or clones a second time.
|
|
148
|
+
let {
|
|
149
|
+
select,
|
|
150
|
+
params,
|
|
151
|
+
where,
|
|
152
|
+
sort,
|
|
153
|
+
take,
|
|
154
|
+
aggs,
|
|
155
|
+
running,
|
|
156
|
+
style,
|
|
157
|
+
header,
|
|
158
|
+
empty,
|
|
159
|
+
columns,
|
|
160
|
+
footer,
|
|
161
|
+
band,
|
|
162
|
+
pageHeader,
|
|
163
|
+
pageFooter,
|
|
164
|
+
} = /** @type {NonNullable<typeof planned.compiled>} */ (planned.compiled);
|
|
165
|
+
|
|
166
|
+
// The runner's own faults are located per render: a budget is spent while
|
|
167
|
+
// traversing this document, not while compiling the path.
|
|
168
|
+
/** @type {(data: any) => any[]} */
|
|
169
|
+
let runSelect = (data) => {
|
|
170
|
+
try {
|
|
171
|
+
return select(data);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
locate("data", schema.data, error, select.isDiagnostic(error) && relocateQuery);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** @type {(data: any) => Generator<any>} */
|
|
178
|
+
let emit = (data) => {
|
|
179
|
+
let root = Object.create(null);
|
|
180
|
+
root.params = params;
|
|
181
|
+
root.input = inputOf(data);
|
|
182
|
+
let base = Object.create(null);
|
|
183
|
+
base["$"] = root;
|
|
184
|
+
// The eager pre-pass: select → filter → report sort → report take →
|
|
185
|
+
// aggregates (over the rows that remain). It cannot be lazy because the
|
|
186
|
+
// report header may interpolate report aggregates; only the banded walk
|
|
187
|
+
// itself streams.
|
|
188
|
+
let rows = runSelect(root.input);
|
|
189
|
+
if (where) rows = rows.filter((row) => where(withRow(base, row)));
|
|
190
|
+
if (sort) rows = sort(rows, base);
|
|
191
|
+
if (take != null) rows = rows.slice(0, take);
|
|
192
|
+
let aggregates = foldAggs(aggs, rows, base, root);
|
|
193
|
+
// The report's runners, started per render like everything else here.
|
|
194
|
+
let runners = startRunners(running);
|
|
195
|
+
// Per-render page band closures: a paginated target calls them once per
|
|
196
|
+
// page with `{ number, total }` (1-based) and receives resolved item
|
|
197
|
+
// events. Unpaginated targets ignore the field.
|
|
198
|
+
/** @type {(items: any) => (page: any) => any[]} */
|
|
199
|
+
let perPage = (items) => (page) => [...items(withPage(base, page))];
|
|
200
|
+
let page = pageOf(pageHeader, pageFooter, perPage);
|
|
201
|
+
// The banded walk itself, over everything the pre-pass settled above. The
|
|
202
|
+
// marking rides on the opening event — its wording, so that a target owns
|
|
203
|
+
// only where it goes. Read as the event is yielded, so a render drained
|
|
204
|
+
// before verification settles counts as unlicensed (SCHEMA.md, "License
|
|
205
|
+
// keys").
|
|
206
|
+
/** @type {() => Generator<object, void, undefined>} */
|
|
207
|
+
function* walked() {
|
|
208
|
+
yield opt(
|
|
209
|
+
{ type: "report-start", params: root.params, aggregates },
|
|
210
|
+
{ page, columns, style: style(base), marking: marking() },
|
|
211
|
+
);
|
|
212
|
+
yield* header(base);
|
|
213
|
+
if (!rows.length && empty) yield* empty(base);
|
|
214
|
+
else yield* band(rows, base, runners);
|
|
215
|
+
yield* footer(base);
|
|
216
|
+
yield { type: "report-end" };
|
|
217
|
+
}
|
|
218
|
+
return walked();
|
|
219
|
+
};
|
|
220
|
+
return Object.assign(emit, {
|
|
221
|
+
names: Array.from(planned.names),
|
|
222
|
+
// Already signature objects — plan() paired each called name with its
|
|
223
|
+
// registry metadata through xprsn's signatures().
|
|
224
|
+
functions: planned.functions,
|
|
225
|
+
paths: select.paths,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** @typedef {import("./license.js").LicenseInfo} LicenseInfo */
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Create a configured quario instance: the host-level options — license key,
|
|
233
|
+
* query budgets. The key is verified once, here, and `license` settles with
|
|
234
|
+
* the result. `report()` compiles a definition once and every target renders
|
|
235
|
+
* from that one compiled stream through `render(target, data)`. See SCHEMA.md
|
|
236
|
+
* ("Instances and targets").
|
|
237
|
+
*
|
|
238
|
+
* @param {{query?: {maxNodes?: number, maxDepth?: number, maxResults?: number},
|
|
239
|
+
* license?: string, trust?: {publicKey?: string, release?: string}}} [options]
|
|
240
|
+
* Host configuration. `trust` is internal, not API — see ./license.js.
|
|
241
|
+
* @returns {{license: Promise<LicenseInfo>,
|
|
242
|
+
* report: (schema: any, funcs?: Record<string, Function>) => any,
|
|
243
|
+
* plan: (schema: any, funcs?: Record<string, Function>) => any}} The instance.
|
|
244
|
+
*/
|
|
245
|
+
export function quario(options) {
|
|
246
|
+
let { licensed, settled } = verify(options?.license, options?.trust);
|
|
247
|
+
// The wording is the instance's, where the key is; the stream is what
|
|
248
|
+
// states it, so nothing wraps a compiled stream to reach its first event.
|
|
249
|
+
/** @type {() => string | false} */
|
|
250
|
+
let marking = () => !licensed() && MARKING;
|
|
251
|
+
// The compiled report around a stream: what `report()` returns, and what
|
|
252
|
+
// `plan()` carries when the document had no problems.
|
|
253
|
+
/** @type {(stream: ReturnType<typeof events>) => any} */
|
|
254
|
+
let wrap = (stream) => ({
|
|
255
|
+
stream,
|
|
256
|
+
names: stream.names,
|
|
257
|
+
functions: stream.functions,
|
|
258
|
+
paths: stream.paths,
|
|
259
|
+
// Every render awaits verification before the target pulls its first
|
|
260
|
+
// event, so no render is marked merely for racing the check and no
|
|
261
|
+
// target carries that concern itself. Target shape problems throw
|
|
262
|
+
// synchronously — definition errors, like everything else — while a
|
|
263
|
+
// render is always a promise, even over a synchronous renderer.
|
|
264
|
+
render: (/** @type {any} */ target, /** @type {any} */ data) => {
|
|
265
|
+
checkTarget(target);
|
|
266
|
+
return settled.then(() => runTarget(target.compile, stream, data));
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
return {
|
|
270
|
+
license: settled,
|
|
271
|
+
/** @type {(schema: any, funcs?: Record<string, Function>) => any} */
|
|
272
|
+
report(schema, funcs) {
|
|
273
|
+
return wrap(events(schema, funcs, options, marking));
|
|
274
|
+
},
|
|
275
|
+
// The plan (CONTEXT.md): both readings of the one descent, kept.
|
|
276
|
+
// `report()` and `validate()` name what a caller wanted from it; this
|
|
277
|
+
// hands over the whole thing — the compiled report (null while the
|
|
278
|
+
// document has problems), every problem structurally, and the per-node
|
|
279
|
+
// anchor sets — so an editing host pays one traversal per edit, not two.
|
|
280
|
+
/** @type {(schema: any, funcs?: Record<string, Function>) => any} */
|
|
281
|
+
plan(schema, funcs) {
|
|
282
|
+
let planned = plan(schema, funcs, options);
|
|
283
|
+
return {
|
|
284
|
+
report: planned.problems.length ? null : wrap(assemble(planned, schema, marking)),
|
|
285
|
+
problems: freezeProblems(planned.problems),
|
|
286
|
+
anchors: freezeAnchors(planned.anchors),
|
|
287
|
+
};
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
package/lib/license.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// License keys (LICENSE section 6): offline verification, scoped to one
|
|
2
|
+
// instance, and the wording of the marking an unlicensed render carries.
|
|
3
|
+
// `quario()` calls `verify` once and closes over the result: no mutable state
|
|
4
|
+
// lives at module scope, so nothing one instance does can reach another.
|
|
5
|
+
// Verification is entirely offline, and asynchronous only because WebCrypto
|
|
6
|
+
// is.
|
|
7
|
+
|
|
8
|
+
// Release date of this version, written into the version commit by
|
|
9
|
+
// release-please (`x-release-please-date`). Both constants keep the `let`
|
|
10
|
+
// spelling that `scripts/license/keygen.mjs` matches on, though nothing
|
|
11
|
+
// reassigns them. A key is valid for every version released inside its window
|
|
12
|
+
// (LICENSE section 7), so validity compares ISO date strings and never reads
|
|
13
|
+
// a clock — accepted output stays accepted.
|
|
14
|
+
let RELEASE = "2026-09-01"; // x-release-please-date
|
|
15
|
+
// The verifying half of the signing pair: the 65-byte uncompressed P-256
|
|
16
|
+
// point, base64url. The private half never enters the repo;
|
|
17
|
+
// scripts/license/sign.mjs mints keys against it.
|
|
18
|
+
let PUBKEY =
|
|
19
|
+
"BEyXC0sH4pDoL-gGxLTqHguWi38WK2v7OrAfNvK8_4Ou8yLhcKgX5dzCoj1ojj6ez0B1cwMDPfejthqEQ4wZGjM";
|
|
20
|
+
|
|
21
|
+
// The marking wording, stated once for the whole engine and carried to every
|
|
22
|
+
// target on `report-start` (SCHEMA.md, "License keys"): the targets own where
|
|
23
|
+
// it goes, never what it says. Keep it encodable by WinAnsi — the PDF target
|
|
24
|
+
// draws it through a base-14 face, and the em dash is the one character here
|
|
25
|
+
// that had to be checked. The assertion that would catch a bad one lives in
|
|
26
|
+
// that target's suite (`packages/pdf/test/pdf.test.js`), not this package's.
|
|
27
|
+
export let MARKING = "Unlicensed evaluation — getquario.com";
|
|
28
|
+
|
|
29
|
+
let ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
30
|
+
let DECODER = new TextDecoder();
|
|
31
|
+
|
|
32
|
+
/** @type {(text: string) => Uint8Array<ArrayBuffer>} */
|
|
33
|
+
let unb64 = (text) => {
|
|
34
|
+
let bin = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
|
|
35
|
+
let bytes = new Uint8Array(bin.length);
|
|
36
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
37
|
+
return bytes;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** @type {(obj: any, name: string, fallback: string) => string} */
|
|
41
|
+
let field = (obj, name, fallback) => (obj && obj[name]) || fallback;
|
|
42
|
+
|
|
43
|
+
/** @type {(key: string) => [string, Uint8Array<ArrayBuffer>]} */
|
|
44
|
+
let parseKey = (key) => {
|
|
45
|
+
if (!key.startsWith("quario_")) throw Error("not a quario_ key");
|
|
46
|
+
let [body, signature, extra] = key.slice(7).split(".");
|
|
47
|
+
if (!signature || extra !== undefined) throw Error("expected <payload>.<signature>");
|
|
48
|
+
return [signature, unb64(body)];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** @type {(value: any) => boolean} */
|
|
52
|
+
let isStr = (value) => typeof value === "string";
|
|
53
|
+
|
|
54
|
+
/** @type {(payload: any) => void} */
|
|
55
|
+
let checkPayload = (payload) => {
|
|
56
|
+
if (!payload || !isStr(payload.id) || !isStr(payload.name))
|
|
57
|
+
throw Error("payload is missing id/name");
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** @type {(payload: any) => void} */
|
|
61
|
+
let checkWindow = (payload) => {
|
|
62
|
+
if (!payload.id.startsWith("1-")) throw Error("unsupported key format version");
|
|
63
|
+
if (!ISO_DATE.test(payload.from) || !ISO_DATE.test(payload.to))
|
|
64
|
+
throw Error("payload window is not a pair of ISO dates");
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** @type {() => Crypto["subtle"]} */
|
|
68
|
+
let cryptoSubtle = () => {
|
|
69
|
+
let subtle = globalThis.crypto && globalThis.crypto.subtle;
|
|
70
|
+
if (!subtle) throw Error("crypto.subtle is unavailable (secure contexts only)");
|
|
71
|
+
return subtle;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** @type {(subtle: Crypto["subtle"], pubkey: string, signature: string, bytes: BufferSource) => Promise<void>} */
|
|
75
|
+
let importAndVerify = async (subtle, pubkey, signature, bytes) => {
|
|
76
|
+
let curve = { name: "ECDSA", namedCurve: "P-256" };
|
|
77
|
+
let pub = await subtle.importKey("raw", unb64(pubkey), curve, false, ["verify"]);
|
|
78
|
+
let hash = { name: "ECDSA", hash: "SHA-256" };
|
|
79
|
+
if (!(await subtle.verify(hash, pub, unb64(signature), bytes)))
|
|
80
|
+
throw Error("signature does not match");
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** @type {(payload: any, release: string) => void} */
|
|
84
|
+
let checkCover = (payload, release) => {
|
|
85
|
+
if (!(payload.from <= release && release <= payload.to))
|
|
86
|
+
throw Error("window does not cover the " + release + " release");
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** @type {(error: unknown) => string} */
|
|
90
|
+
let failMsg = (error) => (error instanceof Error ? error.message : String(error));
|
|
91
|
+
|
|
92
|
+
/** @typedef {{ licensed: boolean, licensee?: string, id?: string }} LicenseInfo */
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Verify one instance's license key.
|
|
96
|
+
*
|
|
97
|
+
* `settled` is the instance's public `license` promise and the gate every
|
|
98
|
+
* render awaits; `licensed()` is the same answer readable synchronously, for
|
|
99
|
+
* the marking on the opening event. A render drained before settlement reads
|
|
100
|
+
* `false` and is marked — the shipped targets await settlement first. Every
|
|
101
|
+
* failure (including a missing `crypto.subtle` outside secure contexts)
|
|
102
|
+
* soft-fails to marked output and warns once; the feature set never gates
|
|
103
|
+
* (LICENSE section 6).
|
|
104
|
+
*
|
|
105
|
+
* @param {string | undefined} key The instance's `license` option.
|
|
106
|
+
* @param {{publicKey?: string, release?: string}} [trust] What this instance
|
|
107
|
+
* verifies against, defaulting to the shipped key and release date. Internal
|
|
108
|
+
* — a test seam, not API: the production private key never enters the repo,
|
|
109
|
+
* so suites verify against freshly generated pairs.
|
|
110
|
+
* @returns {{licensed: () => boolean, settled: Promise<LicenseInfo>}}
|
|
111
|
+
*/
|
|
112
|
+
export let verify = (key, trust) => {
|
|
113
|
+
let pubkey = field(trust, "publicKey", PUBKEY);
|
|
114
|
+
let release = field(trust, "release", RELEASE);
|
|
115
|
+
let valid = false;
|
|
116
|
+
let settled = (async () => {
|
|
117
|
+
if (!key) return { licensed: false };
|
|
118
|
+
// Every throw below is caught here and turned into marked output, so none
|
|
119
|
+
// of them is a definition error — the engine's thrower means the opposite.
|
|
120
|
+
try {
|
|
121
|
+
let [signature, bytes] = parseKey(key);
|
|
122
|
+
let payload = JSON.parse(DECODER.decode(bytes));
|
|
123
|
+
checkPayload(payload);
|
|
124
|
+
checkWindow(payload);
|
|
125
|
+
await importAndVerify(cryptoSubtle(), pubkey, signature, bytes);
|
|
126
|
+
checkCover(payload, release);
|
|
127
|
+
// Set before this promise resolves: `render` awaits `settled`, so a
|
|
128
|
+
// verified instance must never still read as unlicensed afterwards.
|
|
129
|
+
valid = true;
|
|
130
|
+
return { licensed: true, licensee: payload.name, id: payload.id };
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.warn(
|
|
133
|
+
"quario: license key rejected (" +
|
|
134
|
+
failMsg(error) +
|
|
135
|
+
") - output will be marked, see LICENSE section 6",
|
|
136
|
+
);
|
|
137
|
+
return { licensed: false };
|
|
138
|
+
}
|
|
139
|
+
})();
|
|
140
|
+
return { licensed: () => valid, settled };
|
|
141
|
+
};
|
package/lib/locate.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Located diagnostics: where quario re-throws an engine's error with the
|
|
3
|
+
* band/item path and offending source attached.
|
|
4
|
+
*/
|
|
5
|
+
import { isDiagnostic as isQueryDiagnostic } from "padvinder";
|
|
6
|
+
import { isDiagnostic as isXprsnDiagnostic } from "xprsn";
|
|
7
|
+
import { isDiagnostic as isTemplateDiagnostic } from "sjabloon";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* True when an error is one of the stack's authenticated diagnostics — thrown
|
|
11
|
+
* by an engine (xprsn, sjabloon, or padvinder) directly, or re-thrown by
|
|
12
|
+
* quario through that engine's `relocate` — i.e. it safely carries the
|
|
13
|
+
* optional `code`/`start`/`end`/`limit`/`actual`/`blocks` metadata.
|
|
14
|
+
* Authentication is by identity: an error merely shaped like a diagnostic does
|
|
15
|
+
* not pass, and a located copy passes because the engine that minted the
|
|
16
|
+
* original made the copy.
|
|
17
|
+
*
|
|
18
|
+
* @param {unknown} error The caught value.
|
|
19
|
+
* @returns {boolean} Whether `error` is a located diagnostic.
|
|
20
|
+
*/
|
|
21
|
+
export function isDiagnostic(error) {
|
|
22
|
+
return isXprsnDiagnostic(error) || isTemplateDiagnostic(error) || isQueryDiagnostic(error);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @type {(msg: string) => never} */
|
|
26
|
+
export let err = (msg) => {
|
|
27
|
+
throw Error(msg);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// How a located error remembers where it happened as data: the traversal reads
|
|
31
|
+
// this to build a structured problem, so nothing parses a message back apart.
|
|
32
|
+
// A symbol, so the located copy's public shape stays exactly the engine's.
|
|
33
|
+
export let LOCATION = Symbol("quario.location");
|
|
34
|
+
|
|
35
|
+
/** @type {(path: string, msg: string) => never} */
|
|
36
|
+
export let fault = (path, msg) => {
|
|
37
|
+
throw Object.assign(Error(path + ": " + msg), { [LOCATION]: { path } });
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Re-throw `error` located at `path`, with `src` — the offending author
|
|
42
|
+
* source — in the message.
|
|
43
|
+
*
|
|
44
|
+
* A diagnostic is relocated by the engine that authenticated it, so the copy
|
|
45
|
+
* carries every field that engine puts on it and passes that engine's own
|
|
46
|
+
* guard (ADR 0024). `relocate` is that engine's relocation — and a verdict as
|
|
47
|
+
* much as a callback: the call site holds the guard, because two of them are
|
|
48
|
+
* scoped to one evaluator rather than to the module, and `false` is its way of
|
|
49
|
+
* saying the guard rejected this error. Anything else — a host function's
|
|
50
|
+
* throw, quario's own verdict on image bytes — is wrapped as a plain error
|
|
51
|
+
* with no metadata at all.
|
|
52
|
+
*
|
|
53
|
+
* @type {(path: string, src: any, error: any, relocate?: false | ((e: any, o: { prefix: string, offset: number }) => any), offset?: number) => never}
|
|
54
|
+
*/
|
|
55
|
+
export let locate = (path, src, error, relocate, offset = 0) => {
|
|
56
|
+
let prefix = (src === undefined ? path : path + " [" + String(src) + "]") + ": ";
|
|
57
|
+
let where = { [LOCATION]: { path, source: src } };
|
|
58
|
+
if (relocate) throw Object.assign(relocate(error, { prefix, offset }), where);
|
|
59
|
+
// A host throw keeps its own class — the located error is the same kind of
|
|
60
|
+
// failure, named where it happened — and gains no diagnostic metadata.
|
|
61
|
+
throw Object.assign(
|
|
62
|
+
error instanceof Error
|
|
63
|
+
? new /** @type {any} */ (error.constructor)(prefix + error.message)
|
|
64
|
+
: Error(prefix + String(error)),
|
|
65
|
+
where,
|
|
66
|
+
);
|
|
67
|
+
};
|
package/lib/names.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine's closed name vocabulary: what counts as an object worth
|
|
3
|
+
* descending into, what an author may name, and the anchors the engine binds
|
|
4
|
+
* for itself. The traversal checks a report definition against it, and the
|
|
5
|
+
* instance checks a target's `{ name, compile }` shape against the same rules —
|
|
6
|
+
* one spelling of "a plain identifier", wherever a name is offered.
|
|
7
|
+
*/
|
|
8
|
+
/** @type {(value: any) => boolean} */
|
|
9
|
+
export let record = (value) => value && typeof value === "object" && !Array.isArray(value);
|
|
10
|
+
export let NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
11
|
+
// The anchors quario actually binds at evaluation time.
|
|
12
|
+
export let ANCHORS = ["@", "$", "run", "loop", "page"];
|
|
13
|
+
// Refused as group handle names: the anchors above plus the namespace held for
|
|
14
|
+
// future ones. Engine-provided values arrive on a new anchor rather than as new
|
|
15
|
+
// fields on `$` or a group handle (SCHEMA.md, "Stability"), so protecting the
|
|
16
|
+
// anchor namespace is the whole of what the authoring surface has to reserve --
|
|
17
|
+
// aggregate and run names stay the author's, `count` and `index` included.
|
|
18
|
+
export let RESERVED = new Set([...ANCHORS, "report", "table", "data", "meta"]);
|
|
19
|
+
export let BLOCKED = new Set(["__proto__", "constructor", "prototype"]);
|