@formio/uag 1.13.2 → 2.0.0-rc.10
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/README.md +237 -535
- package/bin/uag-eval.js +99 -0
- package/bin/uag.js +116 -0
- package/lib/UAGFormInterface.d.ts +72 -2
- package/lib/UAGFormInterface.js +226 -57
- package/lib/UAGProjectInterface.d.ts +45 -5
- package/lib/UAGProjectInterface.js +187 -36
- package/lib/actionLogs.d.ts +24 -0
- package/lib/actionLogs.js +149 -0
- package/lib/actions/runTask.d.ts +43 -0
- package/lib/actions/runTask.js +324 -0
- package/lib/agent/anonymous.d.ts +34 -0
- package/lib/agent/anonymous.js +99 -0
- package/lib/agent/chat.d.ts +114 -0
- package/lib/agent/chat.js +475 -0
- package/lib/agent/engines.d.ts +17 -0
- package/lib/agent/engines.js +46 -0
- package/lib/agent/errors.d.ts +25 -0
- package/lib/agent/errors.js +132 -0
- package/lib/agent/index.d.ts +4 -0
- package/lib/agent/index.js +19 -0
- package/lib/agent/remote.d.ts +71 -0
- package/lib/agent/remote.js +252 -0
- package/lib/agent/requestId.d.ts +4 -0
- package/lib/agent/requestId.js +19 -0
- package/lib/agent/router.d.ts +61 -0
- package/lib/agent/router.js +307 -0
- package/lib/agent/stubs.d.ts +37 -0
- package/lib/agent/stubs.js +39 -0
- package/lib/agent/tools.d.ts +31 -0
- package/lib/agent/tools.js +76 -0
- package/lib/config.d.ts +97 -3
- package/lib/config.js +19 -0
- package/lib/database.d.ts +70 -0
- package/lib/database.js +212 -0
- package/lib/documents/convert.d.ts +38 -0
- package/lib/documents/convert.js +151 -0
- package/lib/documents/converters.d.ts +28 -0
- package/lib/documents/converters.js +149 -0
- package/lib/documents/detect.d.ts +31 -0
- package/lib/documents/detect.js +169 -0
- package/lib/documents/index.d.ts +9 -0
- package/lib/documents/index.js +43 -0
- package/lib/documents/mime.d.ts +39 -0
- package/lib/documents/mime.js +100 -0
- package/lib/documents/plugins.d.ts +18 -0
- package/lib/documents/plugins.js +65 -0
- package/lib/documents/readers.d.ts +19 -0
- package/lib/documents/readers.js +64 -0
- package/lib/documents/routes.d.ts +52 -0
- package/lib/documents/routes.js +99 -0
- package/lib/documents/types.d.ts +126 -0
- package/lib/documents/types.js +2 -0
- package/lib/documents/uagJs.d.ts +17 -0
- package/lib/documents/uagJs.js +157 -0
- package/lib/formflow.d.ts +47 -0
- package/lib/formflow.js +28 -0
- package/lib/identity.d.ts +53 -0
- package/lib/identity.js +107 -0
- package/lib/index.d.ts +98 -1
- package/lib/index.js +382 -8
- package/lib/markdown.d.ts +1 -0
- package/lib/markdown.js +166 -0
- package/lib/modules/api.d.ts +25 -0
- package/lib/modules/api.js +98 -0
- package/lib/modules/index.d.ts +4 -0
- package/lib/modules/index.js +24 -0
- package/lib/modules/routes.d.ts +14 -0
- package/lib/modules/routes.js +21 -0
- package/lib/modules/types.d.ts +68 -0
- package/lib/modules/types.js +5 -0
- package/lib/pluginFiles.d.ts +28 -0
- package/lib/pluginFiles.js +101 -0
- package/lib/plugins.d.ts +17 -0
- package/lib/plugins.js +93 -0
- package/lib/richText.d.ts +20 -0
- package/lib/richText.js +99 -0
- package/lib/router.d.ts +9 -0
- package/lib/router.js +34 -25
- package/lib/runner.d.ts +149 -0
- package/lib/runner.js +353 -0
- package/lib/runs.d.ts +27 -0
- package/lib/runs.js +13 -0
- package/lib/template.js +7 -9
- package/lib/templates/uagComponentNotFound.md +5 -5
- package/lib/testing.d.ts +74 -0
- package/lib/testing.js +180 -0
- package/lib/tools/SchemaBuilder.d.ts +3 -3
- package/lib/tools/SchemaBuilder.js +4 -4
- package/lib/tools/agentDecide.d.ts +8 -0
- package/lib/tools/agentDecide.js +57 -0
- package/lib/tools/agentProvide.d.ts +8 -0
- package/lib/tools/agentProvide.js +97 -0
- package/lib/tools/collectData.js +3 -2
- package/lib/tools/confirmSubmission.js +1 -1
- package/lib/tools/fetchExternalData.js +8 -17
- package/lib/tools/findSubmission.js +62 -56
- package/lib/tools/getFieldInfo.js +1 -1
- package/lib/tools/getFormFields.js +1 -1
- package/lib/tools/getForms.js +1 -1
- package/lib/tools/index.d.ts +4 -1
- package/lib/tools/index.js +21 -14
- package/lib/tools/readDocument.d.ts +17 -0
- package/lib/tools/readDocument.js +99 -0
- package/lib/tools/submissionUpdate.js +4 -3
- package/lib/tools/submitForm.js +1 -1
- package/lib/tools/utils.d.ts +40 -0
- package/lib/tools/utils.js +16 -1
- package/lib/version.d.ts +2 -0
- package/lib/version.js +5 -0
- package/package.json +49 -19
- package/testing.d.ts +1 -0
- package/testing.js +2 -0
- package/lib/tools/agentProvideData.d.ts +0 -3
- package/lib/tools/agentProvideData.js +0 -62
|
@@ -1,21 +1,36 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.UAGProjectInterface = void 0;
|
|
4
7
|
const appserver_1 = require("@formio/appserver");
|
|
8
|
+
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
9
|
+
const formflow_1 = require("./formflow");
|
|
10
|
+
const runner_1 = require("./runner");
|
|
5
11
|
const express_1 = require("express");
|
|
6
|
-
const tools_1 = require("./tools");
|
|
7
12
|
const router_1 = require("./router");
|
|
8
13
|
const template_1 = require("./template");
|
|
9
14
|
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
15
|
+
const version_1 = require("./version");
|
|
16
|
+
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
17
|
+
const router_2 = require("./agent/router");
|
|
18
|
+
const readers_1 = require("./documents/readers");
|
|
19
|
+
const tools_1 = require("./agent/tools");
|
|
10
20
|
const debug = require('debug')('formio:uag:UAGProjectInterface');
|
|
11
21
|
class UAGProjectInterface extends appserver_1.ProjectInterface {
|
|
22
|
+
/** The names of the UAG forms the project currently has. */
|
|
23
|
+
get formNames() {
|
|
24
|
+
return Object.values(this.forms).map((form) => form.uag?.name).filter(Boolean);
|
|
25
|
+
}
|
|
12
26
|
get config() { return appserver_1.ProjectInterface.module?.config || {}; }
|
|
13
27
|
constructor(endpoint) {
|
|
14
28
|
super(endpoint);
|
|
15
29
|
this.user = null;
|
|
16
|
-
this.formNames = [];
|
|
17
30
|
this.uagTemplate = null;
|
|
18
|
-
|
|
31
|
+
/** Engines enabled at startup; empty until UAGServer.router() resolves them. */
|
|
32
|
+
this.engines = new formflow_1.EngineRegistry();
|
|
33
|
+
this.mcpServer = new mcp_js_1.McpServer({ name: 'formio-uag', version: version_1.UAG_VERSION });
|
|
19
34
|
}
|
|
20
35
|
async initialize() {
|
|
21
36
|
await super.initialize();
|
|
@@ -24,27 +39,40 @@ class UAGProjectInterface extends appserver_1.ProjectInterface {
|
|
|
24
39
|
}
|
|
25
40
|
// Build a fresh McpServer with all tools registered. A new instance is created
|
|
26
41
|
// per request so concurrent requests never share (and overwrite) a transport.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
42
|
+
// A scoped token narrows the tools to its own list, and only ever to tools
|
|
43
|
+
// annotated read-only: no model may write through /mcp with one.
|
|
44
|
+
async buildMcpServer(scope) {
|
|
45
|
+
const server = new mcp_js_1.McpServer({ name: 'formio-uag', version: version_1.UAG_VERSION });
|
|
46
|
+
// The standard UAG tools, plus the custom tools from config.
|
|
47
|
+
let tools = await (0, tools_1.allTools)(this);
|
|
48
|
+
if (scope) {
|
|
49
|
+
tools = (0, formflow_1.toolsForScope)(tools, scope).filter((tool) => tool.annotations?.readOnlyHint === true);
|
|
34
50
|
}
|
|
35
|
-
//
|
|
51
|
+
// Register all the tools (allTools() keeps only the named ones).
|
|
36
52
|
for (const tool of tools) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
server.registerTool(tool.name, {
|
|
54
|
+
title: tool.title,
|
|
55
|
+
description: tool.description,
|
|
56
|
+
// Always a schema, even an empty one: the MCP SDK calls a tool
|
|
57
|
+
// registered without one as `execute(extra)`, and every tool is
|
|
58
|
+
// `execute(args, extra)`, as Agent API research calls it.
|
|
59
|
+
inputSchema: tool.inputSchema || {},
|
|
60
|
+
// Carries readOnly/destructive/idempotent/openWorld through to the
|
|
61
|
+
// client. Tools from config.tools may omit them, so this stays
|
|
62
|
+
// conditional rather than sending `annotations: undefined`.
|
|
63
|
+
...(tool.annotations && { annotations: tool.annotations })
|
|
64
|
+
}, (args, extra) => (0, tools_1.runTool)(tool, this, args, extra));
|
|
65
|
+
}
|
|
66
|
+
// A scoped token that allows no tools must still answer tools/list, with
|
|
67
|
+
// an empty list: clients list tools first,
|
|
68
|
+
// and a server with nothing registered would not offer tools at all.
|
|
69
|
+
if (!tools.length) {
|
|
70
|
+
server.server.registerCapabilities({ tools: {} });
|
|
71
|
+
server.server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({ tools: [] }));
|
|
72
|
+
server.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => ({
|
|
73
|
+
content: [{ type: 'text', text: `The tool "${request.params.name}" is not available to this token.` }],
|
|
74
|
+
isError: true
|
|
75
|
+
}));
|
|
48
76
|
}
|
|
49
77
|
return server;
|
|
50
78
|
}
|
|
@@ -52,32 +80,92 @@ class UAGProjectInterface extends appserver_1.ProjectInterface {
|
|
|
52
80
|
const formInterface = super.addForm(form, machineName);
|
|
53
81
|
if (!formInterface)
|
|
54
82
|
return;
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
debug(`Registering UAG form: ${machineName}`);
|
|
58
|
-
formInterface.uag = {
|
|
59
|
-
machineName: machineName,
|
|
60
|
-
name: form.name || form.path || machineName,
|
|
61
|
-
title: form.title || form.name || machineName,
|
|
62
|
-
description: form.properties?.description || `A form to submit new ${form.title} records.`
|
|
63
|
-
};
|
|
64
|
-
this.formNames.push(formInterface.uag.name);
|
|
83
|
+
// The registration itself is derived from the form by UAGFormInterface.setForm().
|
|
84
|
+
if (formInterface.uag) {
|
|
85
|
+
debug(`Registering UAG form: ${formInterface.uag.machineName}`);
|
|
65
86
|
}
|
|
66
87
|
return formInterface;
|
|
67
88
|
}
|
|
68
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The UAG's routes: `/mcp`, and unless excluded, `/agent/capabilities`.
|
|
91
|
+
* UAGServer mounts capabilities separately, ahead of the project router. A
|
|
92
|
+
* form's agent operations are on the form's own routes (UAGFormInterface).
|
|
93
|
+
*/
|
|
94
|
+
uagRouter(includeAgentApi = true) {
|
|
69
95
|
const router = (0, express_1.Router)();
|
|
70
96
|
const uagRouter = (0, router_1.UAGRouter)(this);
|
|
71
97
|
router.use('/mcp', (req, res, next) => this.authorizeRequest(req, res, next), uagRouter);
|
|
98
|
+
if (includeAgentApi) {
|
|
99
|
+
router.use(this.agentRouter());
|
|
100
|
+
}
|
|
72
101
|
return router;
|
|
73
102
|
}
|
|
103
|
+
/** `/agent/capabilities`, behind its own authentication. */
|
|
104
|
+
agentRouter() {
|
|
105
|
+
return (0, router_2.AgentRouter)(this, (0, formflow_1.formflowAuthenticate)(this));
|
|
106
|
+
}
|
|
107
|
+
/** Whether runs after the save are recorded and survive a restart (the UAG's database). */
|
|
108
|
+
durableRuns() {
|
|
109
|
+
return (0, runner_1.taskRunner)(this).durable;
|
|
110
|
+
}
|
|
111
|
+
/** The engines enabled for Agentic Formflow. */
|
|
112
|
+
formflowEngines() {
|
|
113
|
+
return this.engines;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Agentic Formflow's routing, Agent API and auth settings. The engine
|
|
117
|
+
* settings stay here: the UAG loads the engines, not core.
|
|
118
|
+
*/
|
|
119
|
+
formflowConfig() {
|
|
120
|
+
const { engines, engineTypes, ...config } = this.config || {};
|
|
121
|
+
return config;
|
|
122
|
+
}
|
|
123
|
+
/** A form tagged "uag", or null. */
|
|
124
|
+
async formflowForm(name) {
|
|
125
|
+
const form = await this.getForm(name);
|
|
126
|
+
return form?.uag ? form : null;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Readers for uploaded documents: one built from the installed converters,
|
|
130
|
+
* which reads every type with a route to text.
|
|
131
|
+
*/
|
|
132
|
+
formflowFileReaders() {
|
|
133
|
+
return this.plugins ? [(0, readers_1.converterFileReader)(this.plugins)] : [];
|
|
134
|
+
}
|
|
135
|
+
/** Every MCP tool, as Agentic Formflow tools. */
|
|
136
|
+
async formflowTools() {
|
|
137
|
+
return (await (0, tools_1.allTools)(this)).map((tool) => (0, tools_1.formflowTool)(tool, this));
|
|
138
|
+
}
|
|
74
139
|
async authorizeRequest(req, res, next) {
|
|
75
140
|
try {
|
|
141
|
+
let authorization = req.get('authorization');
|
|
142
|
+
// A scoped token carries the identity it was issued for (the caller's
|
|
143
|
+
// own token, or admin) and limits the tools /mcp offers.
|
|
144
|
+
const bearer = (0, formflow_1.bearerOf)(authorization);
|
|
145
|
+
const scoped = bearer ? (0, formflow_1.readScopedToken)(this, bearer) : null;
|
|
146
|
+
if (scoped) {
|
|
147
|
+
req.uagScope = scoped;
|
|
148
|
+
if (scoped.admin) {
|
|
149
|
+
req.auth = new appserver_1.AuthRequest(this, true);
|
|
150
|
+
return next();
|
|
151
|
+
}
|
|
152
|
+
if (!scoped.bearer) {
|
|
153
|
+
throw new Error('Scoped token has no identity');
|
|
154
|
+
}
|
|
155
|
+
authorization = `Bearer ${scoped.bearer}`;
|
|
156
|
+
}
|
|
76
157
|
const pkce = this.provider('auth.pkce');
|
|
77
158
|
if (!pkce) {
|
|
78
159
|
throw new Error('No PKCE provider configured');
|
|
79
160
|
}
|
|
80
|
-
|
|
161
|
+
// A scoped token may carry a Form.io user token (the caller's x-jwt-token,
|
|
162
|
+
// or one the UAG signed for a run's research), not one of /mcp's own
|
|
163
|
+
// access tokens: it is verified as the project verifies x-jwt-token.
|
|
164
|
+
if (scoped?.bearer && !jsonwebtoken_1.default.decode(scoped.bearer)?.sub) {
|
|
165
|
+
req.auth = await this.formioTokenAuth(pkce, scoped.bearer);
|
|
166
|
+
return next();
|
|
167
|
+
}
|
|
168
|
+
const auth = await pkce.authorize(authorization);
|
|
81
169
|
if (!auth || !auth.user) {
|
|
82
170
|
throw new Error('Unauthorized');
|
|
83
171
|
}
|
|
@@ -85,13 +173,76 @@ class UAGProjectInterface extends appserver_1.ProjectInterface {
|
|
|
85
173
|
next();
|
|
86
174
|
}
|
|
87
175
|
catch (err) {
|
|
176
|
+
// Point MCP clients at OAuth sign-in only when it is on (MCP_OAUTH, with the database).
|
|
177
|
+
const pkce = this.provider('auth.pkce');
|
|
178
|
+
const metadata = pkce?.oauthReady ? `, resource_metadata="${pkce.publicUrl(req)}/.well-known/oauth-protected-resource"` : '';
|
|
179
|
+
// RFC 6750 3.1: a request that presented a token is told it is invalid; one
|
|
180
|
+
// that presented none gets no error code, only the challenge.
|
|
181
|
+
const presented = !!(0, formflow_1.bearerOf)(req.get('authorization'));
|
|
182
|
+
const description = String(err?.message || err || 'Unauthorized').replace(/["\\]/g, '');
|
|
183
|
+
const challenge = presented ? `, error="invalid_token", error_description="${description}"` : '';
|
|
88
184
|
return res.status(401).set({
|
|
89
|
-
'WWW-Authenticate': `Bearer realm="uag-server"
|
|
185
|
+
'WWW-Authenticate': `Bearer realm="uag-server"${challenge}${metadata}`
|
|
90
186
|
}).json({
|
|
91
|
-
|
|
187
|
+
error: presented ? 'invalid_token' : 'unauthorized',
|
|
188
|
+
error_description: presented ? description : 'Send an access token: Authorization: Bearer <token>.'
|
|
92
189
|
});
|
|
93
190
|
}
|
|
94
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* The user a Form.io token stands for, as the project's x-jwt-token
|
|
194
|
+
* authentication decides it: a token tied to a Form.io session is asked of
|
|
195
|
+
* Form.io; one signed with this UAG's secret is read from its claims, bound
|
|
196
|
+
* to this project; any other is asked of Form.io. Never an administrator:
|
|
197
|
+
* an administrator's research carries no user token.
|
|
198
|
+
*/
|
|
199
|
+
async formioTokenAuth(provider, token) {
|
|
200
|
+
const auth = new appserver_1.AuthRequest(this);
|
|
201
|
+
const projectId = String(this.project?._id || '');
|
|
202
|
+
const fromFormio = async () => {
|
|
203
|
+
const user = await provider.formioUser(token);
|
|
204
|
+
if (!user)
|
|
205
|
+
throw new Error('Unauthorized');
|
|
206
|
+
// A user of another project is refused, as /mcp's own sign-in refuses one.
|
|
207
|
+
if (user.project && String(user.project) !== projectId)
|
|
208
|
+
throw new Error('Forbidden');
|
|
209
|
+
return auth.fromFormioUser(user, token, this);
|
|
210
|
+
};
|
|
211
|
+
if (this.proxyMode && appserver_1.AuthProvider.sessionToken(token)) {
|
|
212
|
+
await fromFormio();
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const secret = this.config?.auth?.secret;
|
|
216
|
+
let verified = null;
|
|
217
|
+
try {
|
|
218
|
+
if (secret)
|
|
219
|
+
verified = jsonwebtoken_1.default.verify(token, secret);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
if (err?.name === 'TokenExpiredError')
|
|
223
|
+
throw new Error('Token Expired');
|
|
224
|
+
}
|
|
225
|
+
if (verified) {
|
|
226
|
+
// Only a token for this project: one naming no project (or another) is refused.
|
|
227
|
+
if (!projectId || String(verified.project?._id || '') !== projectId)
|
|
228
|
+
throw new Error('Forbidden');
|
|
229
|
+
// Kept as it is, never signed again: a token the UAG signed for a run
|
|
230
|
+
// keeps its roles and its short lifetime when forwarded to Form.io.
|
|
231
|
+
auth.token = token;
|
|
232
|
+
await auth.fromAuthToken(verified, this);
|
|
233
|
+
}
|
|
234
|
+
else if (this.proxyMode) {
|
|
235
|
+
await fromFormio();
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
throw new Error('Unauthorized');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (!auth.user)
|
|
242
|
+
throw new Error('Unauthorized');
|
|
243
|
+
auth.admin = false;
|
|
244
|
+
return auth;
|
|
245
|
+
}
|
|
95
246
|
mcpJSONResponse(data = {}, isError = false) {
|
|
96
247
|
return {
|
|
97
248
|
content: [
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import type { TaskRunRecord } from './runs';
|
|
3
|
+
import type { UAGProjectInterface } from './UAGProjectInterface';
|
|
4
|
+
/** A run record as a Form.io action item. */
|
|
5
|
+
export declare function actionItem(record: TaskRunRecord): {
|
|
6
|
+
_id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
action: string;
|
|
9
|
+
project: string;
|
|
10
|
+
form: string;
|
|
11
|
+
submission?: string | undefined;
|
|
12
|
+
handler: "after" | "before";
|
|
13
|
+
method: string;
|
|
14
|
+
state: string;
|
|
15
|
+
messages: {
|
|
16
|
+
datetime: string;
|
|
17
|
+
info: string;
|
|
18
|
+
data: any;
|
|
19
|
+
}[];
|
|
20
|
+
data: any;
|
|
21
|
+
created: string;
|
|
22
|
+
modified: string;
|
|
23
|
+
};
|
|
24
|
+
export declare function ActionLogRouter(project: UAGProjectInterface): Router;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.actionItem = actionItem;
|
|
4
|
+
exports.ActionLogRouter = ActionLogRouter;
|
|
5
|
+
const appserver_1 = require("@formio/appserver");
|
|
6
|
+
const express_1 = require("express");
|
|
7
|
+
const lodash_1 = require("lodash");
|
|
8
|
+
const runner_1 = require("./runner");
|
|
9
|
+
const debug = require('debug')('formio:uag:actionLogs');
|
|
10
|
+
/**
|
|
11
|
+
* Action Logs in a portal stage connected to the UAG. The portal asks the
|
|
12
|
+
* stage's URL for `/action` (Form.io Enterprise's action items). The UAG asks
|
|
13
|
+
* Form.io with the caller's own credential, so Form.io decides who may see the
|
|
14
|
+
* logs, and adds its Run task records to Form.io's entries, newest first.
|
|
15
|
+
*
|
|
16
|
+
* Without the UAG's database there are no records, and these requests pass
|
|
17
|
+
* through to Form.io unchanged.
|
|
18
|
+
*/
|
|
19
|
+
const STATES = { pending: 'new', running: 'inprogress', done: 'complete', failed: 'error' };
|
|
20
|
+
const STATUSES = { new: 'pending', inprogress: 'running', complete: 'done', error: 'failed' };
|
|
21
|
+
const MAX_PAGE = 1000;
|
|
22
|
+
/** The caller's credential, as sent, for Form.io. */
|
|
23
|
+
function credential(req) {
|
|
24
|
+
return Object.fromEntries(Object.entries(req.headers)
|
|
25
|
+
.filter(([name, value]) => appserver_1.Actions.CREDENTIAL_HEADERS.includes(name.toLowerCase()) && typeof value === 'string'));
|
|
26
|
+
}
|
|
27
|
+
/** A run record as a Form.io action item. */
|
|
28
|
+
function actionItem(record) {
|
|
29
|
+
const messages = [
|
|
30
|
+
{ datetime: record.created.toISOString(), info: `Run task "${record.task}" ${record.durable ? 'queued' : 'started'} (${record.answer === 'decisions' ? 'decision fields only' : 'the whole task'}).`, data: {} }
|
|
31
|
+
];
|
|
32
|
+
if (record.started) {
|
|
33
|
+
messages.push({ datetime: record.started.toISOString(), info: `Started (attempt ${record.attempts}).`, data: {} });
|
|
34
|
+
}
|
|
35
|
+
if (record.finished) {
|
|
36
|
+
const written = record.written || [];
|
|
37
|
+
const cleared = record.cleared || [];
|
|
38
|
+
const refused = record.refused || [];
|
|
39
|
+
const left = refused.length ? ` Left out answers the form refused: ${refused.join(', ')}.` : '';
|
|
40
|
+
const info = (record.status === 'failed'
|
|
41
|
+
? `Failed: ${record.error || 'unknown error'}`
|
|
42
|
+
: record.saved
|
|
43
|
+
? `Saved ${written.length} field(s)${written.length ? `: ${written.join(', ')}` : ''}${cleared.length ? `; cleared ${cleared.join(', ')}` : ''}.`
|
|
44
|
+
: 'Nothing changed, so nothing was saved.') + left;
|
|
45
|
+
messages.push({ datetime: record.finished.toISOString(), info, data: { engines: record.engines || [], written, cleared, ...(refused.length && { refused }) } });
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
_id: String(record._id),
|
|
49
|
+
title: record.action,
|
|
50
|
+
action: 'agentRunTask',
|
|
51
|
+
project: record.project,
|
|
52
|
+
form: record.form,
|
|
53
|
+
...(record.submission && { submission: record.submission }),
|
|
54
|
+
handler: record.handler,
|
|
55
|
+
method: record.method,
|
|
56
|
+
state: STATES[record.status],
|
|
57
|
+
messages,
|
|
58
|
+
data: {
|
|
59
|
+
task: record.task,
|
|
60
|
+
answer: record.answer,
|
|
61
|
+
durable: record.durable,
|
|
62
|
+
...(record.actingFor?._id && { actingFor: { _id: record.actingFor._id, ...(record.actingFor.email && { email: record.actingFor.email }) } }),
|
|
63
|
+
...(record.agentRole && { agentRole: record.agentRole })
|
|
64
|
+
},
|
|
65
|
+
created: record.created.toISOString(),
|
|
66
|
+
modified: record.modified.toISOString()
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** The records filter for the portal's query (`form`, `submission`, `handler`, `method`, `state`, `title__regex`). */
|
|
70
|
+
function filterOf(project, query) {
|
|
71
|
+
const filter = { project: String(project.project?._id || '') };
|
|
72
|
+
for (const field of ['form', 'submission', 'handler', 'method']) {
|
|
73
|
+
if (typeof query[field] === 'string' && query[field])
|
|
74
|
+
filter[field] = query[field];
|
|
75
|
+
}
|
|
76
|
+
if (typeof query.state === 'string' && query.state)
|
|
77
|
+
filter.status = STATUSES[query.state] || '__none__';
|
|
78
|
+
// Matched as text: a portal filter is never run as a regular expression against the database.
|
|
79
|
+
if (typeof query.title__regex === 'string' && query.title__regex)
|
|
80
|
+
filter.action = { $regex: (0, lodash_1.escapeRegExp)(query.title__regex), $options: 'i' };
|
|
81
|
+
return filter;
|
|
82
|
+
}
|
|
83
|
+
const number = (value, fallback) => {
|
|
84
|
+
const parsed = parseInt(String(value ?? ''), 10);
|
|
85
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
86
|
+
};
|
|
87
|
+
function ActionLogRouter(project) {
|
|
88
|
+
const router = (0, express_1.Router)();
|
|
89
|
+
/** Ask Form.io, as the caller, for its action items. */
|
|
90
|
+
const ask = (req, path, query) => fetch(`${project.endpoint}${path}${query.toString() ? `?${query}` : ''}`, { headers: { ...credential(req), Accept: 'application/json' } });
|
|
91
|
+
/** Relay Form.io's answer as it is. */
|
|
92
|
+
const relay = async (res, response) => {
|
|
93
|
+
const range = response.headers.get('content-range');
|
|
94
|
+
if (range)
|
|
95
|
+
res.set('Content-Range', range);
|
|
96
|
+
res.status(response.status).type(response.headers.get('content-type') || 'application/json').send(await response.text());
|
|
97
|
+
};
|
|
98
|
+
router.get('/action', async (req, res, next) => {
|
|
99
|
+
const records = (0, runner_1.taskRunner)(project).records;
|
|
100
|
+
if (!records || !project.endpoint)
|
|
101
|
+
return next();
|
|
102
|
+
try {
|
|
103
|
+
const skip = number(req.query.skip, 0);
|
|
104
|
+
// A limit of 0 is the default page, not every record.
|
|
105
|
+
const limit = Math.min(number(req.query.limit, 10) || 10, MAX_PAGE);
|
|
106
|
+
// Both lists are sorted the same way, so the page is among the first skip + limit of each.
|
|
107
|
+
const query = new URLSearchParams(Object.entries(req.query).filter(([, value]) => typeof value === 'string'));
|
|
108
|
+
query.set('skip', '0');
|
|
109
|
+
query.set('limit', String(Math.min(skip + limit, MAX_PAGE)));
|
|
110
|
+
query.set('sort', '-modified');
|
|
111
|
+
const response = await ask(req, '/action', query);
|
|
112
|
+
if (!response.ok)
|
|
113
|
+
return relay(res, response);
|
|
114
|
+
const formio = await response.json();
|
|
115
|
+
const formioTotal = Number((response.headers.get('content-range') || '').split('/')[1]) || formio.length;
|
|
116
|
+
const own = await records.list(filterOf(project, req.query), Math.min(skip + limit, MAX_PAGE));
|
|
117
|
+
const merged = [...formio, ...own.items.map(actionItem)]
|
|
118
|
+
.sort((a, b) => Date.parse(b.modified || b.created) - Date.parse(a.modified || a.created));
|
|
119
|
+
const page = merged.slice(skip, skip + limit);
|
|
120
|
+
const total = formioTotal + own.total;
|
|
121
|
+
res.set('Content-Range', page.length ? `${skip}-${skip + page.length - 1}/${total}` : `*/${total}`);
|
|
122
|
+
res.json(page);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
debug(`Action Logs failed: ${err?.message || err}`);
|
|
126
|
+
next(err);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
router.get('/action/:id', async (req, res, next) => {
|
|
130
|
+
const records = (0, runner_1.taskRunner)(project).records;
|
|
131
|
+
if (!records || !project.endpoint)
|
|
132
|
+
return next();
|
|
133
|
+
try {
|
|
134
|
+
const record = await records.get(String(req.params.id));
|
|
135
|
+
if (!record || record.project !== String(project.project?._id || '')) {
|
|
136
|
+
return relay(res, await ask(req, `/action/${encodeURIComponent(String(req.params.id))}`, new URLSearchParams()));
|
|
137
|
+
}
|
|
138
|
+
// Form.io decides who may read Action Logs.
|
|
139
|
+
const allowed = await ask(req, '/action', new URLSearchParams({ limit: '1' }));
|
|
140
|
+
if (!allowed.ok)
|
|
141
|
+
return relay(res, allowed);
|
|
142
|
+
res.json(actionItem(record));
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
next(err);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
return router;
|
|
149
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Action, FormInterface, SubmissionRequest, SubmissionResponse } from '@formio/appserver';
|
|
2
|
+
import { NextFunction } from 'express';
|
|
3
|
+
export declare const RUN_TASK = "agentRunTask";
|
|
4
|
+
/**
|
|
5
|
+
* Remove, from what a client sends, the values of the fields each of the form's
|
|
6
|
+
* Run task actions owns and marks `uagAuthoritative`: on an update they keep
|
|
7
|
+
* their stored values; on a create they start empty. This covers every Run
|
|
8
|
+
* task action on the form, whatever its method, conditions or when it runs, so
|
|
9
|
+
* a client can never store a value the server decides: not before the task
|
|
10
|
+
* runs, and not through a method the task does not run on. The runner's own
|
|
11
|
+
* save is not a client's, and is left alone.
|
|
12
|
+
*
|
|
13
|
+
* As `before:submission:<op>` hooks, these run after an update has loaded the
|
|
14
|
+
* stored submission and before the request's data replaces it.
|
|
15
|
+
*/
|
|
16
|
+
export declare function ownedFieldHooks(): {
|
|
17
|
+
'before:submission:create': (form: FormInterface) => Promise<FormInterface | ((req: SubmissionRequest, res: SubmissionResponse, next: NextFunction) => Promise<void>)>;
|
|
18
|
+
'before:submission:update': (form: FormInterface) => Promise<FormInterface | ((req: SubmissionRequest, res: SubmissionResponse, next: NextFunction) => Promise<void>)>;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Decide how a submission is created through the UAG, from the form's
|
|
22
|
+
* submission access and its Run task actions that run on create:
|
|
23
|
+
* - a caller whose roles may create submissions creates it with their own token;
|
|
24
|
+
* - otherwise, when an action names an agent role the form grants "Create All",
|
|
25
|
+
* and the caller has one of the actions' submitter roles, the agent creates it
|
|
26
|
+
* on their behalf, owned by them;
|
|
27
|
+
* - otherwise the create is refused.
|
|
28
|
+
* Run as a `before:access:create` hook, before the caller's access is checked.
|
|
29
|
+
*/
|
|
30
|
+
export declare function onBehalfHook(): (form: FormInterface) => Promise<FormInterface | ((req: SubmissionRequest, res: SubmissionResponse, next: NextFunction) => Promise<void | SubmissionResponse>)>;
|
|
31
|
+
/** The hooks Run task needs: owned fields kept from clients, and creation on a user's behalf. */
|
|
32
|
+
export declare function runTaskHooks(): {
|
|
33
|
+
'before:submission:create': (form: FormInterface) => Promise<FormInterface | ((req: SubmissionRequest, res: SubmissionResponse, next: NextFunction) => Promise<void>)>;
|
|
34
|
+
'before:submission:update': (form: FormInterface) => Promise<FormInterface | ((req: SubmissionRequest, res: SubmissionResponse, next: NextFunction) => Promise<void>)>;
|
|
35
|
+
'before:access:create': (form: FormInterface) => Promise<FormInterface | ((req: SubmissionRequest, res: SubmissionResponse, next: NextFunction) => Promise<void | SubmissionResponse>)>;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* "Agent: Run task": run one of the form's tasks on a submission sent to the
|
|
39
|
+
* UAG, before the save (the submitter waits, and Form.io stores the result as
|
|
40
|
+
* the one save) or after it, in the background (the results are saved as an
|
|
41
|
+
* update, which runs the form's update actions in turn).
|
|
42
|
+
*/
|
|
43
|
+
export declare const RunTaskAction: Action;
|