wizards 0.2.28 → 0.2.30
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/apps/runtime/dist/auth/account.js +8 -0
- package/apps/runtime/dist/auth/index.js +3 -0
- package/apps/runtime/dist/authoring/guide.js +8 -2
- package/apps/runtime/dist/credits/estimate.js +4 -0
- package/apps/runtime/dist/engine/requirements.js +4 -0
- package/apps/runtime/dist/engine/steps.js +42 -1
- package/apps/runtime/dist/env.js +5 -0
- package/apps/runtime/dist/index.js +4 -0
- package/apps/runtime/dist/manage.js +6 -1
- package/apps/runtime/dist/plugins/loader.js +9 -2
- package/apps/runtime/dist/plugins/pro-keys.js +34 -0
- package/apps/runtime/dist/plugins/pro-state.js +83 -0
- package/apps/runtime/dist/plugins/pro.js +316 -0
- package/apps/runtime/dist/routes/plugins.js +49 -0
- package/apps/runtime/dist/routes/studio.js +3 -0
- package/apps/runtime/dist/tools/plugin.js +49 -25
- package/apps/web/dist/assets/{Account-CVMSZTCv.js → Account-9HRO4JWr.js} +1 -1
- package/apps/web/dist/assets/{ChangelogDialog-3N1CgtWP.js → ChangelogDialog-DXH1HcDL.js} +1 -1
- package/apps/web/dist/assets/{CreditsDialog-BW-RETl-.js → CreditsDialog-BtID6slV.js} +1 -1
- package/apps/web/dist/assets/{EditorPage-DnuHM3nW.js → EditorPage-DhAf6LjO.js} +1 -1
- package/apps/web/dist/assets/{FlowPreview-Ds6ul7-N.js → FlowPreview-D3ZFAeax.js} +1 -1
- package/apps/web/dist/assets/{Harness-D-QoXcSe.js → Harness-mggD1ecl.js} +1 -1
- package/apps/web/dist/assets/{HomePage-Ch0HlwVX.js → HomePage-CBhmQH5G.js} +1 -1
- package/apps/web/dist/assets/{ImportWizard-DVcYcB6l.js → ImportWizard-BB8oJW0X.js} +1 -1
- package/apps/web/dist/assets/{LocalRuntime-D7bzT40S.js → LocalRuntime-Dyvqh8HP.js} +1 -1
- package/apps/web/dist/assets/{NewWizardPage-DvA6AX7D.js → NewWizardPage-CLFsjQgm.js} +2 -2
- package/apps/web/dist/assets/{ProjectPage-BWAISSfX.js → ProjectPage-BwUhAgVx.js} +1 -1
- package/apps/web/dist/assets/{PublicRunner-D2bd95TR.js → PublicRunner-EfNAsmpU.js} +1 -1
- package/apps/web/dist/assets/{RunDrawer-DkOCgXAR.js → RunDrawer-Cwtrh-Iz.js} +1 -1
- package/apps/web/dist/assets/{RunnerView-DF5uW_95.js → RunnerView-EQpbU4EU.js} +1 -1
- package/apps/web/dist/assets/SettingsPage-CASSFJS5.js +1 -0
- package/apps/web/dist/assets/{SetupPage-BaT-VqIc.js → SetupPage-BTjh2p6y.js} +1 -1
- package/apps/web/dist/assets/{SharePage-COKOsuvD.js → SharePage-BSD9UL9f.js} +1 -1
- package/apps/web/dist/assets/arrow-right-CzoY677S.js +1 -0
- package/apps/web/dist/assets/changelog-j7qikrRS.js +1 -0
- package/apps/web/dist/assets/{circle-alert-BG9P3ewL.js → circle-alert-CN6dFS0d.js} +1 -1
- package/apps/web/dist/assets/definition-Cx9uG0KU.js +1 -0
- package/apps/web/dist/assets/{esm-0rdZWuyu.js → esm-CKwbhTDV.js} +1 -1
- package/apps/web/dist/assets/{external-link-BwCtn1nW.js → external-link-DvkH63pi.js} +1 -1
- package/apps/web/dist/assets/{image-DG4q2P1H.js → image-BUbtvL1h.js} +1 -1
- package/apps/web/dist/assets/{index-DHM9S48l.js → index-C4KaV94L.js} +4 -4
- package/apps/web/dist/assets/{lock-DH_BntP_.js → lock-COAprdab.js} +1 -1
- package/apps/web/dist/assets/{meta-DI0ahJ_z.js → meta-vzJM2HEw.js} +1 -1
- package/apps/web/dist/assets/{pen-line-B1dsy3Sr.js → pen-line-JsP-r_aH.js} +1 -1
- package/apps/web/dist/assets/{refresh-cw-B9M1g3SH.js → refresh-cw-DLXaS3Ri.js} +1 -1
- package/apps/web/dist/assets/{settings-menu-DOjgaq6J.js → settings-menu-Ds_wegZm.js} +1 -1
- package/apps/web/dist/assets/{star-axQV_3EC.js → star-CpnGQZXD.js} +1 -1
- package/apps/web/dist/assets/{surface-BE-3Z1DP.js → surface-BFwNWkx9.js} +2 -2
- package/apps/web/dist/assets/{trash-3S1_XpVa.js → trash-CQZuqdcu.js} +1 -1
- package/apps/web/dist/assets/{ui-BZ01zr3i.js → ui-BTkH_7CG.js} +1 -1
- package/apps/web/dist/assets/{upload-U0i8fAW3.js → upload-Dok4YeXQ.js} +1 -1
- package/apps/web/dist/assets/{video-Db4o719f.js → video-BjQoPIsn.js} +1 -1
- package/apps/web/dist/assets/{volume-2-6f7qN_LM.js → volume-2-D-Bn77h4.js} +1 -1
- package/apps/web/dist/assets/{wand-sparkles-MelvBtiK.js → wand-sparkles-D3sDsFFG.js} +1 -1
- package/apps/web/dist/index.html +3 -3
- package/apps/web/dist/mcp/flow.html +49 -49
- package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +15 -3
- package/node_modules/@engenty-wizards/shared/dist/definition.js +38 -9
- package/node_modules/@engenty-wizards/shared/dist/marketplace.js +8 -1
- package/package.json +1 -1
- package/apps/web/dist/assets/SettingsPage-CMTGFXit.js +0 -1
- package/apps/web/dist/assets/arrow-right-hy38IBRU.js +0 -1
- package/apps/web/dist/assets/changelog-C8wFW63z.js +0 -1
- package/apps/web/dist/assets/definition-BiUSuZN5.js +0 -1
|
@@ -91,6 +91,14 @@ export async function accountToken() {
|
|
|
91
91
|
if (cached && cached.expiresAt - Date.now() > 60_000) {
|
|
92
92
|
return cached.token;
|
|
93
93
|
}
|
|
94
|
+
// One refresh at a time: a refresh token is used once, a second caller would be refused.
|
|
95
|
+
refreshing ??= refreshAccount().finally(() => {
|
|
96
|
+
refreshing = null;
|
|
97
|
+
});
|
|
98
|
+
return refreshing;
|
|
99
|
+
}
|
|
100
|
+
let refreshing = null;
|
|
101
|
+
async function refreshAccount() {
|
|
94
102
|
const refresh = await vaultGet(REFRESH);
|
|
95
103
|
if (!refresh) {
|
|
96
104
|
return null;
|
|
@@ -7,6 +7,7 @@ import { nanoid } from "nanoid";
|
|
|
7
7
|
import { control, controlDb, withTenant } from "../db/client.js";
|
|
8
8
|
import { basePath, env } from "../env.js";
|
|
9
9
|
import { discovery, managed, tokenRequest, verifyToken, } from "../manage.js";
|
|
10
|
+
import { refreshPro } from "../plugins/pro.js";
|
|
10
11
|
import { seal, unseal } from "../secrets/crypto.js";
|
|
11
12
|
import { LOCAL_TENANT } from "../tenants/tenant.js";
|
|
12
13
|
import { finishLink } from "./account.js";
|
|
@@ -278,6 +279,8 @@ export const authRoutes = new Hono()
|
|
|
278
279
|
await finishLink(code, rawState);
|
|
279
280
|
// What is published here goes to the account's cloud, from now on and once for all there is.
|
|
280
281
|
void firstSync();
|
|
282
|
+
// The Pro modules the account's plan includes.
|
|
283
|
+
void refreshPro();
|
|
281
284
|
return c.html(closePage("Angemeldet. Du kannst dieses Fenster schließen."));
|
|
282
285
|
}
|
|
283
286
|
catch (err) {
|
|
@@ -114,12 +114,18 @@ type Field = {
|
|
|
114
114
|
// {{field}} → asset://ID, used as <img src="asset://ID">.
|
|
115
115
|
// slot: the person picks one appointment time, shown by day. Options are ISO 8601 date-times with their offset
|
|
116
116
|
// ("2026-10-12T09:00:00+02:00"), nearly always from optionsFrom; {{field}} is the chosen one, as offered.
|
|
117
|
-
// Booking: agent step "free" with
|
|
117
|
+
// Booking: agent step "free" with call { tool: "appointments.availability", input: { duration_minutes: 30, days: 14 } }
|
|
118
|
+
// and output { format: "json", fields: [{ id: "slots", kind: "table", columns: ["start","end","label"] }] };
|
|
118
119
|
// then a page { kind: "slot", optionsFrom: "steps.free.slots.start" }; then an agent step with appointments.book.
|
|
119
120
|
|
|
120
121
|
type AgentStep = {
|
|
121
122
|
type: "agent"
|
|
122
|
-
instructions: string // the task, with {{templates}}
|
|
123
|
+
instructions: string // the task, with {{templates}} ("" with call)
|
|
124
|
+
call?: { tool: "<plugin>.<tool>", input: { … } }
|
|
125
|
+
// NO MODEL: one plugin tool called once with this input; its answer IS the json output
|
|
126
|
+
// (fields name the keys later steps read). Use it where a model would only fetch and copy
|
|
127
|
+
// data (free appointment times) — it answers in a second instead of half a minute.
|
|
128
|
+
// String inputs are templates; a filled-in whole number or true/false is passed as one.
|
|
123
129
|
tools: ("web_search"|"web_fetch"|"browser"|"sandbox"|"image"|"http"|"pages")[]
|
|
124
130
|
mcp?: string[] // ids of the project's MCP servers this step may use
|
|
125
131
|
connections?: string[] // ids of wizard connections this step may read (mail → mail_search, mail_read, mail_save)
|
|
@@ -37,6 +37,10 @@ function formula(step, catalog) {
|
|
|
37
37
|
function stepFormula(step, catalog) {
|
|
38
38
|
const cls = (c) => catalog.classes[c];
|
|
39
39
|
if (step.type === "agent") {
|
|
40
|
+
// A call runs one tool and no model: nothing to pay for.
|
|
41
|
+
if (step.call) {
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
40
44
|
// A decision is one small call to the classifier class.
|
|
41
45
|
if (isDecisionStep(step)) {
|
|
42
46
|
return textCredits(cls("classifier"), TOKENS.decision);
|
|
@@ -11,7 +11,7 @@ import { markMedia } from "../media/marking.js";
|
|
|
11
11
|
import { attachTools, costOf, isHarnessVendor, textModel } from "../models.js";
|
|
12
12
|
import { knowledgeBlock } from "../services/knowledge.js";
|
|
13
13
|
import { buildStepTools } from "../tools/index.js";
|
|
14
|
-
import { spaceContextOf } from "../tools/plugin.js";
|
|
14
|
+
import { callPluginTool, spaceContextOf } from "../tools/plugin.js";
|
|
15
15
|
import { personUploads } from "../tools/store.js";
|
|
16
16
|
import { runWidgetStep } from "../widgets/step.js";
|
|
17
17
|
import { decide } from "./decide.js";
|
|
@@ -338,7 +338,48 @@ async function runAgentEach(step, ctx) {
|
|
|
338
338
|
at: new Date().toISOString(),
|
|
339
339
|
};
|
|
340
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* A call's input as the step's answers fill it: string values are templates; a filled-in whole
|
|
343
|
+
* number or true/false becomes one, an empty value is left out.
|
|
344
|
+
*/
|
|
345
|
+
function callInput(value, ctx) {
|
|
346
|
+
if (typeof value === "string") {
|
|
347
|
+
const text = renderTemplate(value, ctx.scope).trim();
|
|
348
|
+
if (!text) {
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
if (/^-?\d+(\.\d+)?$/.test(text)) {
|
|
352
|
+
return Number(text);
|
|
353
|
+
}
|
|
354
|
+
return text === "true" ? true : text === "false" ? false : text;
|
|
355
|
+
}
|
|
356
|
+
if (Array.isArray(value)) {
|
|
357
|
+
return value.map((v) => callInput(v, ctx)).filter((v) => v !== undefined);
|
|
358
|
+
}
|
|
359
|
+
if (value && typeof value === "object") {
|
|
360
|
+
return Object.fromEntries(Object.entries(value)
|
|
361
|
+
.map(([k, v]) => [k, callInput(v, ctx)])
|
|
362
|
+
.filter(([, v]) => v !== undefined));
|
|
363
|
+
}
|
|
364
|
+
return value;
|
|
365
|
+
}
|
|
366
|
+
/** A step's `call`: one plugin tool, no model; its answer is the step's json output as it is. */
|
|
367
|
+
async function runCallStep(step, ctx) {
|
|
368
|
+
// What it is doing is the step's `working` line, shown as it starts, and what the tool says.
|
|
369
|
+
const answer = await callPluginTool(step.call.tool, callInput(step.call.input, ctx), ctx);
|
|
370
|
+
if (answer &&
|
|
371
|
+
typeof answer === "object" &&
|
|
372
|
+
"error" in answer &&
|
|
373
|
+
Object.keys(answer).length === 1) {
|
|
374
|
+
throw new StepError(String(answer.error));
|
|
375
|
+
}
|
|
376
|
+
const json = answer && typeof answer === "object" && !Array.isArray(answer) ? answer : { result: answer };
|
|
377
|
+
return { json, at: new Date().toISOString() };
|
|
378
|
+
}
|
|
341
379
|
export async function runAgentStep(step, ctx) {
|
|
380
|
+
if (step.call) {
|
|
381
|
+
return runCallStep({ ...step, call: step.call }, ctx);
|
|
382
|
+
}
|
|
342
383
|
if (step.each) {
|
|
343
384
|
return runAgentEach(step, ctx);
|
|
344
385
|
}
|
package/apps/runtime/dist/env.js
CHANGED
|
@@ -222,6 +222,11 @@ export const env = {
|
|
|
222
222
|
watch: !manageUrl && str("PLUGINS_WATCH", "1") === "1",
|
|
223
223
|
/** Plugins' jobs (`server.every`) run. Off (`PLUGIN_JOBS=0`) where another process runs them. */
|
|
224
224
|
jobs: str("PLUGIN_JOBS", "1") === "1",
|
|
225
|
+
/**
|
|
226
|
+
* Public keys (Ed25519, SPKI DER in base64) whose signature a Pro module may carry, besides
|
|
227
|
+
* the one this runtime ships with (plugins/pro-keys.ts). For testing packages signed elsewhere.
|
|
228
|
+
*/
|
|
229
|
+
proKeys: list("PRO_MODULES_KEYS"),
|
|
225
230
|
},
|
|
226
231
|
limits: {
|
|
227
232
|
visitorRunsPerHour: num("LIMIT_VISITOR_RUNS_PER_HOUR", 6),
|
|
@@ -8,6 +8,7 @@ import { managed } from "./manage.js";
|
|
|
8
8
|
import { checkModelsAtStart, loadCatalog, loadLocalModels } from "./models.js";
|
|
9
9
|
import { startJobs } from "./plugins/jobs.js";
|
|
10
10
|
import { loadPlugins } from "./plugins/loader.js";
|
|
11
|
+
import { loadEntitlement, startProChecks } from "./plugins/pro.js";
|
|
11
12
|
import { closeBrowser } from "./render/chromium.js";
|
|
12
13
|
import { clearRunning, writeRunning } from "./running.js";
|
|
13
14
|
import { syncMarketplace } from "./services/marketplace.js";
|
|
@@ -16,6 +17,8 @@ import { purgeUnusedStores } from "./store/index.js";
|
|
|
16
17
|
import { LOCAL_TENANT } from "./tenants/tenant.js";
|
|
17
18
|
import { onRestart } from "./update.js";
|
|
18
19
|
await migrateControlDb();
|
|
20
|
+
// What the linked account's plan confirmed last: the Pro modules it includes load with the rest.
|
|
21
|
+
await loadEntitlement();
|
|
19
22
|
// Before any tenant database opens: each gets the plugins' own tables as it does.
|
|
20
23
|
await loadPlugins();
|
|
21
24
|
startJobs();
|
|
@@ -31,6 +34,7 @@ else {
|
|
|
31
34
|
await loadLocalModels();
|
|
32
35
|
void loadCatalog();
|
|
33
36
|
void checkModelsAtStart().catch(() => undefined);
|
|
37
|
+
startProChecks();
|
|
34
38
|
}
|
|
35
39
|
// The marketplace's starters and the starred entries, kept for offline use; checked hourly.
|
|
36
40
|
void syncMarketplace();
|
|
@@ -47,6 +47,11 @@ function toClaims(payload) {
|
|
|
47
47
|
* `audiences` are the resources this caller accepts; null skips the audience check.
|
|
48
48
|
*/
|
|
49
49
|
export async function verifyToken(token, audiences, base = env.manage.url) {
|
|
50
|
+
const payload = await verifySigned(token, audiences, base);
|
|
51
|
+
return payload ? toClaims(payload) : null;
|
|
52
|
+
}
|
|
53
|
+
/** Any token the Manage-App at `base` signed (its JWKS, its issuer); null when it is not one. */
|
|
54
|
+
export async function verifySigned(token, audiences, base = env.manage.url) {
|
|
50
55
|
try {
|
|
51
56
|
const d = await discovery(base);
|
|
52
57
|
let keys = keySets.get(d.jwks_uri);
|
|
@@ -58,7 +63,7 @@ export async function verifyToken(token, audiences, base = env.manage.url) {
|
|
|
58
63
|
issuer: d.issuer,
|
|
59
64
|
...(audiences ? { audience: audiences } : {}),
|
|
60
65
|
});
|
|
61
|
-
return
|
|
66
|
+
return payload;
|
|
62
67
|
}
|
|
63
68
|
catch {
|
|
64
69
|
return null;
|
|
@@ -10,12 +10,14 @@ import { pluginConnections } from "../connectors/plugin-connections.js";
|
|
|
10
10
|
import { db, onTenantOpen } from "../db/client.js";
|
|
11
11
|
import { parseDocument } from "../documents/parse.js";
|
|
12
12
|
import { env } from "../env.js";
|
|
13
|
+
import { managed } from "../manage.js";
|
|
13
14
|
import { ModelUnavailableError, textModel } from "../models.js";
|
|
14
15
|
import { putIndexEntry, removeIndexEntries } from "../services/project-index.js";
|
|
15
16
|
import { originData } from "../services/space-origin.js";
|
|
16
17
|
import { safeFetch } from "../tools/net-guard.js";
|
|
17
18
|
import { discoverPlugins } from "./discovery.js";
|
|
18
19
|
import { migrateOpenTenants, migratePlugins } from "./migrations.js";
|
|
20
|
+
import { proModulesDir, proSources } from "./pro-state.js";
|
|
19
21
|
import { publicUrlOf } from "./public.js";
|
|
20
22
|
import { announceChange, compileRoute, dropPlugin, emptyRecord, loadedPlugin, loadedPlugins, setPlugin, } from "./registry.js";
|
|
21
23
|
/**
|
|
@@ -231,11 +233,16 @@ export function pluginProblems() {
|
|
|
231
233
|
}
|
|
232
234
|
function discover() {
|
|
233
235
|
const result = discoverPlugins(pluginDirs());
|
|
234
|
-
|
|
236
|
+
// Pro modules come last and only while the plan has them (pro-state.ts).
|
|
237
|
+
const pro = managed ? null : discoverPlugins([proModulesDir()]);
|
|
238
|
+
const allowed = pro
|
|
239
|
+
? proSources(pro.found, new Set(result.found.map((s) => s.id)))
|
|
240
|
+
: { load: [], problems: [] };
|
|
241
|
+
problems = [...result.problems, ...(pro?.problems ?? []), ...allowed.problems];
|
|
235
242
|
for (const problem of problems) {
|
|
236
243
|
console.error(`[plugins] ${problem.path}: ${problem.message}`);
|
|
237
244
|
}
|
|
238
|
-
return result.found;
|
|
245
|
+
return [...result.found, ...allowed.load];
|
|
239
246
|
}
|
|
240
247
|
/** One load at a time: the watcher and a person may ask for a reload at the same moment. */
|
|
241
248
|
let queue = Promise.resolve();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createPublicKey, verify } from "node:crypto";
|
|
2
|
+
import { env } from "../env.js";
|
|
3
|
+
/**
|
|
4
|
+
* The keys a Pro module's package must be signed with (docs/content/dev/plugins/pro-modules).
|
|
5
|
+
* Only the public halves are here; the private one signs in the build of the closed plugins and
|
|
6
|
+
* is kept nowhere else. A new key is added beside the old one, which goes a release later.
|
|
7
|
+
*/
|
|
8
|
+
const SHIPPED = ["MCowBQYDK2VwAyEAqVQAptgVhkBtobNObBDHAZzKFdted0nH0I+LWAHGER8="];
|
|
9
|
+
let keys = null;
|
|
10
|
+
function trusted() {
|
|
11
|
+
keys ??= [...SHIPPED, ...env.plugins.proKeys].map((der) => createPublicKey({ key: Buffer.from(der, "base64"), format: "der", type: "spki" }));
|
|
12
|
+
return keys;
|
|
13
|
+
}
|
|
14
|
+
/** What the signature covers; the packer of the closed plugins writes the same text. */
|
|
15
|
+
const message = (p) => `engenty-module:v1\n${p.id}\n${p.version}\n${p.runtime}\n${p.sha256}`;
|
|
16
|
+
/** Whether one of the trusted keys signed this package for this release. */
|
|
17
|
+
export function signedByUs(p) {
|
|
18
|
+
let signature;
|
|
19
|
+
try {
|
|
20
|
+
signature = Buffer.from(p.signature, "base64");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const data = Buffer.from(message(p));
|
|
26
|
+
return trusted().some((key) => {
|
|
27
|
+
try {
|
|
28
|
+
return verify(null, data, key, signature);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { packageVersion } from "../cli/home.js";
|
|
4
|
+
import { env } from "../env.js";
|
|
5
|
+
import { managed } from "../manage.js";
|
|
6
|
+
/**
|
|
7
|
+
* Which Pro modules may load (docs/content/dev/plugins/pro-modules). A Pro module is a closed
|
|
8
|
+
* plugin the linked account's plan includes, installed by pro.ts into a folder of its own. It
|
|
9
|
+
* loads while the entitlement the account's Manage-App signed lists it and has not ended, and
|
|
10
|
+
* only in the release it was built for. Kept apart from pro.ts, so the loader needs no network.
|
|
11
|
+
*/
|
|
12
|
+
/** Where Pro modules are installed: apart from the person's own plugins, not watched. */
|
|
13
|
+
export const proModulesDir = () => join(env.dataDir, "pro-modules");
|
|
14
|
+
/** Written beside an installed module: what was installed, for which release. */
|
|
15
|
+
export const INSTALLED_FILE = ".engenty-module.json";
|
|
16
|
+
export const ENTITLEMENT_SETTING = "pro.entitlement";
|
|
17
|
+
let entitlement = null;
|
|
18
|
+
export function currentEntitlement() {
|
|
19
|
+
return entitlement;
|
|
20
|
+
}
|
|
21
|
+
export function setEntitlement(value) {
|
|
22
|
+
entitlement = value;
|
|
23
|
+
}
|
|
24
|
+
/** `0.2.26` → `v0.2.26`: the release this runtime is. */
|
|
25
|
+
export const runtimeTag = () => `v${packageVersion()}`;
|
|
26
|
+
export function installedModule(root) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(join(root, INSTALLED_FILE), "utf8"));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function heldBack(source) {
|
|
35
|
+
if (managed) {
|
|
36
|
+
return "managed";
|
|
37
|
+
}
|
|
38
|
+
const installed = installedModule(source.root);
|
|
39
|
+
if (!installed || installed.id !== source.id) {
|
|
40
|
+
return "not_installed";
|
|
41
|
+
}
|
|
42
|
+
if (installed.runtime !== runtimeTag()) {
|
|
43
|
+
return "release";
|
|
44
|
+
}
|
|
45
|
+
if (!(entitlement && entitlement.expiresAt > Date.now())) {
|
|
46
|
+
return "unconfirmed";
|
|
47
|
+
}
|
|
48
|
+
if (!entitlement.modules.includes(source.id)) {
|
|
49
|
+
return "plan";
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
let held = new Map();
|
|
54
|
+
/** The Pro modules that were found and did not load, with the reason, as of the last look. */
|
|
55
|
+
export function heldBackModules() {
|
|
56
|
+
return held;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The Pro modules of a look at the folders that may load, marked as such. A Pro module never
|
|
60
|
+
* takes the id of a plugin found elsewhere.
|
|
61
|
+
*/
|
|
62
|
+
export function proSources(found, taken) {
|
|
63
|
+
const load = [];
|
|
64
|
+
const problems = [];
|
|
65
|
+
held = new Map();
|
|
66
|
+
for (const source of found) {
|
|
67
|
+
if (taken.has(source.id)) {
|
|
68
|
+
problems.push({
|
|
69
|
+
path: source.root,
|
|
70
|
+
message: `Plugin "${source.id}" is already loaded from another folder.`,
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const reason = heldBack(source);
|
|
75
|
+
if (reason) {
|
|
76
|
+
held.set(source.id, reason);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
load.push({ ...source, pro: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { load, problems };
|
|
83
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { unzipSync } from "fflate";
|
|
6
|
+
import { accountToken, linkedAccount } from "../auth/account.js";
|
|
7
|
+
import { env } from "../env.js";
|
|
8
|
+
import { managed, verifySigned } from "../manage.js";
|
|
9
|
+
import { deleteSetting, readSetting, writeSetting } from "../settings.js";
|
|
10
|
+
import { MANIFEST_FILE } from "./discovery.js";
|
|
11
|
+
import { findNewPlugins, reloadPlugin } from "./loader.js";
|
|
12
|
+
import { signedByUs } from "./pro-keys.js";
|
|
13
|
+
import { currentEntitlement, ENTITLEMENT_SETTING, heldBackModules, INSTALLED_FILE, installedModule, proModulesDir, runtimeTag, setEntitlement, } from "./pro-state.js";
|
|
14
|
+
import { loadedPlugin } from "./registry.js";
|
|
15
|
+
/*
|
|
16
|
+
* Pro modules on a runtime that runs alone (docs/content/dev/plugins/pro-modules): the closed
|
|
17
|
+
* plugins the linked account's plan includes, fetched from its Manage-App.
|
|
18
|
+
*
|
|
19
|
+
* GET /v1/modules?runtime=<release> what is built for this release, the plan's
|
|
20
|
+
* part of it, and the signed entitlement
|
|
21
|
+
* GET /v1/modules/:id/package?runtime=<release> one module's zip
|
|
22
|
+
*
|
|
23
|
+
* A package is taken only when its SHA-256 and the signature over id, version, release and that
|
|
24
|
+
* hash check out against a key this runtime carries (pro-keys.ts): neither the Manage-App nor
|
|
25
|
+
* the way there can hand over code of their own.
|
|
26
|
+
*/
|
|
27
|
+
/** The audience of an entitlement, as the Manage-App signs it. */
|
|
28
|
+
const AUDIENCE = "urn:engenty:wizards:modules";
|
|
29
|
+
/** Larger packages are refused before they are read. */
|
|
30
|
+
const MAX_PACKAGE = 30 * 1024 * 1024;
|
|
31
|
+
/** What a package may hold, besides its manifest. */
|
|
32
|
+
const PARTS = ["dist/", "migrations/"];
|
|
33
|
+
const base = () => `${env.local.accountUrl}/v1/modules`;
|
|
34
|
+
/** Something the person can mend or retry; the message is for them. */
|
|
35
|
+
export class ProModuleError extends Error {
|
|
36
|
+
status;
|
|
37
|
+
code;
|
|
38
|
+
constructor(message, status = 400, code = "pro_module") {
|
|
39
|
+
super(message);
|
|
40
|
+
this.status = status;
|
|
41
|
+
this.code = code;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
let listing = null;
|
|
45
|
+
let lastError = null;
|
|
46
|
+
// --- the entitlement -------------------------------------------------------------------
|
|
47
|
+
/**
|
|
48
|
+
* At start, before the plugins load: what the last check said, while it lasts and belongs to the
|
|
49
|
+
* account that is linked now.
|
|
50
|
+
*/
|
|
51
|
+
export async function loadEntitlement() {
|
|
52
|
+
if (managed) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const [stored, account] = await Promise.all([
|
|
56
|
+
readSetting(ENTITLEMENT_SETTING),
|
|
57
|
+
linkedAccount(),
|
|
58
|
+
]);
|
|
59
|
+
setEntitlement(stored && account && stored.tenantId === account.tenantId ? stored : null);
|
|
60
|
+
}
|
|
61
|
+
async function keep(value) {
|
|
62
|
+
setEntitlement(value);
|
|
63
|
+
if (value) {
|
|
64
|
+
await writeSetting(ENTITLEMENT_SETTING, value);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
await deleteSetting(ENTITLEMENT_SETTING);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Loads and unloads Pro modules after the entitlement changed. */
|
|
71
|
+
async function apply(before) {
|
|
72
|
+
const after = currentEntitlement();
|
|
73
|
+
const same = before?.modules.join() === after?.modules.join() &&
|
|
74
|
+
(before?.expiresAt ?? 0) > Date.now() === (after?.expiresAt ?? 0) > Date.now();
|
|
75
|
+
if (!same) {
|
|
76
|
+
await findNewPlugins();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function call(path, token) {
|
|
80
|
+
const url = new URL(`${base()}${path}`);
|
|
81
|
+
url.searchParams.set("runtime", runtimeTag());
|
|
82
|
+
const res = await fetch(url, {
|
|
83
|
+
headers: { authorization: `Bearer ${token}` },
|
|
84
|
+
signal: AbortSignal.timeout(60_000),
|
|
85
|
+
}).catch(() => null);
|
|
86
|
+
if (!res) {
|
|
87
|
+
throw new ProModuleError("Dein engenty-Konto ist gerade nicht erreichbar.", 502, "offline");
|
|
88
|
+
}
|
|
89
|
+
return res;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Asks the account what its plan includes now, keeps the signed answer and fetches what an
|
|
93
|
+
* installed module needs (a new release, a new build). Unreachable: what was confirmed lasts
|
|
94
|
+
* until it ends.
|
|
95
|
+
*/
|
|
96
|
+
export async function refreshPro() {
|
|
97
|
+
if (managed) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const before = currentEntitlement();
|
|
101
|
+
const account = await linkedAccount();
|
|
102
|
+
if (!account) {
|
|
103
|
+
listing = null;
|
|
104
|
+
lastError = null;
|
|
105
|
+
await keep(null);
|
|
106
|
+
await apply(before);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const token = await accountToken();
|
|
111
|
+
if (!token) {
|
|
112
|
+
throw new ProModuleError("Dein engenty-Konto ist gerade nicht erreichbar.", 502, "offline");
|
|
113
|
+
}
|
|
114
|
+
const res = await call("", token);
|
|
115
|
+
if (res.status === 401) {
|
|
116
|
+
throw new ProModuleError("Das engenty-Konto nimmt diese Anmeldung nicht mehr an.", 403);
|
|
117
|
+
}
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
throw new ProModuleError(`Das Konto antwortet nicht wie erwartet (${res.status}).`, 502);
|
|
120
|
+
}
|
|
121
|
+
const answer = (await res.json());
|
|
122
|
+
const claims = await verifySigned(answer.entitlement?.token ?? "", [AUDIENCE], env.local.accountUrl);
|
|
123
|
+
if (!claims || claims.sub !== account.tenantId || !Array.isArray(claims.modules)) {
|
|
124
|
+
throw new ProModuleError("Die Bestätigung des Pakets ließ sich nicht prüfen.", 502);
|
|
125
|
+
}
|
|
126
|
+
listing = answer;
|
|
127
|
+
lastError = null;
|
|
128
|
+
await keep({
|
|
129
|
+
tenantId: account.tenantId,
|
|
130
|
+
modules: claims.modules.map(String),
|
|
131
|
+
plan: answer.plan ?? null,
|
|
132
|
+
expiresAt: (claims.exp ?? 0) * 1000,
|
|
133
|
+
checkedAt: Date.now(),
|
|
134
|
+
});
|
|
135
|
+
await apply(before);
|
|
136
|
+
await updateInstalled();
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
lastError = err.message;
|
|
140
|
+
console.error("[pro modules]", lastError);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
let timer = null;
|
|
144
|
+
/** Checks at start, then once a day while the runtime runs; a check that failed, within the hour. */
|
|
145
|
+
export function startProChecks() {
|
|
146
|
+
if (managed || timer) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
void refreshPro();
|
|
150
|
+
timer = setInterval(() => {
|
|
151
|
+
const checkedAt = currentEntitlement()?.checkedAt ?? 0;
|
|
152
|
+
if (lastError || Date.now() - checkedAt > 23 * 3600_000) {
|
|
153
|
+
void refreshPro();
|
|
154
|
+
}
|
|
155
|
+
}, 3600_000);
|
|
156
|
+
timer.unref();
|
|
157
|
+
}
|
|
158
|
+
// --- installing ------------------------------------------------------------------------
|
|
159
|
+
async function installedIds() {
|
|
160
|
+
const entries = await readdir(proModulesDir(), { withFileTypes: true }).catch(() => []);
|
|
161
|
+
return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
|
|
162
|
+
}
|
|
163
|
+
/** Installed modules built for another release or from another build are fetched anew. */
|
|
164
|
+
async function updateInstalled() {
|
|
165
|
+
for (const id of await installedIds()) {
|
|
166
|
+
const entry = listing?.modules.find((m) => m.id === id);
|
|
167
|
+
const installed = installedModule(join(proModulesDir(), id));
|
|
168
|
+
if (entry?.included && installed?.sha256 !== entry.sha256) {
|
|
169
|
+
await installPro(id).catch((err) => console.error(`[pro modules] ${id}: update failed:`, err.message));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** The files of a zip, refusing any that would land outside the module's folder. */
|
|
174
|
+
function filesOf(zip) {
|
|
175
|
+
let files;
|
|
176
|
+
try {
|
|
177
|
+
files = unzipSync(zip);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
throw new ProModuleError("Das Paket ist beschädigt.", 502);
|
|
181
|
+
}
|
|
182
|
+
const out = {};
|
|
183
|
+
for (const [name, data] of Object.entries(files)) {
|
|
184
|
+
if (name.endsWith("/")) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const parts = name.split("/");
|
|
188
|
+
const safe = parts.every((p) => p && p !== "." && p !== ".." && !p.includes("\\"));
|
|
189
|
+
const allowed = name === MANIFEST_FILE || PARTS.some((part) => name.startsWith(part));
|
|
190
|
+
if (!(safe && allowed)) {
|
|
191
|
+
throw new ProModuleError(`Das Paket enthält eine unerlaubte Datei: ${name}`, 502);
|
|
192
|
+
}
|
|
193
|
+
out[name] = data;
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
/** Fetches one module of the plan, checks it and puts it in place; then it loads. */
|
|
198
|
+
export async function installPro(id) {
|
|
199
|
+
if (managed) {
|
|
200
|
+
throw new ProModuleError("Hier nicht verfügbar.", 404, "not_found");
|
|
201
|
+
}
|
|
202
|
+
const token = await accountToken();
|
|
203
|
+
if (!token) {
|
|
204
|
+
throw new ProModuleError("Verbinde zuerst dein engenty-Konto (Einstellungen → Konto).", 409);
|
|
205
|
+
}
|
|
206
|
+
if (!listing) {
|
|
207
|
+
await refreshPro();
|
|
208
|
+
}
|
|
209
|
+
const entry = listing?.modules.find((m) => m.id === id);
|
|
210
|
+
if (!entry) {
|
|
211
|
+
throw new ProModuleError(`Für ${runtimeTag()} gibt es dieses Modul nicht.`, 404, "not_found");
|
|
212
|
+
}
|
|
213
|
+
if (!entry.included) {
|
|
214
|
+
throw new ProModuleError("Dieses Modul ist nicht Teil deines Pakets.", 403, "plan");
|
|
215
|
+
}
|
|
216
|
+
const res = await call(`/${encodeURIComponent(id)}/package`, token);
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
throw new ProModuleError(`Das Modul ließ sich nicht laden (${res.status}).`, 502);
|
|
219
|
+
}
|
|
220
|
+
if (Number(res.headers.get("content-length") ?? 0) > MAX_PACKAGE) {
|
|
221
|
+
throw new ProModuleError("Das Paket ist zu groß.", 502);
|
|
222
|
+
}
|
|
223
|
+
const zip = new Uint8Array(await res.arrayBuffer());
|
|
224
|
+
const sha256 = createHash("sha256").update(zip).digest("hex");
|
|
225
|
+
const genuine = sha256 === entry.sha256 &&
|
|
226
|
+
signedByUs({
|
|
227
|
+
id,
|
|
228
|
+
version: entry.version,
|
|
229
|
+
runtime: runtimeTag(),
|
|
230
|
+
sha256,
|
|
231
|
+
signature: entry.signature,
|
|
232
|
+
});
|
|
233
|
+
if (zip.length > MAX_PACKAGE || !genuine) {
|
|
234
|
+
throw new ProModuleError("Das Paket ist nicht von engenty signiert und wurde verworfen.", 502);
|
|
235
|
+
}
|
|
236
|
+
const files = filesOf(zip);
|
|
237
|
+
const manifest = JSON.parse(new TextDecoder().decode(files[MANIFEST_FILE] ?? new Uint8Array()));
|
|
238
|
+
if (manifest?.id !== id) {
|
|
239
|
+
throw new ProModuleError("Das Paket passt nicht zu diesem Modul.", 502);
|
|
240
|
+
}
|
|
241
|
+
const dir = proModulesDir();
|
|
242
|
+
const target = join(dir, id);
|
|
243
|
+
const fresh = join(dir, `.new-${id}-${randomBytes(4).toString("hex")}`);
|
|
244
|
+
const old = join(dir, `.old-${id}-${randomBytes(4).toString("hex")}`);
|
|
245
|
+
await mkdir(fresh, { recursive: true });
|
|
246
|
+
try {
|
|
247
|
+
for (const [path, data] of Object.entries(files)) {
|
|
248
|
+
await mkdir(dirname(join(fresh, path)), { recursive: true });
|
|
249
|
+
await writeFile(join(fresh, path), data);
|
|
250
|
+
}
|
|
251
|
+
const installed = {
|
|
252
|
+
id,
|
|
253
|
+
version: entry.version,
|
|
254
|
+
runtime: runtimeTag(),
|
|
255
|
+
sha256,
|
|
256
|
+
installedAt: new Date().toISOString(),
|
|
257
|
+
};
|
|
258
|
+
await writeFile(join(fresh, INSTALLED_FILE), `${JSON.stringify(installed, null, 2)}\n`);
|
|
259
|
+
if (existsSync(target)) {
|
|
260
|
+
await rename(target, old);
|
|
261
|
+
}
|
|
262
|
+
await rename(fresh, target);
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
await rm(fresh, { recursive: true, force: true });
|
|
266
|
+
await rm(old, { recursive: true, force: true });
|
|
267
|
+
}
|
|
268
|
+
await reloadPlugin(id);
|
|
269
|
+
}
|
|
270
|
+
/** Removes an installed module; its tables and what is in them stay. */
|
|
271
|
+
export async function removePro(id) {
|
|
272
|
+
if (managed || !(await installedIds()).includes(id)) {
|
|
273
|
+
throw new ProModuleError("Dieses Modul ist nicht installiert.", 404, "not_found");
|
|
274
|
+
}
|
|
275
|
+
await rm(join(proModulesDir(), id), { recursive: true, force: true });
|
|
276
|
+
await findNewPlugins();
|
|
277
|
+
}
|
|
278
|
+
export async function proState() {
|
|
279
|
+
const entitlement = currentEntitlement();
|
|
280
|
+
const account = await linkedAccount();
|
|
281
|
+
const ids = new Set([...(listing?.modules.map((m) => m.id) ?? []), ...(await installedIds())]);
|
|
282
|
+
const modules = [...ids].sort().map((id) => {
|
|
283
|
+
const entry = listing?.modules.find((m) => m.id === id);
|
|
284
|
+
const installed = installedModule(join(proModulesDir(), id));
|
|
285
|
+
const plugin = loadedPlugin(id);
|
|
286
|
+
return {
|
|
287
|
+
id,
|
|
288
|
+
name: entry?.name ?? plugin?.source.manifest.name ?? id,
|
|
289
|
+
version: entry?.version ?? installed?.version ?? "",
|
|
290
|
+
description: entry?.description ?? plugin?.source.manifest.description ?? "",
|
|
291
|
+
size: entry?.size ?? null,
|
|
292
|
+
included: entitlement?.modules.includes(id) ?? false,
|
|
293
|
+
installed: installed ? { version: installed.version, runtime: installed.runtime } : null,
|
|
294
|
+
loaded: Boolean(plugin?.source.pro),
|
|
295
|
+
heldBack: heldBackModules().get(id) ?? null,
|
|
296
|
+
update: Boolean(entry?.included && installed && installed.sha256 !== entry.sha256),
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
return {
|
|
300
|
+
linked: Boolean(account),
|
|
301
|
+
/** Where the plans are: `<accountUrl>/billing`. */
|
|
302
|
+
accountUrl: env.local.accountUrl,
|
|
303
|
+
runtime: runtimeTag(),
|
|
304
|
+
plan: entitlement?.plan ?? listing?.plan ?? null,
|
|
305
|
+
expiresAt: entitlement ? new Date(entitlement.expiresAt).toISOString() : null,
|
|
306
|
+
checkedAt: entitlement ? new Date(entitlement.checkedAt).toISOString() : null,
|
|
307
|
+
error: lastError,
|
|
308
|
+
modules,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** For tests: forget what the account said. */
|
|
312
|
+
export function resetProForTests() {
|
|
313
|
+
listing = null;
|
|
314
|
+
lastError = null;
|
|
315
|
+
setEntitlement(null);
|
|
316
|
+
}
|