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.
Files changed (64) hide show
  1. package/apps/runtime/dist/auth/account.js +8 -0
  2. package/apps/runtime/dist/auth/index.js +3 -0
  3. package/apps/runtime/dist/authoring/guide.js +8 -2
  4. package/apps/runtime/dist/credits/estimate.js +4 -0
  5. package/apps/runtime/dist/engine/requirements.js +4 -0
  6. package/apps/runtime/dist/engine/steps.js +42 -1
  7. package/apps/runtime/dist/env.js +5 -0
  8. package/apps/runtime/dist/index.js +4 -0
  9. package/apps/runtime/dist/manage.js +6 -1
  10. package/apps/runtime/dist/plugins/loader.js +9 -2
  11. package/apps/runtime/dist/plugins/pro-keys.js +34 -0
  12. package/apps/runtime/dist/plugins/pro-state.js +83 -0
  13. package/apps/runtime/dist/plugins/pro.js +316 -0
  14. package/apps/runtime/dist/routes/plugins.js +49 -0
  15. package/apps/runtime/dist/routes/studio.js +3 -0
  16. package/apps/runtime/dist/tools/plugin.js +49 -25
  17. package/apps/web/dist/assets/{Account-CVMSZTCv.js → Account-9HRO4JWr.js} +1 -1
  18. package/apps/web/dist/assets/{ChangelogDialog-3N1CgtWP.js → ChangelogDialog-DXH1HcDL.js} +1 -1
  19. package/apps/web/dist/assets/{CreditsDialog-BW-RETl-.js → CreditsDialog-BtID6slV.js} +1 -1
  20. package/apps/web/dist/assets/{EditorPage-DnuHM3nW.js → EditorPage-DhAf6LjO.js} +1 -1
  21. package/apps/web/dist/assets/{FlowPreview-Ds6ul7-N.js → FlowPreview-D3ZFAeax.js} +1 -1
  22. package/apps/web/dist/assets/{Harness-D-QoXcSe.js → Harness-mggD1ecl.js} +1 -1
  23. package/apps/web/dist/assets/{HomePage-Ch0HlwVX.js → HomePage-CBhmQH5G.js} +1 -1
  24. package/apps/web/dist/assets/{ImportWizard-DVcYcB6l.js → ImportWizard-BB8oJW0X.js} +1 -1
  25. package/apps/web/dist/assets/{LocalRuntime-D7bzT40S.js → LocalRuntime-Dyvqh8HP.js} +1 -1
  26. package/apps/web/dist/assets/{NewWizardPage-DvA6AX7D.js → NewWizardPage-CLFsjQgm.js} +2 -2
  27. package/apps/web/dist/assets/{ProjectPage-BWAISSfX.js → ProjectPage-BwUhAgVx.js} +1 -1
  28. package/apps/web/dist/assets/{PublicRunner-D2bd95TR.js → PublicRunner-EfNAsmpU.js} +1 -1
  29. package/apps/web/dist/assets/{RunDrawer-DkOCgXAR.js → RunDrawer-Cwtrh-Iz.js} +1 -1
  30. package/apps/web/dist/assets/{RunnerView-DF5uW_95.js → RunnerView-EQpbU4EU.js} +1 -1
  31. package/apps/web/dist/assets/SettingsPage-CASSFJS5.js +1 -0
  32. package/apps/web/dist/assets/{SetupPage-BaT-VqIc.js → SetupPage-BTjh2p6y.js} +1 -1
  33. package/apps/web/dist/assets/{SharePage-COKOsuvD.js → SharePage-BSD9UL9f.js} +1 -1
  34. package/apps/web/dist/assets/arrow-right-CzoY677S.js +1 -0
  35. package/apps/web/dist/assets/changelog-j7qikrRS.js +1 -0
  36. package/apps/web/dist/assets/{circle-alert-BG9P3ewL.js → circle-alert-CN6dFS0d.js} +1 -1
  37. package/apps/web/dist/assets/definition-Cx9uG0KU.js +1 -0
  38. package/apps/web/dist/assets/{esm-0rdZWuyu.js → esm-CKwbhTDV.js} +1 -1
  39. package/apps/web/dist/assets/{external-link-BwCtn1nW.js → external-link-DvkH63pi.js} +1 -1
  40. package/apps/web/dist/assets/{image-DG4q2P1H.js → image-BUbtvL1h.js} +1 -1
  41. package/apps/web/dist/assets/{index-DHM9S48l.js → index-C4KaV94L.js} +4 -4
  42. package/apps/web/dist/assets/{lock-DH_BntP_.js → lock-COAprdab.js} +1 -1
  43. package/apps/web/dist/assets/{meta-DI0ahJ_z.js → meta-vzJM2HEw.js} +1 -1
  44. package/apps/web/dist/assets/{pen-line-B1dsy3Sr.js → pen-line-JsP-r_aH.js} +1 -1
  45. package/apps/web/dist/assets/{refresh-cw-B9M1g3SH.js → refresh-cw-DLXaS3Ri.js} +1 -1
  46. package/apps/web/dist/assets/{settings-menu-DOjgaq6J.js → settings-menu-Ds_wegZm.js} +1 -1
  47. package/apps/web/dist/assets/{star-axQV_3EC.js → star-CpnGQZXD.js} +1 -1
  48. package/apps/web/dist/assets/{surface-BE-3Z1DP.js → surface-BFwNWkx9.js} +2 -2
  49. package/apps/web/dist/assets/{trash-3S1_XpVa.js → trash-CQZuqdcu.js} +1 -1
  50. package/apps/web/dist/assets/{ui-BZ01zr3i.js → ui-BTkH_7CG.js} +1 -1
  51. package/apps/web/dist/assets/{upload-U0i8fAW3.js → upload-Dok4YeXQ.js} +1 -1
  52. package/apps/web/dist/assets/{video-Db4o719f.js → video-BjQoPIsn.js} +1 -1
  53. package/apps/web/dist/assets/{volume-2-6f7qN_LM.js → volume-2-D-Bn77h4.js} +1 -1
  54. package/apps/web/dist/assets/{wand-sparkles-MelvBtiK.js → wand-sparkles-D3sDsFFG.js} +1 -1
  55. package/apps/web/dist/index.html +3 -3
  56. package/apps/web/dist/mcp/flow.html +49 -49
  57. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +15 -3
  58. package/node_modules/@engenty-wizards/shared/dist/definition.js +38 -9
  59. package/node_modules/@engenty-wizards/shared/dist/marketplace.js +8 -1
  60. package/package.json +1 -1
  61. package/apps/web/dist/assets/SettingsPage-CMTGFXit.js +0 -1
  62. package/apps/web/dist/assets/arrow-right-hy38IBRU.js +0 -1
  63. package/apps/web/dist/assets/changelog-C8wFW63z.js +0 -1
  64. 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 tools ["appointments.availability"] outputs a table "slots" with a "start" column;
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);
@@ -17,6 +17,10 @@ export function stepClasses(step) {
17
17
  function ownClasses(step) {
18
18
  switch (step.type) {
19
19
  case "agent": {
20
+ // A call runs one tool and no model.
21
+ if (step.call) {
22
+ return [];
23
+ }
20
24
  if (isDecisionStep(step)) {
21
25
  return ["classifier"];
22
26
  }
@@ -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
  }
@@ -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 toClaims(payload);
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
- problems = result.problems;
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
+ }