wizards 0.2.25 → 0.2.27
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/agents/architect.js +5 -0
- package/apps/runtime/dist/agents/project-assistant.js +10 -1
- package/apps/runtime/dist/authoring/guide.js +61 -8
- package/apps/runtime/dist/connectors/builtin.js +17 -1
- package/apps/runtime/dist/connectors/index.js +43 -14
- package/apps/runtime/dist/connectors/plugin-connections.js +248 -0
- package/apps/runtime/dist/connectors/via-account.js +63 -0
- package/apps/runtime/dist/credits/estimate.js +12 -2
- package/apps/runtime/dist/db/migrations/tenant/0010_plugin_connection.sql +15 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/0010_snapshot.json +2942 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
- package/apps/runtime/dist/db/schema.js +21 -0
- package/apps/runtime/dist/engenty/connections-google/connectors/calendar.js +25 -3
- package/apps/runtime/dist/engenty/connections-microsoft/outlook.js +29 -1
- package/apps/runtime/dist/engine/decide.js +82 -0
- package/apps/runtime/dist/engine/input.js +21 -5
- package/apps/runtime/dist/engine/requirements.js +80 -15
- package/apps/runtime/dist/engine/runner.js +252 -6
- package/apps/runtime/dist/engine/steps.js +206 -18
- package/apps/runtime/dist/engine/surface.js +90 -0
- package/apps/runtime/dist/mcp/tools.js +3 -2
- package/apps/runtime/dist/plugins/loader.js +22 -1
- package/apps/runtime/dist/plugins/registry.js +10 -0
- package/apps/runtime/dist/render/downloads.js +7 -0
- package/apps/runtime/dist/routes/runs.js +28 -7
- package/apps/runtime/dist/routes/studio.js +3 -1
- package/apps/runtime/dist/services/marketplace.js +134 -3
- package/apps/runtime/dist/services/relevance.js +17 -49
- package/apps/runtime/dist/services/runs.js +43 -15
- package/apps/runtime/dist/services/wizards.js +4 -2
- package/apps/runtime/dist/tools/pages.js +31 -6
- package/apps/runtime/dist/tools/plugin.js +29 -1
- package/apps/web/dist/assets/Account-DW396ig5.js +1 -0
- package/apps/web/dist/assets/{ChangelogDialog-B7C0r9dF.js → ChangelogDialog-BdZ1SmEd.js} +1 -1
- package/apps/web/dist/assets/CreditsDialog-fOYdoev3.js +1 -0
- package/apps/web/dist/assets/EditorPage-CAARFsaI.js +3 -0
- package/apps/web/dist/assets/{FlowPreview-D0m5Qk6S.js → FlowPreview-U2AEnKlX.js} +1 -1
- package/apps/web/dist/assets/{Harness-bIL3pHB_.js → Harness-CtDlm_2b.js} +2 -2
- package/apps/web/dist/assets/HomePage-Bd-mkLxW.js +1 -0
- package/apps/web/dist/assets/ImportWizard-BMPGjd_t.js +1 -0
- package/apps/web/dist/assets/LocalRuntime-DjSNCRnV.js +1 -0
- package/apps/web/dist/assets/NewWizardPage-DZPcDR9i.js +2 -0
- package/apps/web/dist/assets/ProjectPage-_LAtG3tn.js +2 -0
- package/apps/web/dist/assets/PublicRunner-6Iqp0DmU.js +1 -0
- package/apps/web/dist/assets/RunDrawer-CtQoY3Ha.js +4 -0
- package/apps/web/dist/assets/RunnerView-BLOCNZaZ.js +2 -0
- package/apps/web/dist/assets/SettingsPage-Z6skBf6I.js +1 -0
- package/apps/web/dist/assets/SetupPage-4-Lic4pT.js +1 -0
- package/apps/web/dist/assets/SharePage-DfdhXxZm.js +1 -0
- package/apps/web/dist/assets/arrow-right-BOtqnsA1.js +1 -0
- package/apps/web/dist/assets/brand-CCAI9p0f.js +2 -0
- package/apps/web/dist/assets/changelog-DQM0oz9S.js +1 -0
- package/apps/web/dist/assets/circle-alert-BC6xmJ9r.js +1 -0
- package/apps/web/dist/assets/definition-D5V79jHA.js +1 -0
- package/apps/web/dist/assets/esm-FEWi61F_.js +7 -0
- package/apps/web/dist/assets/external-link-wKL1HE6G.js +1 -0
- package/apps/web/dist/assets/image-BMdSChOR.js +1 -0
- package/apps/web/dist/assets/index-BOp2fz-2.css +1 -0
- package/apps/web/dist/assets/index-YJMooz0J.js +132 -0
- package/apps/web/dist/assets/lock-BpqzLMuv.js +1 -0
- package/apps/web/dist/assets/meta-BqZ7SlZz.js +1 -0
- package/apps/web/dist/assets/pen-line-DM-u0dES.js +1 -0
- package/apps/web/dist/assets/refresh-cw-Bk7HkOIM.js +1 -0
- package/apps/web/dist/assets/settings-menu-BM_NfZfn.js +1 -0
- package/apps/web/dist/assets/star-JHzYYl5R.js +1 -0
- package/apps/web/dist/assets/trash-CdLg-Ela.js +1 -0
- package/apps/web/dist/assets/ui-8QmvuC9u.js +9 -0
- package/apps/web/dist/assets/upload-CL1R6shM.js +1 -0
- package/apps/web/dist/assets/video-BioLmXNO.js +1 -0
- package/apps/web/dist/assets/volume-2-_IOfPNCv.js +1 -0
- package/apps/web/dist/assets/wand-sparkles-DYtDQSJZ.js +1 -0
- package/apps/web/dist/index.html +4 -5
- package/apps/web/dist/mcp/flow.html +61 -61
- package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +85 -0
- package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +23 -0
- package/node_modules/@engenty-wizards/shared/dist/decision.d.ts +76 -0
- package/node_modules/@engenty-wizards/shared/dist/decision.js +147 -0
- package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +1076 -51
- package/node_modules/@engenty-wizards/shared/dist/definition.js +597 -44
- package/node_modules/@engenty-wizards/shared/dist/marketplace-entry.js +19 -2
- package/node_modules/@engenty-wizards/shared/dist/marketplace.d.ts +6 -1
- package/node_modules/@engenty-wizards/shared/dist/run.d.ts +55 -0
- package/node_modules/@engenty-wizards/shared/dist/run.js +3 -0
- package/node_modules/@engenty-wizards/shared/dist/surface.d.ts +89 -0
- package/node_modules/@engenty-wizards/shared/dist/surface.js +198 -0
- package/package.json +1 -1
- package/apps/web/dist/assets/Account-CpxsROpe.js +0 -1
- package/apps/web/dist/assets/CreditsDialog-Com2Y3g8.js +0 -1
- package/apps/web/dist/assets/EditorPage-BCagWjrz.js +0 -2
- package/apps/web/dist/assets/HomePage-DPE_Z9HB.js +0 -1
- package/apps/web/dist/assets/ImportWizard-Dz2uKznp.js +0 -1
- package/apps/web/dist/assets/LocalRuntime-wMKUVZOu.js +0 -1
- package/apps/web/dist/assets/NewWizardPage-BOxU2jWq.js +0 -2
- package/apps/web/dist/assets/ProjectPage-BJp2wqIk.js +0 -5
- package/apps/web/dist/assets/PublicRunner-BsrqVK0J.js +0 -1
- package/apps/web/dist/assets/RunDrawer-CLfJ0W_V.js +0 -1
- package/apps/web/dist/assets/RunnerView-BXMELFC4.js +0 -70
- package/apps/web/dist/assets/SettingsPage-BkuUm-bf.js +0 -1
- package/apps/web/dist/assets/SetupPage-CoyXpcvS.js +0 -1
- package/apps/web/dist/assets/SharePage-BuR-he1L.js +0 -1
- package/apps/web/dist/assets/api-rJBQEu1G.js +0 -10
- package/apps/web/dist/assets/arrow-right-6FyN3e7D.js +0 -1
- package/apps/web/dist/assets/changelog-Bmw6dg-T.js +0 -1
- package/apps/web/dist/assets/check-CUpby_J9.js +0 -1
- package/apps/web/dist/assets/chevron-down-DymW-2BP.js +0 -1
- package/apps/web/dist/assets/circle-alert-D2SCwFT-.js +0 -1
- package/apps/web/dist/assets/createLucideIcon-q3MZMmnf.js +0 -1
- package/apps/web/dist/assets/definition-BznNdlhf.js +0 -55
- package/apps/web/dist/assets/esm-BrGYYg2h.js +0 -7
- package/apps/web/dist/assets/external-link-MAL7vJYj.js +0 -1
- package/apps/web/dist/assets/image-CC_2mJd6.js +0 -1
- package/apps/web/dist/assets/index-BZs1IxAk.css +0 -1
- package/apps/web/dist/assets/index-Co5QOA9_.js +0 -10
- package/apps/web/dist/assets/lock-DZL2N67v.js +0 -1
- package/apps/web/dist/assets/meta-B3jzXP27.js +0 -1
- package/apps/web/dist/assets/pen-line-NK46C_nG.js +0 -1
- package/apps/web/dist/assets/plus-Du-Jx8aI.js +0 -1
- package/apps/web/dist/assets/refresh-cw-BNtHis1x.js +0 -1
- package/apps/web/dist/assets/settings-menu-CCNUb0Fd.js +0 -1
- package/apps/web/dist/assets/table-2-BdDuBcF2.js +0 -1
- package/apps/web/dist/assets/trash-0IafyhZB.js +0 -1
- package/apps/web/dist/assets/ui-DJOZSSKo.js +0 -1
- package/apps/web/dist/assets/upload-DNRENWzQ.js +0 -1
- package/apps/web/dist/assets/video-9fDPRPCv.js +0 -1
- package/apps/web/dist/assets/volume-2-DE67_rue.js +0 -1
- package/apps/web/dist/assets/wand-sparkles-Clj3VRB5.js +0 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { anthropic } from "@ai-sdk/anthropic";
|
|
2
|
+
import { SURFACE_GUIDE } from "@engenty-wizards/shared/surface";
|
|
2
3
|
import { isTextMime } from "@engenty-wizards/shared/workspace";
|
|
3
4
|
import { Agent } from "@mastra/core/agent";
|
|
4
5
|
import { createTool } from "@mastra/core/tools";
|
|
@@ -27,6 +28,9 @@ ${SCHEMA_DOC}
|
|
|
27
28
|
${PRINCIPLES}
|
|
28
29
|
${WIDGET_GUIDE}
|
|
29
30
|
|
|
31
|
+
Surface catalog (for surface steps and output.surface):
|
|
32
|
+
${SURFACE_GUIDE}
|
|
33
|
+
|
|
30
34
|
${mcpServersLine(mcp)}
|
|
31
35
|
|
|
32
36
|
${connectorsLine(connectors)}${pluginToolsLine(pluginTools)}
|
|
@@ -40,6 +44,7 @@ HOW YOU WORK
|
|
|
40
44
|
- You change the wizard ONLY through tools: edit_wizard (small ops) or replace_wizard (new or mostly rewritten). Every write returns the validator's issues — fix them before you finish.
|
|
41
45
|
- Widgets and their data live in the workspace: list_files, read_file, write_file, delete_file, download_files. Then check_widget, look at the screenshot, fix, check again.
|
|
42
46
|
- Order: 1. write the wizard's steps, 2. fetch only the reference data the widget truly needs (one download_files call for all of it), 3. write the widget and its sample.json, 4. check_widget and fix, 5. answer. You have about 30 tool calls — never spend them on exploring.
|
|
47
|
+
- A message that starts "New step" or "Neuer Schritt" comes from the studio's step panel or diagram: add exactly that one step in the place it names – of the kind in brackets, or the kind that fits best when none is named – and build it completely: a page's fields, an AI step's instructions and output, a view's components and data, a widget with its HTML and sample.json. Read what it needs from the steps before it, and leave the other steps as they are unless the new step needs a change there. With "Only if:" / "Nur wenn:" or "AI decides:" / "KI entscheidet:" the new step is a branch that only some runs take: put it right after the step before it, and give that step a rule to the new step (a "when" condition written from the admin's words against the data known there, or an "ask" statement for AI decides) plus an "otherwise" rule (only "goto") to the step every other run went to before; the new step then goes on to that same step as the next in the list. Keep that step's other rules.
|
|
43
48
|
- Call independent tools in the same step (they run together).
|
|
44
49
|
- Never retype data you downloaded. Reshape it with run_script (output to a file), or let the widget read the raw files with wizard.json() and transform them when it starts.
|
|
45
50
|
- Work in few, purposeful steps: every tool call costs the admin time and credits.
|
|
@@ -12,7 +12,7 @@ import { brandColorSchema, ownedProject, updateProject } from "../services/proje
|
|
|
12
12
|
import { categoryRows } from "../services/space-categories.js";
|
|
13
13
|
import { currentTenant } from "../tenants/tenant.js";
|
|
14
14
|
import { safeFetch } from "../tools/net-guard.js";
|
|
15
|
-
import { assistantToolsOf } from "../tools/plugin.js";
|
|
15
|
+
import { assistantPageOf, assistantToolsOf } from "../tools/plugin.js";
|
|
16
16
|
import { readWebsite } from "./website.js";
|
|
17
17
|
const MAX_STEPS = 24;
|
|
18
18
|
const SYSTEM = `You help the admin of "engenty wizards" set up a SPACE: what every wizard of the space draws on — who they are (title, about), the brand (logos, colours), assets (images, graphics, videos), documents, and facts (label → value: address, VAT id, phone, opening hours — anything).
|
|
@@ -337,7 +337,16 @@ export async function runProjectAssistant(input) {
|
|
|
337
337
|
model: resolved.model,
|
|
338
338
|
tools,
|
|
339
339
|
});
|
|
340
|
+
const page = input.plugin
|
|
341
|
+
? await assistantPageOf(currentTenant(), input.plugin, { id: project.id, name: project.name })
|
|
342
|
+
: null;
|
|
340
343
|
const context = [
|
|
344
|
+
page
|
|
345
|
+
? [
|
|
346
|
+
`The admin talks to you from the page of "${page.name}" in the studio${page.description ? `: ${page.description}` : "."} They most likely want to change what that page holds: use its tools (their names begin with "${input.plugin?.replace(/-/g, "_")}_"). Space settings stay possible when they ask for them.`,
|
|
347
|
+
...page.blocks,
|
|
348
|
+
].join("\n\n")
|
|
349
|
+
: "",
|
|
341
350
|
`The project now:\n\`\`\`json\n${JSON.stringify(await projectState(input.userId, input.projectId))}\n\`\`\``,
|
|
342
351
|
input.part === "knowledge"
|
|
343
352
|
? "The admin talks to you from Wissen: they want the wizards to know something."
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SURFACE_GUIDE } from "@engenty-wizards/shared/surface";
|
|
1
2
|
import { sandboxGuideLine } from "../sandbox/index.js";
|
|
2
3
|
import { EXAMPLE_WIZARD } from "./example.js";
|
|
3
4
|
/**
|
|
@@ -50,14 +51,34 @@ type Connection =
|
|
|
50
51
|
// "write" that in truth only looks something up (MCP servers often leave the mark out).
|
|
51
52
|
|
|
52
53
|
// Every step: { id (camelCase, unique), title, description?, next?: Branch[] }
|
|
53
|
-
// Branch = { when:
|
|
54
|
-
//
|
|
55
|
-
//
|
|
54
|
+
// Branch = { when: Condition | Condition[], goto: stepId | "end", max? }
|
|
55
|
+
// | { ask: "a statement about the run", goto: stepId | "end", max? }
|
|
56
|
+
// | { goto: stepId | "end" } // otherwise: where the run goes when no other branch holds (one per step)
|
|
57
|
+
// ask: a decision reads the person's answers and what the steps made so far and takes the branch when the
|
|
58
|
+
// statement is true ("The person wants a refund, not an exchange"). All ask branches of a step are decided
|
|
59
|
+
// together in one cheap call, after no "when" branch matched; if none clearly holds the next step follows.
|
|
60
|
+
// Use ask where a rule on one field cannot say it; never one decision step per branch.
|
|
61
|
+
// max: how often the branch may be taken in one run. A branch BACK to an earlier step is a loop ("improve until
|
|
62
|
+
// the check passes": a decision step branches back to the writing step) — give it max (at most 10 without);
|
|
63
|
+
// the step that runs again sees what it made last time.
|
|
64
|
+
// Condition = { field, op: "equals"|"notEquals"|"in"|"notEmpty"|"empty"|"gt"|"lt"|"contains", value? }
|
|
65
|
+
// field is a page field id, an output field of this or an earlier agent step as "steps.<stepId>.<fieldId>",
|
|
66
|
+
// or how many rows a stored list has as "lists.<listId>.count"
|
|
67
|
+
// gt / lt compare numbers (value is a number); contains looks into a multiselect, a list output or a text;
|
|
68
|
+
// in takes a list of values. A list of conditions holds when every one holds.
|
|
69
|
+
// first matching branch wins, otherwise the step's "otherwise" branch, else the next step in the list.
|
|
70
|
+
// A step only some runs take sits right after the step that branches to it; that step's otherwise branch
|
|
71
|
+
// leads every other run past it, and the step itself goes on as the next in the list.
|
|
56
72
|
|
|
57
|
-
type PageStep = { type: "page", fields: Field[] (1–5 per page), cta?: string }
|
|
73
|
+
type PageStep = { type: "page", fields: Field[] (1–5 per page), groups?: { id, instructions, optional? }[], cta?: string }
|
|
74
|
+
// Decided fields: a field with ask: "statement" is shown when a decision finds the statement true of the run;
|
|
75
|
+
// fields with group: groupId are alternatives of which the decision shows one ("How should we reach the person?"),
|
|
76
|
+
// or none when the group is optional. Decided once as the run reaches the page. Such a page has up to 12 fields
|
|
77
|
+
// and shows at most 5. Use them where the earlier answers decide which question makes sense and no single field
|
|
78
|
+
// rule can say it; "when" stays the first choice.
|
|
58
79
|
type Field = {
|
|
59
80
|
id (camelCase, unique across the WHOLE wizard), label, kind, required?, placeholder?, help?,
|
|
60
|
-
options?: string[] // select / multiselect
|
|
81
|
+
options?: string[] // select / multiselect / slot
|
|
61
82
|
default?: string|number|boolean|string[]
|
|
62
83
|
columns?: { id, label, kind: "text"|"number"|"money" }[] // items only; row amount = product of number/money columns
|
|
63
84
|
vat?: { rate?: number, field?: fieldId } // items only; VAT % fixed or read from a field
|
|
@@ -74,9 +95,15 @@ type Field = {
|
|
|
74
95
|
list?: listId // kind "list": the page shows that stored list for the person to check, correct and extend
|
|
75
96
|
prefill?: "steps.<id>.<key>" // what the field starts with, from an earlier step. An "items" field takes a table whose
|
|
76
97
|
// columns are exactly its column ids — the step proposes the positions, the person corrects them
|
|
98
|
+
when?: Condition | Condition[] // the field is shown only while this holds: it reads another field of the SAME page (decided as
|
|
99
|
+
// the person types) or anything known before the page (an earlier answer, steps.<id>.<field>).
|
|
100
|
+
// A hidden field is not required and not kept; its {{template}} is empty.
|
|
101
|
+
optionsFrom?: "steps.<id>.<key>" | "steps.<id>.<key>.<column>" | "lists.<id>.<column>"
|
|
102
|
+
// select / multiselect / slot: the choices come from earlier data — a "list" output, one column of a
|
|
103
|
+
// "table" output, or a column of a stored list. "options" stand in while that data is empty.
|
|
77
104
|
}
|
|
78
105
|
// kinds: text textarea number select multiselect date email url toggle color image file items connection list
|
|
79
|
-
// location audio signature
|
|
106
|
+
// location audio signature slot
|
|
80
107
|
// location: where the person is — the phone's position on a tap (with its accuracy), or an address they type.
|
|
81
108
|
// {{field}} → "Herrengasse 16, 8010 Graz (47.07071, 15.43950, ±12 m)" · {{field.lat}} {{field.lng}}
|
|
82
109
|
// {{field.accuracy}} (metres) · {{field.label}} (the address line) · {{field.map}} (a map link).
|
|
@@ -85,6 +112,10 @@ type Field = {
|
|
|
85
112
|
// {{field}} is what the person said, as text. {{field.seconds}} is its length.
|
|
86
113
|
// signature: drawn with the finger — a picture (dark ink, clear ground). A document step can place it:
|
|
87
114
|
// {{field}} → asset://ID, used as <img src="asset://ID">.
|
|
115
|
+
// slot: the person picks one appointment time, shown by day. Options are ISO 8601 date-times with their offset
|
|
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;
|
|
118
|
+
// then a page { kind: "slot", optionsFrom: "steps.free.slots.start" }; then an agent step with appointments.book.
|
|
88
119
|
|
|
89
120
|
type AgentStep = {
|
|
90
121
|
type: "agent"
|
|
@@ -92,7 +123,12 @@ type AgentStep = {
|
|
|
92
123
|
tools: ("web_search"|"web_fetch"|"browser"|"sandbox"|"image"|"http"|"pages")[]
|
|
93
124
|
mcp?: string[] // ids of the project's MCP servers this step may use
|
|
94
125
|
connections?: string[] // ids of wizard connections this step may read (mail → mail_search, mail_read, mail_save)
|
|
95
|
-
output: { format: "text"|"markdown"|"json", fields?: { id, kind: "text"|"number"|"list"|"table"|"yesno"|"choice", description, columns?: string[], options?: string[] }[] }
|
|
126
|
+
output: { format: "text"|"markdown"|"json", fields?: { id, kind: "text"|"number"|"list"|"table"|"yesno"|"choice"|"score", description, columns?: string[], options?: string[] }[], surface?: boolean }
|
|
127
|
+
// "score" answers its description on the scale in options, lowest first ("low", "medium", "high")
|
|
128
|
+
each?: "steps.<id>.<key>" | "lists.<id>"
|
|
129
|
+
// ONE RUN PER ENTRY (at most 20): the rows of a table output, the entries of a list output, or the
|
|
130
|
+
// rows of a stored list; the instructions read {{item.<column>}}, {{index}}, {{count}}. The output is
|
|
131
|
+
// one table, steps.<id>.rows: the entry's columns plus the step's output fields, a row per entry.
|
|
96
132
|
// a table with columns gives rows as objects with exactly those keys — what widgets read
|
|
97
133
|
// "yesno" answers the question in its description with true/false; "choice" picks one of its options
|
|
98
134
|
model?: "classifier"|"standard"|"high"|"highest" // the kind of model, see "Models"; default "high"
|
|
@@ -110,6 +146,19 @@ type AgentStep = {
|
|
|
110
146
|
// browser_request_user (the person solves a captcha or "continue with Google" in a picture of the page),
|
|
111
147
|
// browser_download (keeps a file from a signed-in site).
|
|
112
148
|
|
|
149
|
+
type SurfaceStep = {
|
|
150
|
+
type: "surface"
|
|
151
|
+
components: SurfaceComponent[] // a view from the surface catalog (below), written once; every run brings new data
|
|
152
|
+
data: { [key]: "fieldId" | "steps.<id>" | "steps.<id>.<key>" | "lists.<id>" | "brand.name" | "today" }
|
|
153
|
+
// what the components read by path: {"path": "/<key>/..."}
|
|
154
|
+
// or instead of components — pieces a decision picks per run (no chart when there is nothing to chart):
|
|
155
|
+
candidates?: { id, description, components: SurfaceComponent[] (the first has the piece's id), group?, required? }[]
|
|
156
|
+
groups?: { id, instructions, optional? }[]
|
|
157
|
+
}
|
|
158
|
+
// A SURFACE is a native view in the runner — facts, a list of results, key figures, a chart — themed, on phones, free per run.
|
|
159
|
+
// Prefer it to a widget for such views; a widget is for what the catalog cannot show (a map, an animation).
|
|
160
|
+
// An agent step with output.format "json" can also set output.surface: true: it composes such a view of its own result.
|
|
161
|
+
// Reviews and the result show a step's view instead of its plain table.
|
|
113
162
|
type GenerateStep = {
|
|
114
163
|
type: "generate"
|
|
115
164
|
asset: "image"|"video"|"voice"|"document"|"dashboard"
|
|
@@ -186,6 +235,7 @@ A template may only use fields asked and steps run EARLIER.`;
|
|
|
186
235
|
export const PRINCIPLES = `
|
|
187
236
|
How good wizards look:
|
|
188
237
|
- For non-technical end users. Plain words, friendly titles phrased as questions ("Wofür ist der Post?").
|
|
238
|
+
- A page whose fields depend on an earlier answer uses "when" on its fields, never one copy of the page per answer and a branch to each copy. Choices that depend on what an earlier step found use "optionsFrom", never one page per possible result.
|
|
189
239
|
- Few pages, 1–5 fields each, one theme per page. Only ask what the result really needs; prefer selects with sensible defaults.
|
|
190
240
|
- Put a review step before anything expensive (video) and before the final result, so people can correct or regenerate.
|
|
191
241
|
- Money/totals: use an "items" field with vat — the runner computes totals; documents must use {{items}} verbatim. Never let a model compute totals.
|
|
@@ -277,13 +327,16 @@ ${SCHEMA_DOC}
|
|
|
277
327
|
${PRINCIPLES}
|
|
278
328
|
${WIDGET_GUIDE}
|
|
279
329
|
|
|
330
|
+
Surface catalog (for surface steps and output.surface):
|
|
331
|
+
${SURFACE_GUIDE}
|
|
332
|
+
|
|
280
333
|
${mcpServersLine(mcp)}
|
|
281
334
|
|
|
282
335
|
${connectorsLine(connectors)}${pluginToolsLine(pluginTools)}
|
|
283
336
|
|
|
284
337
|
Models: a step names the KIND of model it needs, never a model. Pick the cheapest class that does the job:
|
|
285
338
|
- "classifier": routing, yes/no, picking from options, pulling a few values out of text.
|
|
286
|
-
A DECISION is a "classifier" step with no tools whose json output has only "yesno" and "
|
|
339
|
+
A DECISION is a "classifier" step with no tools whose json output has only "yesno", "choice" and "score" fields: it is answered in one fast, very cheap call. A branch can also read how sure it is: "steps.<id>.<field>.p" is the probability of its answer (a number 0–1; 1 or 0 where no decision model is set up), e.g. { field: "steps.check.urgent.p", op: "gt", value: 0.8 }. Write the question into each field's description, give the step everything it must judge in its instructions via {{templates}}, and branch on the answer with "next" (e.g. { when: { field: "steps.check.refund", op: "equals", value: true }, goto: "refund" }).
|
|
287
340
|
- "standard": short copy, calling an API, reformatting, simple summaries.
|
|
288
341
|
- "high": research with tools, long documents, reasoning over many sources (the default).
|
|
289
342
|
- "highest": hard reasoning or writing code; rare in a run, several times the cost of "high".
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createGuardedFetch } from "../engenty/connections-external/net/guarded-fetch.js";
|
|
2
2
|
import { githubConnector } from "../engenty/connections-github/connector.js";
|
|
3
3
|
import { googleConnectors } from "../engenty/connections-google/definitions.js";
|
|
4
|
+
import { GOOGLE_OAUTH2 } from "../engenty/connections-google/shared.js";
|
|
4
5
|
import { hubspotConnector } from "../engenty/connections-hubspot/connector.js";
|
|
5
6
|
import { microsoftOneDriveConnector } from "../engenty/connections-microsoft/onedrive.js";
|
|
6
7
|
import { microsoftOutlookConnector } from "../engenty/connections-microsoft/outlook.js";
|
|
@@ -8,9 +9,11 @@ import { s3Connector } from "../engenty/connections-s3/s3.js";
|
|
|
8
9
|
import { hasOAuth2ClientCredentials, } from "../engenty/connections-sdk/oauth2.js";
|
|
9
10
|
import { registerConnectorDefinition } from "../engenty/connections-sdk/registry.js";
|
|
10
11
|
import { slackConnector } from "../engenty/connections-slack/connector.js";
|
|
12
|
+
import { managed } from "../manage.js";
|
|
11
13
|
import { gmailConnector as mailGmail } from "./gmail.js";
|
|
12
14
|
import { imapConnector } from "./imap.js";
|
|
13
15
|
import { outlookConnector as mailOutlook } from "./outlook.js";
|
|
16
|
+
import { googleThroughAccount } from "./via-account.js";
|
|
14
17
|
/**
|
|
15
18
|
* engenty's own connectors, as engenty ships them: Gmail, Drive, Calendar, Contacts, Outlook,
|
|
16
19
|
* OneDrive, Slack, GitHub, HubSpot, S3. Every project has them; an OAuth one can be connected
|
|
@@ -53,7 +56,20 @@ export async function usable(connector) {
|
|
|
53
56
|
}
|
|
54
57
|
// A connector that registers its own OAuth client needs none configured.
|
|
55
58
|
return (connector.auth.oauth2.dynamicClientRegistration === true ||
|
|
56
|
-
hasOAuth2ClientCredentials(connector.auth.oauth2, resolveEnv))
|
|
59
|
+
(await hasOAuth2ClientCredentials(connector.auth.oauth2, resolveEnv)) ||
|
|
60
|
+
viaAccount(connector));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A Google connector on a local install without a Google client of its own: it connects through
|
|
64
|
+
* the Manage-App of the linked account, when that one offers it. Offered before an account is
|
|
65
|
+
* linked too; connecting then asks to link it first.
|
|
66
|
+
*/
|
|
67
|
+
export async function viaAccount(connector) {
|
|
68
|
+
return (!managed &&
|
|
69
|
+
connector.auth.kind === "oauth2" &&
|
|
70
|
+
connector.auth.oauth2 === GOOGLE_OAUTH2 &&
|
|
71
|
+
!(await hasOAuth2ClientCredentials(connector.auth.oauth2, resolveEnv)) &&
|
|
72
|
+
(await googleThroughAccount()));
|
|
57
73
|
}
|
|
58
74
|
/** What the server's owner has to set for an OAuth connector that is not usable yet. */
|
|
59
75
|
export function missingSetup(connector) {
|
|
@@ -5,8 +5,9 @@ import { scopesForGroups, } from "../engenty/connections-sdk/types.js";
|
|
|
5
5
|
import { env } from "../env.js";
|
|
6
6
|
import { seal, unseal } from "../secrets/crypto.js";
|
|
7
7
|
import { deleteSecret, getSecret, putSecret } from "../store/index.js";
|
|
8
|
-
import { connectorFetch, MAIL_CONNECTORS, resolveEnv, usable } from "./builtin.js";
|
|
8
|
+
import { connectorFetch, MAIL_CONNECTORS, resolveEnv, usable, viaAccount } from "./builtin.js";
|
|
9
9
|
import { resolveConnector } from "./external.js";
|
|
10
|
+
import { AccountConnectError, redeemTicket, refreshThroughAccount, startThroughAccount, } from "./via-account.js";
|
|
10
11
|
/** The connectors a person can pick for each kind of connection. */
|
|
11
12
|
const CONNECTORS = { mail: MAIL_CONNECTORS };
|
|
12
13
|
const HINTS = {
|
|
@@ -95,6 +96,24 @@ export async function startOAuth(scope, projectId, runId, connection, connectorI
|
|
|
95
96
|
if (connector.auth.kind !== "oauth2") {
|
|
96
97
|
throw new Error(`${connector.name} is not connected by signing in.`);
|
|
97
98
|
}
|
|
99
|
+
if (await viaAccount(connector)) {
|
|
100
|
+
// The Manage-App keeps the PKCE verifier; ours stays empty.
|
|
101
|
+
const state = {
|
|
102
|
+
scope,
|
|
103
|
+
projectId,
|
|
104
|
+
runId,
|
|
105
|
+
connectionId: connection.id,
|
|
106
|
+
connectorId,
|
|
107
|
+
verifier: "",
|
|
108
|
+
exp: Date.now() + 15 * 60_000,
|
|
109
|
+
};
|
|
110
|
+
try {
|
|
111
|
+
return await startThroughAccount(scopesFor(connection, connector), seal(state), connectRedirectUri());
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
throw err instanceof AccountConnectError ? new ConnectError(err.message) : err;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
98
117
|
// Servers that hand out OAuth clients on request (most MCP servers): get one on first use.
|
|
99
118
|
await connector.auth.oauth2.registerClient?.();
|
|
100
119
|
const pkce = createOAuth2Pkce();
|
|
@@ -120,7 +139,11 @@ export async function startOAuth(scope, projectId, runId, connection, connectorI
|
|
|
120
139
|
export function oauthStateRun(rawState) {
|
|
121
140
|
return unseal(rawState)?.runId ?? null;
|
|
122
141
|
}
|
|
123
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Google came back: with a code, or — connected through the account's Manage-App — with a
|
|
144
|
+
* ticket that holds the tokens.
|
|
145
|
+
*/
|
|
146
|
+
export async function finishOAuth(answer, rawState) {
|
|
124
147
|
const state = unseal(rawState);
|
|
125
148
|
if (!state || state.exp < Date.now()) {
|
|
126
149
|
throw new Error("Die Anmeldung ist abgelaufen. Bitte noch einmal verbinden.");
|
|
@@ -134,13 +157,15 @@ export async function finishOAuth(code, rawState) {
|
|
|
134
157
|
if (connector.auth.kind !== "oauth2") {
|
|
135
158
|
throw new Error("not an oauth connector");
|
|
136
159
|
}
|
|
137
|
-
const tokens =
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
160
|
+
const tokens = "ticket" in answer
|
|
161
|
+
? await redeemTicket(answer.ticket)
|
|
162
|
+
: await exchangeAuthorizationCode({
|
|
163
|
+
code: answer.code,
|
|
164
|
+
codeVerifier: state.verifier,
|
|
165
|
+
config: connector.auth.oauth2,
|
|
166
|
+
redirectUri: connectRedirectUri(),
|
|
167
|
+
resolveEnv,
|
|
168
|
+
});
|
|
144
169
|
// A provider that will not name the account still connected it.
|
|
145
170
|
const account = (await connector.auth.oauth2
|
|
146
171
|
.resolveAccount?.(tokens.accessToken, fetch)
|
|
@@ -150,6 +175,7 @@ export async function finishOAuth(code, rawState) {
|
|
|
150
175
|
refreshToken: tokens.refreshToken,
|
|
151
176
|
expiresAt: tokens.expiresAt?.toISOString() ?? null,
|
|
152
177
|
scopes: tokens.grantedScopes,
|
|
178
|
+
...("ticket" in answer ? { via: "account" } : {}),
|
|
153
179
|
};
|
|
154
180
|
await putSecret(state.scope, slot(connection.id), connector.id, account.label, stored);
|
|
155
181
|
return state;
|
|
@@ -227,12 +253,15 @@ export async function connectionContext(scope, connection, projectId) {
|
|
|
227
253
|
data.refreshToken &&
|
|
228
254
|
expires !== null &&
|
|
229
255
|
expires - Date.now() < 5 * 60_000) {
|
|
230
|
-
const tokens =
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
256
|
+
const tokens = data.via === "account"
|
|
257
|
+
? await refreshThroughAccount(data.refreshToken)
|
|
258
|
+
: await refreshAccessToken({
|
|
259
|
+
config: connector.auth.oauth2,
|
|
260
|
+
refreshToken: data.refreshToken,
|
|
261
|
+
resolveEnv,
|
|
262
|
+
});
|
|
235
263
|
data = {
|
|
264
|
+
...(data.via ? { via: data.via } : {}),
|
|
236
265
|
accessToken: tokens.accessToken,
|
|
237
266
|
refreshToken: tokens.refreshToken ?? data.refreshToken,
|
|
238
267
|
expiresAt: tokens.expiresAt?.toISOString() ?? null,
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { and, desc, eq } from "drizzle-orm";
|
|
2
|
+
import { nanoid } from "nanoid";
|
|
3
|
+
import { db, schema } from "../db/client.js";
|
|
4
|
+
import { buildAuthorizationUrl, createOAuth2Pkce, exchangeAuthorizationCode, refreshAccessToken, } from "../engenty/connections-sdk/oauth2.js";
|
|
5
|
+
import { seal, unseal } from "../secrets/crypto.js";
|
|
6
|
+
import { currentTenant } from "../tenants/tenant.js";
|
|
7
|
+
import { builtinConnector, connectorFetch, resolveEnv, usable, viaAccount } from "./builtin.js";
|
|
8
|
+
import { connectRedirectUri as redirectUri } from "./index.js";
|
|
9
|
+
import { AccountConnectError, redeemTicket, refreshThroughAccount, startThroughAccount, } from "./via-account.js";
|
|
10
|
+
function oauthConnector(id) {
|
|
11
|
+
const connector = builtinConnector(id);
|
|
12
|
+
if (connector?.auth.kind !== "oauth2") {
|
|
13
|
+
throw new Error(`"${id}" is not a built-in connector that signs in.`);
|
|
14
|
+
}
|
|
15
|
+
return connector;
|
|
16
|
+
}
|
|
17
|
+
/** The provider rights the named actions need, and the connector's own base rights. */
|
|
18
|
+
function scopesOf(connector, actions) {
|
|
19
|
+
const scopes = new Set(connector.auth.kind === "oauth2" ? connector.auth.oauth2.baseScopes : []);
|
|
20
|
+
for (const id of actions) {
|
|
21
|
+
const action = connector.actions.find((a) => a.id === id);
|
|
22
|
+
if (!action) {
|
|
23
|
+
throw new Error(`${connector.name} has no action "${id}".`);
|
|
24
|
+
}
|
|
25
|
+
for (const scope of action.providerScopes ?? []) {
|
|
26
|
+
scopes.add(scope);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return [...scopes];
|
|
30
|
+
}
|
|
31
|
+
const view = (row) => ({
|
|
32
|
+
id: row.id,
|
|
33
|
+
space: row.projectId,
|
|
34
|
+
connector: row.connector,
|
|
35
|
+
label: row.label,
|
|
36
|
+
actions: row.actions,
|
|
37
|
+
createdAt: row.createdAt.toISOString(),
|
|
38
|
+
});
|
|
39
|
+
/** A state the callback can tell is a plugin's: the tenant to finish it in, or null. */
|
|
40
|
+
export function pluginStateTenant(rawState) {
|
|
41
|
+
const state = unseal(rawState);
|
|
42
|
+
return state && typeof state.plugin === "string" ? state.tenantId : null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The provider came back for a plugin's sign-in: keeps the account. A second sign-in to the
|
|
46
|
+
* same account for the same space replaces the first one's tokens.
|
|
47
|
+
*/
|
|
48
|
+
export async function finishPluginOAuth(answer, rawState) {
|
|
49
|
+
const state = unseal(rawState);
|
|
50
|
+
if (!state?.plugin || state.exp < Date.now()) {
|
|
51
|
+
throw new Error("Die Anmeldung ist abgelaufen. Bitte noch einmal verbinden.");
|
|
52
|
+
}
|
|
53
|
+
const connector = oauthConnector(state.connectorId);
|
|
54
|
+
if (connector.auth.kind !== "oauth2") {
|
|
55
|
+
throw new Error("not an oauth connector");
|
|
56
|
+
}
|
|
57
|
+
const tokens = "ticket" in answer
|
|
58
|
+
? await redeemTicket(answer.ticket)
|
|
59
|
+
: await exchangeAuthorizationCode({
|
|
60
|
+
code: answer.code,
|
|
61
|
+
codeVerifier: state.verifier,
|
|
62
|
+
config: connector.auth.oauth2,
|
|
63
|
+
redirectUri: redirectUri(),
|
|
64
|
+
resolveEnv,
|
|
65
|
+
});
|
|
66
|
+
const account = (await connector.auth.oauth2
|
|
67
|
+
.resolveAccount?.(tokens.accessToken, fetch)
|
|
68
|
+
.catch(() => null)) ?? { label: connector.name };
|
|
69
|
+
const stored = {
|
|
70
|
+
accessToken: tokens.accessToken,
|
|
71
|
+
refreshToken: tokens.refreshToken,
|
|
72
|
+
expiresAt: tokens.expiresAt?.toISOString() ?? null,
|
|
73
|
+
scopes: tokens.grantedScopes,
|
|
74
|
+
...("ticket" in answer ? { via: "account" } : {}),
|
|
75
|
+
};
|
|
76
|
+
const same = await db.query.pluginConnection.findFirst({
|
|
77
|
+
where: and(eq(schema.pluginConnection.plugin, state.plugin), eq(schema.pluginConnection.projectId, state.projectId), eq(schema.pluginConnection.connector, connector.id), eq(schema.pluginConnection.label, account.label)),
|
|
78
|
+
});
|
|
79
|
+
if (same) {
|
|
80
|
+
await db
|
|
81
|
+
.update(schema.pluginConnection)
|
|
82
|
+
.set({ actions: state.actions, data: seal(stored), updatedAt: new Date() })
|
|
83
|
+
.where(eq(schema.pluginConnection.id, same.id));
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
await db.insert(schema.pluginConnection).values({
|
|
87
|
+
id: nanoid(14),
|
|
88
|
+
plugin: state.plugin,
|
|
89
|
+
projectId: state.projectId,
|
|
90
|
+
connector: connector.id,
|
|
91
|
+
label: account.label,
|
|
92
|
+
actions: state.actions,
|
|
93
|
+
data: seal(stored),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return { plugin: state.plugin, projectId: state.projectId };
|
|
97
|
+
}
|
|
98
|
+
/** The tokens of a connection, refreshed when they run out within five minutes. */
|
|
99
|
+
async function freshTokens(row, connector) {
|
|
100
|
+
let data = unseal(row.data);
|
|
101
|
+
if (!data) {
|
|
102
|
+
throw new Error("The connection's keys cannot be read here. Connect the account again.");
|
|
103
|
+
}
|
|
104
|
+
const expires = data.expiresAt ? Date.parse(data.expiresAt) : null;
|
|
105
|
+
if (connector.auth.kind === "oauth2" &&
|
|
106
|
+
data.refreshToken &&
|
|
107
|
+
expires !== null &&
|
|
108
|
+
expires - Date.now() < 5 * 60_000) {
|
|
109
|
+
const tokens = data.via === "account"
|
|
110
|
+
? await refreshThroughAccount(data.refreshToken)
|
|
111
|
+
: await refreshAccessToken({
|
|
112
|
+
config: connector.auth.oauth2,
|
|
113
|
+
refreshToken: data.refreshToken,
|
|
114
|
+
resolveEnv,
|
|
115
|
+
});
|
|
116
|
+
data = {
|
|
117
|
+
...(data.via ? { via: data.via } : {}),
|
|
118
|
+
accessToken: tokens.accessToken,
|
|
119
|
+
refreshToken: tokens.refreshToken ?? data.refreshToken,
|
|
120
|
+
expiresAt: tokens.expiresAt?.toISOString() ?? null,
|
|
121
|
+
scopes: tokens.grantedScopes.length ? tokens.grantedScopes : data.scopes,
|
|
122
|
+
};
|
|
123
|
+
await db
|
|
124
|
+
.update(schema.pluginConnection)
|
|
125
|
+
.set({ data: seal(data), updatedAt: new Date() })
|
|
126
|
+
.where(eq(schema.pluginConnection.id, row.id));
|
|
127
|
+
}
|
|
128
|
+
return data;
|
|
129
|
+
}
|
|
130
|
+
function contextOf(row, connector, data) {
|
|
131
|
+
const ctx = {
|
|
132
|
+
accessToken: data.accessToken,
|
|
133
|
+
fetchImpl: connectorFetch,
|
|
134
|
+
log: () => undefined,
|
|
135
|
+
connection: {
|
|
136
|
+
id: row.id,
|
|
137
|
+
connector_id: connector.id,
|
|
138
|
+
auth_kind: connector.auth.kind,
|
|
139
|
+
autonomous_mode: "read_only",
|
|
140
|
+
connected_by: row.createdBy,
|
|
141
|
+
created_at: row.createdAt.toISOString(),
|
|
142
|
+
display_name: row.label,
|
|
143
|
+
error_message: null,
|
|
144
|
+
external_account: row.label,
|
|
145
|
+
granted_scopes: data.scopes,
|
|
146
|
+
owner_user_id: null,
|
|
147
|
+
space_id: row.projectId,
|
|
148
|
+
status: "active",
|
|
149
|
+
tenant_id: currentTenant(),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
return ctx;
|
|
153
|
+
}
|
|
154
|
+
/** `server.connections` of one plugin; every call runs inside the current tenant. */
|
|
155
|
+
export function pluginConnections(plugin) {
|
|
156
|
+
const own = (id) => db.query.pluginConnection.findFirst({
|
|
157
|
+
where: and(eq(schema.pluginConnection.id, id), eq(schema.pluginConnection.plugin, plugin)),
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
async available(ids) {
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const id of ids) {
|
|
163
|
+
const connector = builtinConnector(id);
|
|
164
|
+
if (connector?.auth.kind === "oauth2" && (await usable(connector))) {
|
|
165
|
+
out.push({ id, name: connector.name });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
},
|
|
170
|
+
async start({ space, connector: connectorId, actions }) {
|
|
171
|
+
const connector = oauthConnector(connectorId);
|
|
172
|
+
if (connector.auth.kind !== "oauth2") {
|
|
173
|
+
throw new Error("not an oauth connector");
|
|
174
|
+
}
|
|
175
|
+
const project = await db.query.project.findFirst({
|
|
176
|
+
where: eq(schema.project.id, space),
|
|
177
|
+
columns: { id: true },
|
|
178
|
+
});
|
|
179
|
+
if (!project) {
|
|
180
|
+
throw new Error(`There is no space "${space}".`);
|
|
181
|
+
}
|
|
182
|
+
const scopes = scopesOf(connector, actions);
|
|
183
|
+
const base = {
|
|
184
|
+
plugin,
|
|
185
|
+
tenantId: currentTenant(),
|
|
186
|
+
projectId: space,
|
|
187
|
+
connectorId,
|
|
188
|
+
actions: [...new Set(actions)],
|
|
189
|
+
exp: Date.now() + 15 * 60_000,
|
|
190
|
+
};
|
|
191
|
+
if (await viaAccount(connector)) {
|
|
192
|
+
// The Manage-App keeps the PKCE verifier; ours stays empty.
|
|
193
|
+
try {
|
|
194
|
+
const url = await startThroughAccount(scopes, seal({ ...base, verifier: "" }), redirectUri());
|
|
195
|
+
return { url };
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
throw err instanceof AccountConnectError
|
|
199
|
+
? Object.assign(new Error(err.message), { status: 409, code: "account" })
|
|
200
|
+
: err;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
await connector.auth.oauth2.registerClient?.();
|
|
204
|
+
const pkce = createOAuth2Pkce();
|
|
205
|
+
const url = await buildAuthorizationUrl({
|
|
206
|
+
connector,
|
|
207
|
+
pkce,
|
|
208
|
+
redirectUri: redirectUri(),
|
|
209
|
+
scopes,
|
|
210
|
+
state: seal({ ...base, verifier: pkce.codeVerifier }),
|
|
211
|
+
resolveEnv,
|
|
212
|
+
});
|
|
213
|
+
return { url };
|
|
214
|
+
},
|
|
215
|
+
async list(space) {
|
|
216
|
+
const rows = await db.query.pluginConnection.findMany({
|
|
217
|
+
where: and(eq(schema.pluginConnection.plugin, plugin), eq(schema.pluginConnection.projectId, space)),
|
|
218
|
+
orderBy: [desc(schema.pluginConnection.createdAt)],
|
|
219
|
+
});
|
|
220
|
+
return rows.map(view);
|
|
221
|
+
},
|
|
222
|
+
async get(id) {
|
|
223
|
+
const row = await own(id);
|
|
224
|
+
return row ? view(row) : null;
|
|
225
|
+
},
|
|
226
|
+
async call(id, actionId, input) {
|
|
227
|
+
const row = await own(id);
|
|
228
|
+
if (!row) {
|
|
229
|
+
throw new Error(`There is no connection "${id}".`);
|
|
230
|
+
}
|
|
231
|
+
if (!row.actions.includes(actionId)) {
|
|
232
|
+
throw new Error(`The connection was not made for "${actionId}". Connect it again.`);
|
|
233
|
+
}
|
|
234
|
+
const connector = oauthConnector(row.connector);
|
|
235
|
+
const action = connector.actions.find((a) => a.id === actionId);
|
|
236
|
+
if (!action) {
|
|
237
|
+
throw new Error(`${connector.name} has no action "${actionId}".`);
|
|
238
|
+
}
|
|
239
|
+
const data = await freshTokens(row, connector);
|
|
240
|
+
return (await action.handler(action.inputSchema.parse(input), contextOf(row, connector, data)));
|
|
241
|
+
},
|
|
242
|
+
async remove(id) {
|
|
243
|
+
await db
|
|
244
|
+
.delete(schema.pluginConnection)
|
|
245
|
+
.where(and(eq(schema.pluginConnection.id, id), eq(schema.pluginConnection.plugin, plugin)));
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { accountToken } from "../auth/account.js";
|
|
2
|
+
import { env } from "../env.js";
|
|
3
|
+
/*
|
|
4
|
+
* Google accounts on a local install without an OAuth client of its own: the Manage-App of the
|
|
5
|
+
* linked account runs the consent at Google with its client and hands the tokens over, and
|
|
6
|
+
* refreshes them later (docs/manage-contract.md, "Google accounts through the Manage-App").
|
|
7
|
+
*/
|
|
8
|
+
const base = () => `${env.local.accountUrl}/v1/connect`;
|
|
9
|
+
/** Something the person can mend or retry; the message is for them. */
|
|
10
|
+
export class AccountConnectError extends Error {
|
|
11
|
+
}
|
|
12
|
+
let offered = null;
|
|
13
|
+
/** Whether the Manage-App connects Google accounts; asked at most every ten minutes. */
|
|
14
|
+
export async function googleThroughAccount() {
|
|
15
|
+
if (offered && Date.now() - offered.at < (offered.google ? 600_000 : 60_000)) {
|
|
16
|
+
return offered.google;
|
|
17
|
+
}
|
|
18
|
+
const google = await fetch(base(), { signal: AbortSignal.timeout(5000) })
|
|
19
|
+
.then(async (res) => res.ok ? (await res.json()).google === true : false)
|
|
20
|
+
.catch(() => false);
|
|
21
|
+
offered = { at: Date.now(), google };
|
|
22
|
+
return google;
|
|
23
|
+
}
|
|
24
|
+
async function call(path, body) {
|
|
25
|
+
const token = await accountToken();
|
|
26
|
+
if (!token) {
|
|
27
|
+
throw new AccountConnectError("Verbinde zuerst dein engenty-Konto (Einstellungen → Konto), dann lässt sich Google hier verbinden.");
|
|
28
|
+
}
|
|
29
|
+
const res = await fetch(`${base()}${path}`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
32
|
+
body: JSON.stringify(body),
|
|
33
|
+
signal: AbortSignal.timeout(20_000),
|
|
34
|
+
}).catch(() => null);
|
|
35
|
+
if (!res) {
|
|
36
|
+
throw new AccountConnectError("Dein engenty-Konto ist gerade nicht erreichbar. Bitte später noch einmal.");
|
|
37
|
+
}
|
|
38
|
+
const data = (await res.json().catch(() => ({})));
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
throw new AccountConnectError(data.code === "invalid_grant"
|
|
41
|
+
? "Google nimmt diese Verbindung nicht mehr an. Bitte das Konto neu verbinden."
|
|
42
|
+
: data.code === "not_configured"
|
|
43
|
+
? "Google lässt sich über dein engenty-Konto gerade nicht verbinden."
|
|
44
|
+
: `Verbinden über dein engenty-Konto hat nicht geklappt (${data.code ?? res.status}).`);
|
|
45
|
+
}
|
|
46
|
+
return data;
|
|
47
|
+
}
|
|
48
|
+
const tokensOf = (t) => ({
|
|
49
|
+
accessToken: t.access_token,
|
|
50
|
+
refreshToken: t.refresh_token ?? null,
|
|
51
|
+
expiresAt: t.expires_in ? new Date(Date.now() + t.expires_in * 1000) : null,
|
|
52
|
+
grantedScopes: (t.scope ?? "").split(/\s+/).filter(Boolean),
|
|
53
|
+
});
|
|
54
|
+
/** Google's consent page for these scopes; Google comes back to `returnUrl` with a ticket. */
|
|
55
|
+
export async function startThroughAccount(scopes, state, returnUrl) {
|
|
56
|
+
return (await call("/google/start", { scopes, state, returnUrl })).url;
|
|
57
|
+
}
|
|
58
|
+
export async function redeemTicket(ticket) {
|
|
59
|
+
return tokensOf(await call("/google/redeem", { ticket }));
|
|
60
|
+
}
|
|
61
|
+
export async function refreshThroughAccount(refreshToken) {
|
|
62
|
+
return tokensOf(await call("/google/refresh", { refreshToken }));
|
|
63
|
+
}
|