wizards 0.5.0 → 0.5.2

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 (160) hide show
  1. package/README.md +45 -42
  2. package/apps/runtime/dist/agents/studio-assistant.js +3 -3
  3. package/apps/runtime/dist/app.js +7 -4
  4. package/apps/runtime/dist/auth/index.js +2 -2
  5. package/apps/runtime/dist/authoring/guide.js +1 -1
  6. package/apps/runtime/dist/cli/account.js +31 -28
  7. package/apps/runtime/dist/cli/autostart.js +5 -3
  8. package/apps/runtime/dist/cli/clients.js +1 -1
  9. package/apps/runtime/dist/cli/connect.js +24 -24
  10. package/apps/runtime/dist/cli/features.js +6 -6
  11. package/apps/runtime/dist/cli/main.js +51 -39
  12. package/apps/runtime/dist/cli/mcp.js +5 -4
  13. package/apps/runtime/dist/cli/menu.js +32 -30
  14. package/apps/runtime/dist/cli/report.js +7 -9
  15. package/apps/runtime/dist/cli/setup.js +42 -38
  16. package/apps/runtime/dist/cli/start.js +16 -15
  17. package/apps/runtime/dist/cli/status.js +20 -20
  18. package/apps/runtime/dist/cli/ui.js +9 -2
  19. package/apps/runtime/dist/cli/update.js +6 -6
  20. package/apps/runtime/dist/cli/validate.js +1 -1
  21. package/apps/runtime/dist/connectors/external.js +3 -2
  22. package/apps/runtime/dist/connectors/index.js +5 -5
  23. package/apps/runtime/dist/connectors/plugin-connections.js +2 -2
  24. package/apps/runtime/dist/connectors/via-account.js +5 -5
  25. package/apps/runtime/dist/db/control-schema.js +14 -0
  26. package/apps/runtime/dist/db/migrations/control/0009_plugin_claim.sql +9 -0
  27. package/apps/runtime/dist/db/migrations/control/meta/0009_snapshot.json +561 -0
  28. package/apps/runtime/dist/db/migrations/control/meta/_journal.json +7 -0
  29. package/apps/runtime/dist/docs/dev/index.md +16 -14
  30. package/apps/runtime/dist/docs/dev/plugins/connections.md +27 -24
  31. package/apps/runtime/dist/docs/dev/plugins/events.md +19 -17
  32. package/apps/runtime/dist/docs/dev/plugins/examples.md +13 -12
  33. package/apps/runtime/dist/docs/dev/plugins/index.md +29 -24
  34. package/apps/runtime/dist/docs/dev/plugins/layout.md +20 -19
  35. package/apps/runtime/dist/docs/dev/plugins/quick-start.md +16 -15
  36. package/apps/runtime/dist/docs/dev/plugins/reference.md +52 -40
  37. package/apps/runtime/dist/docs/dev/plugins/reloading.md +24 -23
  38. package/apps/runtime/dist/docs/dev/plugins/routes.md +26 -22
  39. package/apps/runtime/dist/docs/dev/plugins/runners.md +49 -44
  40. package/apps/runtime/dist/docs/dev/plugins/server.md +95 -43
  41. package/apps/runtime/dist/docs/dev/plugins/shipping.md +19 -16
  42. package/apps/runtime/dist/docs/dev/plugins/space.md +75 -69
  43. package/apps/runtime/dist/docs/dev/plugins/starters.md +22 -17
  44. package/apps/runtime/dist/docs/dev/plugins/studio.md +80 -37
  45. package/apps/runtime/dist/docs/dev/plugins/styling.md +44 -41
  46. package/apps/runtime/dist/docs/dev/plugins/tables.md +11 -11
  47. package/apps/runtime/dist/docs/dev/plugins/tools.md +26 -25
  48. package/apps/runtime/dist/docs/dev/wizard-format.md +35 -23
  49. package/apps/runtime/dist/docs/user/ai-apps.md +46 -43
  50. package/apps/runtime/dist/docs/user/assistant.md +41 -36
  51. package/apps/runtime/dist/docs/user/build-a-wizard.md +64 -39
  52. package/apps/runtime/dist/docs/user/command-line.md +47 -44
  53. package/apps/runtime/dist/docs/user/connectors.md +40 -35
  54. package/apps/runtime/dist/docs/user/create-account.md +27 -22
  55. package/apps/runtime/dist/docs/user/data.md +29 -29
  56. package/apps/runtime/dist/docs/user/first-start.md +26 -28
  57. package/apps/runtime/dist/docs/user/index.md +16 -17
  58. package/apps/runtime/dist/docs/user/install.md +28 -27
  59. package/apps/runtime/dist/docs/user/local-only.md +21 -17
  60. package/apps/runtime/dist/docs/user/models.md +92 -84
  61. package/apps/runtime/dist/docs/user/plugins.md +28 -23
  62. package/apps/runtime/dist/docs/user/run.md +51 -50
  63. package/apps/runtime/dist/docs/user/server.md +9 -9
  64. package/apps/runtime/dist/docs/user/space.md +87 -65
  65. package/apps/runtime/dist/docs/user/steps.md +73 -69
  66. package/apps/runtime/dist/docs/user/test-and-share.md +127 -105
  67. package/apps/runtime/dist/docs/user/troubleshooting.md +40 -38
  68. package/apps/runtime/dist/docs/user/wordpress.md +62 -54
  69. package/apps/runtime/dist/documents/parse.js +2 -2
  70. package/apps/runtime/dist/engenty/shims/web-ingest.js +17 -1
  71. package/apps/runtime/dist/engine/input.js +4 -4
  72. package/apps/runtime/dist/engine/push.js +2 -2
  73. package/apps/runtime/dist/engine/runner.js +8 -8
  74. package/apps/runtime/dist/engine/steps.js +2 -2
  75. package/apps/runtime/dist/engine/surface.js +1 -1
  76. package/apps/runtime/dist/film/step.js +3 -3
  77. package/apps/runtime/dist/harness/claude.js +1 -1
  78. package/apps/runtime/dist/harness/codex.js +1 -1
  79. package/apps/runtime/dist/harness/model.js +2 -2
  80. package/apps/runtime/dist/harness/terminal.js +3 -3
  81. package/apps/runtime/dist/mcp/handler.js +2 -2
  82. package/apps/runtime/dist/mcp/tools.js +9 -9
  83. package/apps/runtime/dist/models.js +15 -15
  84. package/apps/runtime/dist/plugins/claims.js +56 -0
  85. package/apps/runtime/dist/plugins/loader.js +2 -0
  86. package/apps/runtime/dist/plugins/public.js +2 -3
  87. package/apps/runtime/dist/plugins/runs.js +13 -8
  88. package/apps/runtime/dist/render/chromium.js +24 -11
  89. package/apps/runtime/dist/routes/api.js +15 -9
  90. package/apps/runtime/dist/routes/debug.js +1 -1
  91. package/apps/runtime/dist/routes/delivery.js +46 -0
  92. package/apps/runtime/dist/routes/lang.js +7 -0
  93. package/apps/runtime/dist/routes/marketplace.js +2 -1
  94. package/apps/runtime/dist/routes/plugin-public.js +2 -1
  95. package/apps/runtime/dist/routes/plugins.js +13 -14
  96. package/apps/runtime/dist/routes/runs.js +59 -49
  97. package/apps/runtime/dist/routes/studio.js +53 -23
  98. package/apps/runtime/dist/services/access.js +3 -3
  99. package/apps/runtime/dist/services/assistant-chats.js +1 -1
  100. package/apps/runtime/dist/services/brand.js +2 -2
  101. package/apps/runtime/dist/services/cloud.js +5 -4
  102. package/apps/runtime/dist/services/errors.js +2 -1
  103. package/apps/runtime/dist/services/knowledge-model.js +2 -2
  104. package/apps/runtime/dist/services/marketplace.js +2 -2
  105. package/apps/runtime/dist/services/package-file.js +12 -9
  106. package/apps/runtime/dist/services/package.js +2 -2
  107. package/apps/runtime/dist/services/project-files.js +10 -12
  108. package/apps/runtime/dist/services/projects.js +13 -8
  109. package/apps/runtime/dist/services/runners.js +4 -3
  110. package/apps/runtime/dist/services/runs.js +3 -3
  111. package/apps/runtime/dist/services/site.js +2 -2
  112. package/apps/runtime/dist/services/space-categories.js +4 -4
  113. package/apps/runtime/dist/services/space-data.js +13 -13
  114. package/apps/runtime/dist/services/space-origin.js +1 -1
  115. package/apps/runtime/dist/services/spaces.js +7 -7
  116. package/apps/runtime/dist/services/wizards.js +5 -5
  117. package/apps/runtime/dist/tools/net-guard.js +135 -24
  118. package/apps/runtime/dist/tools/plugin.js +1 -1
  119. package/apps/runtime/dist/tools/store.js +1 -1
  120. package/apps/runtime/dist/widgets/step.js +1 -1
  121. package/apps/web/dist/assets/AboutDialog-hRgrS60N.js +1 -0
  122. package/apps/web/dist/assets/{ChangelogDialog-BXpkgatF.js → ChangelogDialog-BUSpLhC6.js} +1 -1
  123. package/apps/web/dist/assets/{ChatView-t7z44uFN.js → ChatView-DO7P5Vv_.js} +1 -1
  124. package/apps/web/dist/assets/EditorPage-BabV35Bj.js +3 -0
  125. package/apps/web/dist/assets/{FlowPreview-WpS8DCPl.js → FlowPreview-CKrwLPN0.js} +1 -1
  126. package/apps/web/dist/assets/{Harness-DL7Bse40.js → Harness-o8IIQXZp.js} +1 -1
  127. package/apps/web/dist/assets/{NewWizardChannels-Crt6PzH2.js → NewWizardChannels-DqU79Myj.js} +1 -1
  128. package/apps/web/dist/assets/{NewWizardPage-COiVmNsl.js → NewWizardPage-D1UCvmDp.js} +1 -1
  129. package/apps/web/dist/assets/{OnboardingPage-BwluEqXq.js → OnboardingPage-C70bqPnq.js} +1 -1
  130. package/apps/web/dist/assets/{ProjectPage-BIRU_AlN.js → ProjectPage-BOLvmQWZ.js} +1 -1
  131. package/apps/web/dist/assets/{PublicRunner-BxMb4gEt.js → PublicRunner-B0PGyHI9.js} +1 -1
  132. package/apps/web/dist/assets/{ReadOnly-D9r5ze5S.js → ReadOnly-B0mJBD1q.js} +1 -1
  133. package/apps/web/dist/assets/{RunDrawer-DmJl2QOJ.js → RunDrawer-BsnsIJ0Y.js} +1 -1
  134. package/apps/web/dist/assets/{RunnerView-CF_1rC-7.js → RunnerView-B56i3nKO.js} +1 -1
  135. package/apps/web/dist/assets/SettingsPage-BvfRUqb2.js +1 -0
  136. package/apps/web/dist/assets/{SetupPage-BGKtbS7j.js → SetupPage-BvAFPu1J.js} +1 -1
  137. package/apps/web/dist/assets/{SharePage-C4MqXKDA.js → SharePage-hV5pYrO2.js} +1 -1
  138. package/apps/web/dist/assets/changelog-BftXQzUA.js +1 -0
  139. package/apps/web/dist/assets/{esm-CsY8TkyE.js → esm-DfapVSy6.js} +1 -1
  140. package/apps/web/dist/assets/{index-BjQvGQp_.js → index-BuFrRzvG.js} +4 -4
  141. package/apps/web/dist/assets/{index-Bi2x2g7n.css → index-DSbQiZY8.css} +1 -1
  142. package/apps/web/dist/assets/{lock-CI6TpWL-.js → lock-B-tJwE4p.js} +1 -1
  143. package/apps/web/dist/assets/oss-credits-BJrR7BR1.js +1 -0
  144. package/apps/web/dist/assets/{phone-BrtQz-fG.js → phone-DRHxY0l_.js} +1 -1
  145. package/apps/web/dist/assets/{settings-menu-BtEKL72n.js → settings-menu-OPrJTW8a.js} +1 -1
  146. package/apps/web/dist/assets/{ui-4IaTD7LB.js → ui-DftgEA2k.js} +3 -3
  147. package/apps/web/dist/assets/{upload-CWJAtPRo.js → upload-B73coFNw.js} +1 -1
  148. package/apps/web/dist/index.html +3 -3
  149. package/apps/web/dist/mcp/flow.html +4 -4
  150. package/bin/wizards.mjs +1 -1
  151. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +21 -2
  152. package/node_modules/@engenty-wizards/shared/dist/projects.js +1 -1
  153. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +48 -48
  154. package/node_modules/@engenty-wizards/shared/dist/run.js +48 -48
  155. package/package.json +5 -4
  156. package/apps/web/dist/assets/AboutDialog-A_ILyeue.js +0 -1
  157. package/apps/web/dist/assets/EditorPage-4qxNJgKr.js +0 -3
  158. package/apps/web/dist/assets/SettingsPage-3xSInE-A.js +0 -1
  159. package/apps/web/dist/assets/changelog-CM_GdIVI.js +0 -1
  160. package/apps/web/dist/assets/oss-credits-BQQX0kvk.js +0 -1
@@ -247,12 +247,12 @@ const MAKES = {
247
247
  function missingText(cfg, cls) {
248
248
  const bound = cfg.settings.bindings[cls];
249
249
  if (isCreditsRef(bound)) {
250
- return bilingual("Dafür ist das Guthaben gewählt, aber kein Konto angemeldet.", "The credits are picked for this, but no account is signed in.");
250
+ return bilingual("Dafür sind Credits gewählt, aber du bist mit keinem Konto angemeldet.", "This is set to run on credits, but you’re not signed in to an account.");
251
251
  }
252
252
  const makes = MAKES[cls];
253
253
  const provider = bound ? providerInfo(providerOfRef(bound) ?? "") : null;
254
254
  if (provider) {
255
- return bilingual(`Für ${makes.de} ist ${provider.name} gewählt, aber kein API Key hinterlegt.`, `${provider.name} is picked for ${makes.en}, but no API key is set.`);
255
+ return bilingual(`Für ${makes.de} ist ${provider.name} gewählt, aber kein API-Key hinterlegt.`, `${provider.name} is chosen for ${makes.en}, but no API key is set.`);
256
256
  }
257
257
  return bilingual(`Für ${makes.de} ist kein Modell eingerichtet.`, `No model is set up for ${makes.en}.`);
258
258
  }
@@ -274,7 +274,7 @@ async function creditAccess(cfg, chosen) {
274
274
  const token = await accountToken();
275
275
  if (!token) {
276
276
  if (chosen) {
277
- throw new ModelUnavailableError(bilingual("Das Konto ist nicht mehr angemeldet. Bitte in den Einstellungen neu anmelden.", "The account is no longer signed in. Please sign in again in the settings."));
277
+ throw new ModelUnavailableError(bilingual("Das Konto ist nicht mehr angemeldet. Melde dich unter Einstellungen → Konto neu an.", "The account is no longer signed in. Sign in again under Settings → Account."));
278
278
  }
279
279
  return null;
280
280
  }
@@ -301,7 +301,7 @@ async function routeOf(cls) {
301
301
  function need(cfg, name) {
302
302
  const key = cfg.keys[name];
303
303
  if (!key) {
304
- throw new ModelUnavailableError(bilingual(`Für ${providerInfo(name)?.name ?? name} ist kein API Key hinterlegt.`, `No API key is set for ${providerInfo(name)?.name ?? name}.`));
304
+ throw new ModelUnavailableError(bilingual(`Für ${providerInfo(name)?.name ?? name} ist kein API-Key hinterlegt.`, `No API key is set for ${providerInfo(name)?.name ?? name}.`));
305
305
  }
306
306
  return key;
307
307
  }
@@ -412,7 +412,7 @@ async function ownLanguageModel(cfg, ref, meta) {
412
412
  };
413
413
  }
414
414
  }
415
- throw new ModelUnavailableError(bilingual(`Für das Modell "${ref}" ist kein Zugang eingerichtet. Bitte in den Einstellungen einen API Key hinterlegen oder ein Konto anmelden.`, `There is no access set up for the model "${ref}". Please add an API key or sign in to an account in the settings.`));
415
+ throw new ModelUnavailableError(bilingual(`Das Modell „${ref}“ lässt sich noch nicht nutzen. Hinterleg unter Einstellungen → Modelle einen API-Key oder melde dich mit einem Konto an.`, `The model “${ref}” can’t be used yet. Add an API key under Settings → Models, or sign in to an account.`));
416
416
  }
417
417
  /**
418
418
  * The language model a text class runs on. A run watched from a device that offers the class
@@ -711,7 +711,7 @@ export function freeTierRefusal(err) {
711
711
  }
712
712
  /** What the person reads when Vercel's free tier leaves a model out, and how to fix it. */
713
713
  export function freeTierText(model) {
714
- return bilingual(`Dein AI Gateway Key ist bei Vercel im kostenlosen Tarif, und der schließt ${model ? `„${model}“` : "dieses Modell"} aus. Abhilfe: bei Vercel Guthaben aufladen (${GATEWAY_TOP_UP}) oder unter Einstellungen → Modelle dafür einen anderen Weg wählen.`, `Your AI Gateway key is on Vercel's free tier, which leaves out ${model ? `“${model}”` : "this model"}. To fix it: top up at Vercel (${GATEWAY_TOP_UP}) or pick another way for it under Settings → Models.`);
714
+ return bilingual(`Dein AI-Gateway-Key nutzt bei Vercel die kostenlose Stufe, und die schließt ${model ? `„${model}“` : "dieses Modell"} aus. Lade bei Vercel Guthaben auf (${GATEWAY_TOP_UP}) oder wähle unter Einstellungen → Modelle ein anderes Modell.`, `Your AI Gateway key is on Vercel’s free tier, which doesn’t include ${model ? `“${model}”` : "this model"}. Top up at Vercel (${GATEWAY_TOP_UP}) or choose another model under Settings → Models.`);
715
715
  }
716
716
  /** What a key was answered for a model: refusals are asked again soon, as a top-up lifts them. */
717
717
  const gatewayProbes = new Map();
@@ -765,7 +765,7 @@ const CONVERSATION_MODEL = env.conversationModel || "gpt-realtime";
765
765
  export async function conversationAccess() {
766
766
  const cfg = await config();
767
767
  if (!cfg.keys.openai) {
768
- throw new ModelUnavailableError(bilingual("Für Sprachgespräche ist kein OpenAI API Key hinterlegt (Einstellungen → Modelle).", "No OpenAI API key is set for voice conversations (Settings → Models)."));
768
+ throw new ModelUnavailableError(bilingual("Für Sprachgespräche ist kein OpenAI-API-Key hinterlegt (Einstellungen → Modelle).", "No OpenAI API key is set for voice conversations (Settings → Models)."));
769
769
  }
770
770
  return { apiKey: cfg.keys.openai, model: CONVERSATION_MODEL };
771
771
  }
@@ -802,11 +802,11 @@ export async function classProblem(cls) {
802
802
  const named = creditsModelOf((await config()).settings.bindings[cls]);
803
803
  if (named) {
804
804
  return catalog?.models && !catalog.models.some((m) => m.id === named)
805
- ? bilingual("Dieses Modell gibt es beim Konto nicht.", "The account has no such model.")
805
+ ? bilingual("Dieses Modell gibt es mit deinem Konto nicht.", "This model isn’t available with your account.")
806
806
  : null;
807
807
  }
808
808
  return catalog?.classes && !catalog.classes[cls]
809
- ? bilingual("Dafür ist beim Konto kein Modell eingerichtet.", "The account has no model set up for this.")
809
+ ? bilingual("Dafür ist bei deinem Konto kein Modell eingerichtet.", "Your account has no model set up for this.")
810
810
  : null;
811
811
  }
812
812
  const client = harness(resolved.vendor);
@@ -833,7 +833,7 @@ export async function filmClient(cls) {
833
833
  const client = managed ? null : harness(cfg.settings.source);
834
834
  if (!client || !FILM_CLIENTS.has(client.id)) {
835
835
  return {
836
- problem: bilingual("Filme macht Claude Code auf diesem Computer mit deinem Abo. Richte es unter „Modelle“ als KI-Abo ein.", "Films are made by Claude Code on this computer with your subscription. Set it up as an AI subscription under “Models”."),
836
+ problem: bilingual("Filme macht Claude Code auf diesem Computer mit deinem Abo. Richte es unter Einstellungen → Modelle als KI-Abo ein.", "Claude Code makes films on this computer, using your subscription. Set it up as an AI subscription under Settings → Models."),
837
837
  };
838
838
  }
839
839
  const status = await detectHarness(client.id);
@@ -920,7 +920,7 @@ async function appleProblem(cls) {
920
920
  }
921
921
  return (await hasFfmpeg())
922
922
  ? null
923
- : bilingual("Aufnahmen aus Chrome oder Firefox (WebM, Ogg) schreibt dieser Mac erst mit ffmpeg auf; Safari und die App brauchen es nicht. ffmpeg installieren, z. B. mit „brew install ffmpeg“.", "Recordings from Chrome or Firefox (WebM, Ogg) need ffmpeg before this Mac can write them down; Safari and the app do not. Install ffmpeg, e.g. with “brew install ffmpeg”.");
923
+ : bilingual("Aufnahmen aus Chrome oder Firefox (WebM, Ogg) schreibt dieser Mac erst mit ffmpeg auf. Safari und die App brauchen es nicht. Installiere ffmpeg, z. B. mit „brew install ffmpeg“.", "This Mac needs ffmpeg to transcribe recordings from Chrome or Firefox (WebM, Ogg). Safari and the app don’t. Install ffmpeg, e.g. with “brew install ffmpeg”.");
924
924
  }
925
925
  /** At start the ways are asked once, so a model the key cannot reach shows before a run meets it. */
926
926
  export async function checkModelsAtStart() {
@@ -992,11 +992,11 @@ export async function checkKey(name, key, lang = "en") {
992
992
  ok: false,
993
993
  message: res.status === 401
994
994
  ? de
995
- ? "Der Anbieter kennt diesen Key nicht."
996
- : "The provider does not know this key."
995
+ ? "Der Anbieter kennt diesen API-Key nicht."
996
+ : "The provider doesn’t recognise this key."
997
997
  : de
998
- ? `Der Anbieter antwortet mit ${res.status}.`
999
- : `The provider answers with ${res.status}.`,
998
+ ? `Der Anbieter antwortet mit Fehler ${res.status}.`
999
+ : `The provider answered with error ${res.status}.`,
1000
1000
  };
1001
1001
  }
1002
1002
  catch {
@@ -0,0 +1,56 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import { control, controlDb } from "../db/client.js";
3
+ import { currentTenant } from "../tenants/tenant.js";
4
+ import { publicRefOf } from "./public.js";
5
+ /**
6
+ * `server.claims`: keys a plugin holds across the runtime's tenants, each for one tenant at a
7
+ * time (control `plugin_claim`). A key is the plugin's own; another plugin's of the same name is
8
+ * another key.
9
+ */
10
+ const MAX_KEY = 200;
11
+ function checked(key) {
12
+ if (!key || key.length > MAX_KEY) {
13
+ throw Object.assign(new Error(`A key is 1 to ${MAX_KEY} characters.`), {
14
+ status: 400,
15
+ code: "invalid",
16
+ });
17
+ }
18
+ return key;
19
+ }
20
+ export function claimsApiFor(plugin) {
21
+ const where = (key) => and(eq(control.pluginClaim.plugin, plugin), eq(control.pluginClaim.key, checked(key)));
22
+ const holderOf = async (key) => (await controlDb.query.pluginClaim.findFirst({ where: where(key) }))?.tenantId ?? null;
23
+ return {
24
+ async take(key) {
25
+ const tenantId = currentTenant();
26
+ await controlDb
27
+ .insert(control.pluginClaim)
28
+ .values({ plugin, key: checked(key), tenantId, updatedAt: new Date() })
29
+ .onConflictDoNothing();
30
+ return (await holderOf(key)) === tenantId;
31
+ },
32
+ async put(key) {
33
+ const tenantId = currentTenant();
34
+ const updatedAt = new Date();
35
+ await controlDb
36
+ .insert(control.pluginClaim)
37
+ .values({ plugin, key: checked(key), tenantId, updatedAt })
38
+ .onConflictDoUpdate({
39
+ target: [control.pluginClaim.plugin, control.pluginClaim.key],
40
+ set: { tenantId, updatedAt },
41
+ });
42
+ },
43
+ async drop(key) {
44
+ await controlDb
45
+ .delete(control.pluginClaim)
46
+ .where(and(where(key), eq(control.pluginClaim.tenantId, currentTenant())));
47
+ },
48
+ async holder(key) {
49
+ const tenantId = await holderOf(key);
50
+ return tenantId ? publicRefOf(plugin, tenantId) : null;
51
+ },
52
+ async mine(key) {
53
+ return (await holderOf(key)) === currentTenant();
54
+ },
55
+ };
56
+ }
@@ -21,6 +21,7 @@ import { putIndexEntry, removeIndexEntries } from "../services/project-index.js"
21
21
  import { originData } from "../services/space-origin.js";
22
22
  import { safeFetch } from "../tools/net-guard.js";
23
23
  import { channelsApi } from "./channels.js";
24
+ import { claimsApiFor } from "./claims.js";
24
25
  import { discoverPlugins } from "./discovery.js";
25
26
  import { migrateOpenTenants, migratePlugins } from "./migrations.js";
26
27
  import { proModulesDir, proSources } from "./pro-state.js";
@@ -166,6 +167,7 @@ function apiFor(record) {
166
167
  },
167
168
  sharedUrl: (path) => sharedUrlOf(source.id, path),
168
169
  channels: channelsApi,
170
+ claims: claimsApiFor(source.id),
169
171
  runs: runsApiFor(source.id),
170
172
  speech: {
171
173
  async conversation() {
@@ -22,9 +22,8 @@ export async function publicUrlOf(plugin, path) {
22
22
  export function sharedUrlOf(plugin, path) {
23
23
  return `${env.publicUrl}/api/public/plugins/${plugin}/${SHARED}${rest(path)}`;
24
24
  }
25
- /** The ref of the current tenant's public address for a plugin, made the first time. */
26
- export async function publicRefOf(plugin) {
27
- const tenantId = currentTenant();
25
+ /** The ref of a tenant's public address for a plugin (the current tenant's by default), made the first time. */
26
+ export async function publicRefOf(plugin, tenantId = currentTenant()) {
28
27
  const where = and(eq(control.pluginAddress.tenantId, tenantId), eq(control.pluginAddress.plugin, plugin));
29
28
  let row = await controlDb.query.pluginAddress.findFirst({ where });
30
29
  if (!row) {
@@ -11,6 +11,7 @@ import { saveAsset } from "../files/storage.js";
11
11
  import { wizardUnavailable } from "../limits.js";
12
12
  import { transcribeAudio } from "../media/transcribe.js";
13
13
  import { errorText, ModelUnavailableError } from "../model-errors.js";
14
+ import { uploadMime } from "../routes/delivery.js";
14
15
  import { runTicket } from "../secrets/signing.js";
15
16
  import { notFound, ServiceError } from "../services/errors.js";
16
17
  import { runnerSettingsOf, runnersOf } from "../services/runners.js";
@@ -51,7 +52,7 @@ export function runsApiFor(pluginId) {
51
52
  }
52
53
  const visitorId = visitorOf(input.person);
53
54
  if (await visitorOverLimit(visitorId, null)) {
54
- throw new ServiceError("refused", "This person started many runs lately. Try again in an hour.");
55
+ throw new ServiceError("refused", noteText({ code: "tooManyRuns" }, wizardLang(version.definition)));
55
56
  }
56
57
  const runId = await createRun({
57
58
  wizardId: w.id,
@@ -66,7 +67,9 @@ export function runsApiFor(pluginId) {
66
67
  throw new ServiceError("refused", errorText(err, wizardLang(version.definition)));
67
68
  }
68
69
  if (err instanceof NoCreditsError) {
69
- throw new ServiceError("no_credits", "The wizard's credits are used up.");
70
+ throw new ServiceError("no_credits", wizardLang(version.definition) === "de"
71
+ ? "Dieser Wizard hat gerade kein Guthaben mehr. Versuch es später noch einmal."
72
+ : "This wizard has run out of credits for now. Try again later.");
70
73
  }
71
74
  throw err;
72
75
  });
@@ -126,9 +129,9 @@ export function runsApiFor(pluginId) {
126
129
  await reviewStep(runId, stepId, action).catch(refusedInput);
127
130
  },
128
131
  async answerAsk(runId, askId, answer) {
129
- await pluginRun(runId);
132
+ const run = await pluginRun(runId);
130
133
  if (!(await answerAsk(runId, askId, answer === "allow" ? { type: "done" } : { type: "skip" }))) {
131
- throw new ServiceError("refused", "This question is no longer open.");
134
+ throw new ServiceError("refused", noteText({ code: "askClosed" }, wizardLang(run.definition)));
132
135
  }
133
136
  },
134
137
  async control(runId, action) {
@@ -173,10 +176,12 @@ export function runsApiFor(pluginId) {
173
176
  },
174
177
  async upload(runId, file) {
175
178
  await pluginRun(runId);
179
+ // The type comes from the channel's sender: kept only when it is one a wizard takes.
180
+ const mime = uploadMime(file.mime);
176
181
  const asset = await saveAsset({
177
182
  runId,
178
- kind: file.mime.startsWith("image/") ? "image" : "file",
179
- mime: file.mime,
183
+ kind: mime.startsWith("image/") ? "image" : "file",
184
+ mime,
180
185
  name: file.name,
181
186
  data: file.data instanceof Uint8Array ? file.data : new Uint8Array(file.data),
182
187
  });
@@ -185,11 +190,11 @@ export function runsApiFor(pluginId) {
185
190
  async transcribe(runId, file) {
186
191
  const run = await pluginRun(runId);
187
192
  if (run.status === "done" || run.status === "cancelled" || run.status === "failed") {
188
- throw new ServiceError("refused", "The run is over.");
193
+ throw new ServiceError("refused", noteText({ code: "runOver" }, wizardLang(run.definition)));
189
194
  }
190
195
  const bytes = file.data instanceof Uint8Array ? file.data : new Uint8Array(file.data);
191
196
  if (!bytes.length || bytes.length > 10_000_000) {
192
- throw new ServiceError("invalid", "A recording of up to 10 MB.");
197
+ throw new ServiceError("invalid", noteText({ code: "recordingTooLarge" }, wizardLang(run.definition)));
193
198
  }
194
199
  const { text } = await transcribeAudio({
195
200
  bytes,
@@ -4,7 +4,8 @@ import { assertPublicUrl } from "../tools/net-guard.js";
4
4
  /**
5
5
  * One Chromium for the whole server: the engenty-browser container over CDP when
6
6
  * `BROWSER_CDP_URL` is set, else a local headless Chrome. Every user of it opens
7
- * its own context, so runs never share cookies or storage.
7
+ * its own context, so runs never share cookies or storage. Service workers are blocked: their
8
+ * requests would not pass a context's route guard.
8
9
  */
9
10
  let browserPromise = null;
10
11
  async function connect() {
@@ -31,8 +32,17 @@ export function getBrowser() {
31
32
  }
32
33
  export async function newContext(options = {}) {
33
34
  const browser = await getBrowser();
34
- return browser.newContext({ viewport: { width: 1280, height: 900 }, ...options });
35
+ return browser.newContext({
36
+ viewport: { width: 1280, height: 900 },
37
+ serviceWorkers: "block",
38
+ ...options,
39
+ });
35
40
  }
41
+ /**
42
+ * Verdicts are kept only briefly: a name's addresses can change, and a page that rebinds its own
43
+ * name to a private address must not ride on an old "public".
44
+ */
45
+ const HOST_CHECK_TTL_MS = 30_000;
36
46
  const hostChecks = new Map();
37
47
  function publicHost(url) {
38
48
  let host;
@@ -42,25 +52,28 @@ function publicHost(url) {
42
52
  catch {
43
53
  return Promise.resolve(false);
44
54
  }
45
- let check = hostChecks.get(host);
46
- if (!check) {
47
- check = assertPublicUrl(url).then(() => true, () => false);
48
- hostChecks.set(host, check);
49
- if (hostChecks.size > 2000) {
50
- hostChecks.clear();
51
- }
55
+ const now = Date.now();
56
+ const cached = hostChecks.get(host);
57
+ if (cached && now - cached.at < HOST_CHECK_TTL_MS) {
58
+ return cached.check;
59
+ }
60
+ const check = assertPublicUrl(url).then(() => true, () => false);
61
+ hostChecks.set(host, { at: now, check });
62
+ if (hostChecks.size > 2000) {
63
+ hostChecks.clear();
52
64
  }
53
65
  return check;
54
66
  }
55
67
  /**
56
68
  * A context for browsing the web on a person's behalf: every request, whoever started it (the
57
- * agent, a redirect, the person clicking), may only reach public hosts.
69
+ * agent, a redirect, the person clicking), may only reach public hosts over http(s). Pages may
70
+ * still use their own data: and blob: URLs; every other scheme (file:, ftp:, ...) is refused.
58
71
  */
59
72
  export async function webContext(options = {}) {
60
73
  const context = await newContext({ acceptDownloads: true, ...options });
61
74
  await context.route("**/*", async (route) => {
62
75
  const url = route.request().url();
63
- if (!/^https?:/i.test(url) || (await publicHost(url))) {
76
+ if (/^(data|blob):/i.test(url) || (/^https?:/i.test(url) && (await publicHost(url)))) {
64
77
  await route.continue();
65
78
  }
66
79
  else {
@@ -20,7 +20,7 @@ import { siteJob, siteSchema, startSiteJob } from "../services/site.js";
20
20
  import { checkOnServer, checkSchema, listSyncedSpaces, patchSyncedWizard, removeSyncedSpace, removeSyncedWizard, rotateSyncedLink, syncDataSchema, syncSettingsSchema, syncSpaceData, syncWizard, syncWizardSchema, } from "../services/spaces.js";
21
21
  import { createWizard, ownedWizard, publishWizard, writeDraft } from "../services/wizards.js";
22
22
  import { codeOf, tenantStatus } from "../tenants/control.js";
23
- import { inLang } from "./lang.js";
23
+ import { inLang, notFoundAnswer } from "./lang.js";
24
24
  import { dispatchAs } from "./plugins.js";
25
25
  const importSchema = z.object({
26
26
  /** An earlier import's wizard: it is updated instead of a new one being made. */
@@ -44,7 +44,7 @@ const PRO_PLUGINS = ["contacts", "appointments"];
44
44
  const WIZARD_BYTES = 48 * 1024 * 1024;
45
45
  const tooLarge = bodyLimit({
46
46
  maxSize: WIZARD_BYTES,
47
- onError: (c) => c.json({ error: "Request body too large.", code: "too_large" }, 413),
47
+ onError: (c) => c.json({ error: "The request is too large.", code: "too_large" }, 413),
48
48
  });
49
49
  /**
50
50
  * The caller of an API call, signed in like an MCP client (access token or API key), with the
@@ -57,10 +57,13 @@ async function caller(c, scopes) {
57
57
  }
58
58
  const who = principalOf(authInfo);
59
59
  if (scopes.some((scope) => !who.scopes.includes(scope))) {
60
- return c.json({ error: "This token may not do that.", code: "insufficient_scope" }, 403);
60
+ return c.json({ error: "This token isn’t allowed to do that.", code: "insufficient_scope" }, 403);
61
61
  }
62
62
  if ((await tenantStatus(who.tenantId)) === "suspended") {
63
- return c.json({ error: "tenant_suspended" }, 403);
63
+ return c.json({
64
+ error: inLang(c, "Dieses Konto ist gesperrt.", "This account is suspended."),
65
+ code: "tenant_suspended",
66
+ }, 403);
64
67
  }
65
68
  return who;
66
69
  }
@@ -69,7 +72,7 @@ function withinSyncLimit(c, who) {
69
72
  return allowed(`sync:${who.tenantId}`, 240, HOUR)
70
73
  ? null
71
74
  : c.json({
72
- error: inLang(c, "Zu viele Übertragungen. Bitte später noch einmal.", "Too many transfers. Please try again later."),
75
+ error: inLang(c, "Zu viele Übertragungen. Versuch es später noch einmal.", "Too many transfers. Try again later."),
73
76
  code: "rate_limited",
74
77
  }, 429);
75
78
  }
@@ -91,7 +94,7 @@ async function pluginCall(c) {
91
94
  code: "plugin",
92
95
  plugin: id,
93
96
  }, 403)
94
- : c.json({ error: "not found", code: "not_found" }, 404);
97
+ : c.json(notFoundAnswer(c), 404);
95
98
  }
96
99
  return inTenantAs(who, () => dispatchAs(c, {
97
100
  id: who.userId,
@@ -287,7 +290,7 @@ export const apiRoutes = new Hono()
287
290
  }
288
291
  if (!allowed(`site:${who.tenantId}`, 20, HOUR)) {
289
292
  return c.json({
290
- error: inLang(c, "Zu oft hintereinander. Bitte später noch einmal.", "Too often in a row. Please try again later."),
293
+ error: inLang(c, "Zu oft hintereinander. Versuch es später noch einmal.", "Too many times in a row. Try again later."),
291
294
  code: "rate_limited",
292
295
  }, 429);
293
296
  }
@@ -389,10 +392,13 @@ export const apiRoutes = new Hono()
389
392
  const body = importSchema.parse(await c.req.json());
390
393
  if (!who.scopes.includes("wizards:write") ||
391
394
  (body.publish && !who.scopes.includes("wizards:publish"))) {
392
- return c.json({ error: "This token may not do that.", code: "insufficient_scope" }, 403);
395
+ return c.json({ error: "This token isn’t allowed to do that.", code: "insufficient_scope" }, 403);
393
396
  }
394
397
  if ((await tenantStatus(who.tenantId)) === "suspended") {
395
- return c.json({ error: "tenant_suspended" }, 403);
398
+ return c.json({
399
+ error: inLang(c, "Dieses Konto ist gesperrt.", "This account is suspended."),
400
+ code: "tenant_suspended",
401
+ }, 403);
396
402
  }
397
403
  return inTenantAs(who, async () => {
398
404
  const writer = { source: "mcp", client: who.client };
@@ -15,7 +15,7 @@ export const localDebugRoutes = new Hono()
15
15
  .use(async (c, next) => {
16
16
  const ticket = c.req.header(LOCAL_MCP_HEADER) ?? "";
17
17
  if (managed || !localTicketExpiry(env.authSecret, ticket, Date.now(), "local-debug")) {
18
- return c.json({ error: "unauthorized" }, 401);
18
+ return c.json({ error: "This ticket isn’t valid.", code: "unauthorized" }, 401);
19
19
  }
20
20
  await next();
21
21
  })
@@ -3,6 +3,52 @@ import { basePath } from "../env.js";
3
3
  export function uploadLimit(mime) {
4
4
  return mime.startsWith("video/") ? 40_000_000 : 15_000_000;
5
5
  }
6
+ /** `type/subtype` and nothing else: no commas, spaces or quotes a browser could read another type from. */
7
+ const MIME = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
8
+ /** The bare type of a declared one ("audio/webm;codecs=opus" → "audio/webm"), or null if it is not one. */
9
+ export function bareMime(declared) {
10
+ const mime = declared.split(";")[0].trim().toLowerCase();
11
+ return MIME.test(mime) ? mime : null;
12
+ }
13
+ /** What a person hands a wizard besides photos, recordings and clips: documents and tables. */
14
+ const DOCUMENT_TYPES = new Set([
15
+ "application/pdf",
16
+ "application/msword",
17
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
18
+ "application/vnd.ms-excel",
19
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
20
+ "application/vnd.ms-powerpoint",
21
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
22
+ "application/vnd.oasis.opendocument.text",
23
+ "application/vnd.oasis.opendocument.spreadsheet",
24
+ "application/vnd.oasis.opendocument.presentation",
25
+ "application/rtf",
26
+ "application/json",
27
+ "application/zip",
28
+ "message/rfc822",
29
+ "text/plain",
30
+ "text/csv",
31
+ "text/tab-separated-values",
32
+ "text/markdown",
33
+ "text/calendar",
34
+ "text/vcard",
35
+ ]);
36
+ /**
37
+ * The type an upload is kept as: the browser's word for it, when it is a picture, a recording, a
38
+ * clip or a document. Anything else (an HTML page, a script) is kept as plain bytes; reading it
39
+ * goes by its name (documents/parse.ts).
40
+ */
41
+ export function uploadMime(declared) {
42
+ const mime = bareMime(declared);
43
+ return mime && (/^(image|audio|video)\//.test(mime) || DOCUMENT_TYPES.has(mime))
44
+ ? mime
45
+ : "application/octet-stream";
46
+ }
47
+ /** What a browser shows in place without running anything of the file's: photos, sound, film, PDFs. */
48
+ export function inlineSafe(mime) {
49
+ return ((/^(image|audio|video)\//.test(mime) && !mime.startsWith("image/svg")) ||
50
+ mime === "application/pdf");
51
+ }
6
52
  /** `version`: the published one, so a new engenty or name reaches icons a phone has kept. */
7
53
  export function wizardManifest(token, title, description, version = 0) {
8
54
  const start = `${basePath}/w/${token}`;
@@ -11,3 +11,10 @@ export function requestLang(c) {
11
11
  export function inLang(c, de, en) {
12
12
  return requestLang(c) === "de" ? de : en;
13
13
  }
14
+ /** A 404 the person may read: a sentence in their language, the machine code beside it. */
15
+ export function notFoundAnswer(c) {
16
+ return {
17
+ error: inLang(c, "Das gibt es nicht oder nicht mehr.", "This doesn’t exist or was deleted."),
18
+ code: "not_found",
19
+ };
20
+ }
@@ -2,6 +2,7 @@ import { CAPABILITY_IDS, INDUSTRY_IDS, ITEM_FORMAT_IDS, MARKETPLACE_PLAN_IDS, US
2
2
  import { Hono } from "hono";
3
3
  import { z } from "zod";
4
4
  import { asLang, marketplaceDetail, searchMarketplace, starEntry, } from "../services/marketplace.js";
5
+ import { notFoundAnswer } from "./lang.js";
5
6
  const searchSchema = z.object({
6
7
  q: z.string().trim().max(200).default(""),
7
8
  lang: z.string().optional(),
@@ -25,7 +26,7 @@ export const marketplaceStudio = new Hono()
25
26
  })
26
27
  .get("/:id", async (c) => {
27
28
  const detail = await marketplaceDetail(c.req.param("id"), asLang(c.req.query("lang")));
28
- return detail ? c.json(detail) : c.json({ error: "not found" }, 404);
29
+ return detail ? c.json(detail) : c.json(notFoundAnswer(c), 404);
29
30
  })
30
31
  .put("/:id/star", async (c) => {
31
32
  await starEntry(c.get("user").tenantId, c.req.param("id"), true);
@@ -3,6 +3,7 @@ import { withTenant } from "../db/client.js";
3
3
  import { SHARED, tenantOfAddress } from "../plugins/public.js";
4
4
  import { loadedPlugin, pluginIdsOf } from "../plugins/registry.js";
5
5
  import { tenantStatus } from "../tenants/control.js";
6
+ import { notFoundAnswer } from "./lang.js";
6
7
  import { answerOf } from "./plugins.js";
7
8
  /**
8
9
  * Plugins' public routes (`server.registerPublicRoute`), below `/api/public/plugins/<id>/<ref>`.
@@ -10,7 +11,7 @@ import { answerOf } from "./plugins.js";
10
11
  * Shared routes (`server.registerSharedRoute`) are below `/api/public/plugins/<id>/shared`, for
11
12
  * every tenant; their handler enters a tenant by the ref the call carries.
12
13
  */
13
- const notFound = (c) => c.json({ error: "not found", code: "not_found" }, 404);
14
+ const notFound = (c) => c.json(notFoundAnswer(c), 404);
14
15
  /** What a handler threw, as the answer: its status and code, or a 500. */
15
16
  function failed(c, id, what, err) {
16
17
  const answer = answerOf(err);
@@ -5,6 +5,7 @@ import { managed } from "../manage.js";
5
5
  import { pluginProblems, reloadPlugin, rescanPlugins } from "../plugins/loader.js";
6
6
  import { installPro, proState, refreshPro, removePro } from "../plugins/pro.js";
7
7
  import { loadedPlugin, onPluginChange, pluginAsset, pluginIdsOf, pluginsOf, } from "../plugins/registry.js";
8
+ import { notFoundAnswer } from "./lang.js";
8
9
  const admin = (user) => user.role === "owner" || user.role === "admin";
9
10
  /** Plugins change while the app runs only where it runs alone: a reload would hit every tenant. */
10
11
  const canReload = (user) => !managed && admin(user);
@@ -49,7 +50,7 @@ export async function dispatchAs(c, user) {
49
50
  const id = c.req.param("id") ?? "";
50
51
  const plugin = (await pluginIdsOf(user.tenantId)).has(id) ? loadedPlugin(id) : undefined;
51
52
  if (!plugin) {
52
- return c.json({ error: "not found", code: "not_found" }, 404);
53
+ return c.json(notFoundAnswer(c), 404);
53
54
  }
54
55
  const url = new URL(c.req.url);
55
56
  const path = url.pathname.slice(url.pathname.indexOf(`/plugins/${id}`) + `/plugins/${id}`.length);
@@ -59,7 +60,7 @@ export async function dispatchAs(c, user) {
59
60
  continue;
60
61
  }
61
62
  if (route.role === "admin" && !admin(user)) {
62
- return c.json({ error: "forbidden", code: "forbidden" }, 403);
63
+ return c.json({ error: "This isn’t allowed here.", code: "forbidden" }, 403);
63
64
  }
64
65
  const request = {
65
66
  request: c.req.raw,
@@ -91,7 +92,7 @@ export async function dispatchAs(c, user) {
91
92
  return c.json({ error: "The plugin failed.", code: "plugin_failed" }, 500);
92
93
  }
93
94
  }
94
- return c.json({ error: "not found", code: "not_found" }, 404);
95
+ return c.json(notFoundAnswer(c), 404);
95
96
  }
96
97
  const dispatch = (c) => dispatchAs(c, c.get("user"));
97
98
  const TYPES = {
@@ -117,7 +118,7 @@ export const pluginRoutes = new Hono()
117
118
  const plugin = (await pluginIdsOf(user.tenantId)).has(id) ? loadedPlugin(id) : undefined;
118
119
  const asset = plugin && kind ? pluginAsset(plugin, kind.kind) : null;
119
120
  if (!asset || !kind) {
120
- return c.json({ error: "not found", code: "not_found" }, 404);
121
+ return c.json(notFoundAnswer(c), 404);
121
122
  }
122
123
  return c.body(new Uint8Array(await readFile(asset.file)), 200, {
123
124
  "content-type": kind.type,
@@ -127,7 +128,7 @@ export const pluginRoutes = new Hono()
127
128
  // Says when a plugin loaded again, so an open studio takes the new one without a page load.
128
129
  .get("/-/events", (c) => {
129
130
  if (managed) {
130
- return c.json({ error: "not found", code: "not_found" }, 404);
131
+ return c.json(notFoundAnswer(c), 404);
131
132
  }
132
133
  return streamSSE(c, async (stream) => {
133
134
  let closed = false;
@@ -149,37 +150,35 @@ export const pluginRoutes = new Hono()
149
150
  })
150
151
  .post("/-/reload", async (c) => {
151
152
  if (!canReload(c.get("user"))) {
152
- return c.json({ error: "forbidden", code: "forbidden" }, 403);
153
+ return c.json({ error: "This isn’t allowed here.", code: "forbidden" }, 403);
153
154
  }
154
155
  await rescanPlugins();
155
156
  return c.json({ plugins: (await pluginsOf(c.get("user").tenantId)).map(view) });
156
157
  })
157
158
  .post("/-/reload/:id", async (c) => {
158
159
  if (!canReload(c.get("user"))) {
159
- return c.json({ error: "forbidden", code: "forbidden" }, 403);
160
+ return c.json({ error: "This isn’t allowed here.", code: "forbidden" }, 403);
160
161
  }
161
162
  const plugin = await reloadPlugin(c.req.param("id"));
162
- return plugin
163
- ? c.json({ plugin: view(plugin) })
164
- : c.json({ error: "not found", code: "not_found" }, 404);
163
+ return plugin ? c.json({ plugin: view(plugin) }) : c.json(notFoundAnswer(c), 404);
165
164
  })
166
165
  // Pro modules: the closed plugins the linked account's plan includes (plugins/pro.ts).
167
166
  .get("/-/pro", async (c) => {
168
167
  if (!canReload(c.get("user"))) {
169
- return c.json({ error: "not found", code: "not_found" }, 404);
168
+ return c.json(notFoundAnswer(c), 404);
170
169
  }
171
170
  return c.json(await proState());
172
171
  })
173
172
  .post("/-/pro/refresh", async (c) => {
174
173
  if (!canReload(c.get("user"))) {
175
- return c.json({ error: "forbidden", code: "forbidden" }, 403);
174
+ return c.json({ error: "This isn’t allowed here.", code: "forbidden" }, 403);
176
175
  }
177
176
  await refreshPro();
178
177
  return c.json(await proState());
179
178
  })
180
179
  .post("/-/pro/:id", async (c) => {
181
180
  if (!canReload(c.get("user"))) {
182
- return c.json({ error: "forbidden", code: "forbidden" }, 403);
181
+ return c.json({ error: "This isn’t allowed here.", code: "forbidden" }, 403);
183
182
  }
184
183
  try {
185
184
  await installPro(c.req.param("id"));
@@ -195,7 +194,7 @@ export const pluginRoutes = new Hono()
195
194
  })
196
195
  .delete("/-/pro/:id", async (c) => {
197
196
  if (!canReload(c.get("user"))) {
198
- return c.json({ error: "forbidden", code: "forbidden" }, 403);
197
+ return c.json({ error: "This isn’t allowed here.", code: "forbidden" }, 403);
199
198
  }
200
199
  try {
201
200
  await removePro(c.req.param("id"));