@lowdefy/api 5.6.0 → 6.0.0
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/dist/context/createAuthorize.js +4 -1
- package/dist/context/resolveStrategyCaller.js +56 -0
- package/dist/index.js +12 -2
- package/dist/routes/agent/callAgent.js +7 -184
- package/dist/routes/agent/prepareAgent.js +173 -0
- package/dist/routes/auth/createLogger.js +2 -5
- package/dist/routes/auth/createPrefixedCookies.js +52 -0
- package/dist/routes/auth/{getNextAuthConfig.js → getAuthConfig.js} +27 -16
- package/dist/routes/auth/resolveCookies.js +37 -0
- package/dist/routes/auth/strategies/createAuthStrategies.js +77 -0
- package/dist/routes/auth/strategies/getAuthStrategies.js +31 -0
- package/dist/routes/endpoints/authorizeApiEndpoint.js +8 -2
- package/dist/routes/endpoints/callEndpoint.js +26 -3
- package/dist/routes/endpoints/control/controlReject.js +3 -1
- package/dist/routes/endpoints/control/controlThrow.js +4 -1
- package/dist/routes/endpoints/findSchedule.js +35 -0
- package/dist/routes/endpoints/forwardScheduledEndpoint.js +106 -0
- package/dist/routes/endpoints/getEndpointConfig.js +9 -3
- package/dist/routes/endpoints/getEnvironmentSchedules.js +29 -0
- package/dist/routes/endpoints/handleAgentCall.js +83 -0
- package/dist/routes/endpoints/handleEndpointCall.js +49 -1
- package/dist/routes/endpoints/handleRenderNotification.js +189 -0
- package/dist/routes/endpoints/handleValidateSchema.js +3 -1
- package/dist/routes/endpoints/isUnauthenticatedHuman.js +31 -0
- package/dist/routes/endpoints/resolveCronEnvironment.js +37 -0
- package/dist/routes/endpoints/runDetachedEndpoint.js +66 -0
- package/dist/routes/endpoints/runRoutine.js +12 -0
- package/dist/routes/endpoints/runScheduledEndpoint.js +104 -0
- package/dist/routes/endpoints/runWebhookEndpoint.js +83 -0
- package/dist/routes/endpoints/scheduleBackground.js +48 -0
- package/dist/routes/mcp/createMcpServer.js +160 -0
- package/dist/routes/notifications/derivePreview.js +30 -0
- package/dist/routes/notifications/getNotificationConfig.js +32 -0
- package/dist/routes/notifications/resolveNotificationLinks.js +72 -0
- package/dist/routes/notifications/resolveThemeLogo.js +35 -0
- package/dist/routes/page/dynamic/resolveDynamicContent.js +172 -0
- package/dist/routes/page/dynamic/unescapeOperators.js +36 -0
- package/dist/routes/page/dynamic/validateFragment.js +104 -0
- package/dist/routes/page/getPageConfig.js +16 -6
- package/dist/routes/websocket/authorizeWebsocket.js +27 -0
- package/dist/routes/websocket/createChannelRegistry.js +269 -0
- package/dist/routes/websocket/createWebSocketConnection.js +131 -0
- package/dist/routes/websocket/getWebsocketConfig.js +30 -0
- package/dist/routes/websocket/getWebsocketResolver.js +33 -0
- package/dist/routes/websocket/prepareChannel.js +72 -0
- package/dist/test/testContext.js +4 -2
- package/package.json +12 -10
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/ import { validate } from '@lowdefy/ajv';
|
|
16
|
+
import { ConfigError } from '@lowdefy/errors';
|
|
17
|
+
import { type } from '@lowdefy/helpers';
|
|
18
|
+
import addStepResult from './addStepResult.js';
|
|
19
|
+
import derivePreview from '../notifications/derivePreview.js';
|
|
20
|
+
import getNotificationConfig from '../notifications/getNotificationConfig.js';
|
|
21
|
+
import resolveNotificationLinks from '../notifications/resolveNotificationLinks.js';
|
|
22
|
+
import resolveThemeLogo from '../notifications/resolveThemeLogo.js';
|
|
23
|
+
function itemHasPageLinks(item, dataKeys) {
|
|
24
|
+
const links = Object.values(item.links ?? {});
|
|
25
|
+
const arrayLinks = dataKeys.flatMap((key)=>(type.isArray(item[key]) ? item[key] : []).map((entry)=>entry?.link));
|
|
26
|
+
return [
|
|
27
|
+
...links,
|
|
28
|
+
...arrayLinks
|
|
29
|
+
].some((link)=>type.isObject(link) && !type.isNone(link.pageId));
|
|
30
|
+
}
|
|
31
|
+
async function handleRenderNotification(context, routineContext, { step }) {
|
|
32
|
+
const { logger, evaluateOperators } = context;
|
|
33
|
+
logger.debug({
|
|
34
|
+
event: 'debug_start_render_notification',
|
|
35
|
+
step
|
|
36
|
+
});
|
|
37
|
+
const evaluatedProperties = evaluateOperators({
|
|
38
|
+
input: step.properties,
|
|
39
|
+
items: routineContext.items,
|
|
40
|
+
location: step.stepId,
|
|
41
|
+
payload: routineContext.payload,
|
|
42
|
+
state: routineContext.state,
|
|
43
|
+
steps: routineContext.steps
|
|
44
|
+
});
|
|
45
|
+
const { notificationId, data, landingPage, recordId } = evaluatedProperties;
|
|
46
|
+
let { serverUrl } = evaluatedProperties;
|
|
47
|
+
if (!type.isString(notificationId)) {
|
|
48
|
+
throw new ConfigError(`RenderNotification step "${step.stepId}" properties.notificationId must evaluate to a string. Received ${JSON.stringify(notificationId)}.`, {
|
|
49
|
+
configKey: step['~k']
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (type.isArray(data)) {
|
|
53
|
+
throw new ConfigError(`RenderNotification step "${step.stepId}" properties.data must evaluate to an object. Received an array — iterate with a ":for" control and render one item per step.`, {
|
|
54
|
+
configKey: step['~k']
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (!type.isObject(data)) {
|
|
58
|
+
throw new ConfigError(`RenderNotification step "${step.stepId}" properties.data must evaluate to an object. Received ${JSON.stringify(data)}.`, {
|
|
59
|
+
configKey: step['~k']
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (!type.isNone(serverUrl)) {
|
|
63
|
+
if (!type.isString(serverUrl) || serverUrl === '') {
|
|
64
|
+
throw new ConfigError(`RenderNotification step "${step.stepId}" properties.serverUrl must evaluate to a non-empty string. Received ${JSON.stringify(serverUrl)}.`, {
|
|
65
|
+
configKey: step['~k']
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
serverUrl = serverUrl.replace(/\/$/, '');
|
|
69
|
+
}
|
|
70
|
+
if (!type.isNone(landingPage) && !type.isString(landingPage)) {
|
|
71
|
+
throw new ConfigError(`RenderNotification step "${step.stepId}" properties.landingPage must evaluate to a string. Received ${JSON.stringify(landingPage)}.`, {
|
|
72
|
+
configKey: step['~k']
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (!type.isNone(recordId) && !type.isString(recordId)) {
|
|
76
|
+
throw new ConfigError(`RenderNotification step "${step.stepId}" properties.recordId must evaluate to a string. Received ${JSON.stringify(recordId)}.`, {
|
|
77
|
+
configKey: step['~k']
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const notificationConfig = await getNotificationConfig(context, {
|
|
81
|
+
notificationId,
|
|
82
|
+
configKey: step['~k']
|
|
83
|
+
});
|
|
84
|
+
const configKey = notificationConfig['~k'];
|
|
85
|
+
const Template = context.notifications[notificationConfig.type];
|
|
86
|
+
if (!Template) {
|
|
87
|
+
throw new ConfigError(`Notification template type "${notificationConfig.type}" can not be found.`, {
|
|
88
|
+
configKey
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const { renderEmail, interpolateProperties } = context;
|
|
92
|
+
if (!type.isFunction(renderEmail) || !type.isFunction(interpolateProperties)) {
|
|
93
|
+
// plugins/notifications.js exports real implementations whenever the app
|
|
94
|
+
// has notifications built — reaching this means the build artifact and
|
|
95
|
+
// config are out of sync.
|
|
96
|
+
throw new ConfigError('Email rendering is not available. Rebuild the app — @lowdefy/email-templates is installed when "notifications:" is configured.', {
|
|
97
|
+
configKey
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const app = await context.readConfigFile('app.json') ?? {};
|
|
101
|
+
const basePath = context.config?.basePath ?? '';
|
|
102
|
+
// Resolve after the merge so a per-notification theme.logo override also
|
|
103
|
+
// resolves; precedence stays notification theme over app.email.
|
|
104
|
+
const theme = resolveThemeLogo({
|
|
105
|
+
theme: {
|
|
106
|
+
...app.email ?? {},
|
|
107
|
+
...notificationConfig.theme ?? {}
|
|
108
|
+
},
|
|
109
|
+
serverUrl,
|
|
110
|
+
basePath
|
|
111
|
+
});
|
|
112
|
+
if (itemHasPageLinks(data, Template.dataKeys ?? [])) {
|
|
113
|
+
if (type.isNone(serverUrl)) {
|
|
114
|
+
throw new ConfigError(`Notification "${notificationId}" has links but no server URL is available. Set the serverUrl step property.`, {
|
|
115
|
+
configKey: step['~k']
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// Landing URLs embed the record id — without it the landing page cannot
|
|
119
|
+
// resolve the record and every link in the email would be dead.
|
|
120
|
+
if (!type.isNone(landingPage) && type.isNone(recordId)) {
|
|
121
|
+
throw new ConfigError(`RenderNotification step "${step.stepId}" properties.landingPage requires properties.recordId to compose landing URLs.`, {
|
|
122
|
+
configKey: step['~k']
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const resolvedItem = resolveNotificationLinks({
|
|
127
|
+
item: data,
|
|
128
|
+
dataKeys: Template.dataKeys ?? [],
|
|
129
|
+
serverUrl: serverUrl ?? '',
|
|
130
|
+
basePath,
|
|
131
|
+
landingPage,
|
|
132
|
+
recordId
|
|
133
|
+
});
|
|
134
|
+
let interpolated;
|
|
135
|
+
try {
|
|
136
|
+
interpolated = interpolateProperties({
|
|
137
|
+
properties: notificationConfig.properties,
|
|
138
|
+
data: resolvedItem,
|
|
139
|
+
markdownProperties: Template.markdownProperties ?? []
|
|
140
|
+
});
|
|
141
|
+
} catch (error) {
|
|
142
|
+
throw new ConfigError(`Notification "${notificationId}" template interpolation failed: ${error.message}`, {
|
|
143
|
+
configKey,
|
|
144
|
+
cause: error
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (Template.schema) {
|
|
148
|
+
const { valid, errors } = validate({
|
|
149
|
+
schema: Template.schema,
|
|
150
|
+
data: interpolated,
|
|
151
|
+
returnErrors: true
|
|
152
|
+
});
|
|
153
|
+
if (!valid) {
|
|
154
|
+
throw new ConfigError(`Notification "${notificationId}" properties do not match template "${notificationConfig.type}" schema: ${errors?.[0]?.message}.`, {
|
|
155
|
+
configKey
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const { html, text } = await renderEmail({
|
|
160
|
+
Template,
|
|
161
|
+
properties: interpolated,
|
|
162
|
+
data: resolvedItem,
|
|
163
|
+
theme,
|
|
164
|
+
links: resolvedItem.links ?? {}
|
|
165
|
+
});
|
|
166
|
+
const result = {
|
|
167
|
+
subject: interpolated.subject,
|
|
168
|
+
title: interpolated.title ?? interpolated.subject,
|
|
169
|
+
preview: derivePreview({
|
|
170
|
+
properties: interpolated
|
|
171
|
+
}),
|
|
172
|
+
html,
|
|
173
|
+
text,
|
|
174
|
+
data: resolvedItem
|
|
175
|
+
};
|
|
176
|
+
addStepResult(context, routineContext, {
|
|
177
|
+
result,
|
|
178
|
+
stepId: step.stepId
|
|
179
|
+
});
|
|
180
|
+
logger.debug({
|
|
181
|
+
event: 'debug_end_render_notification',
|
|
182
|
+
stepId: step.stepId,
|
|
183
|
+
notificationId
|
|
184
|
+
});
|
|
185
|
+
return {
|
|
186
|
+
status: 'continue'
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
export default handleRenderNotification;
|
|
@@ -51,10 +51,12 @@ async function handleValidateSchema(context, routineContext, { step }) {
|
|
|
51
51
|
const error = new Error(buildErrorMessage(result.errors, step.stepId), {
|
|
52
52
|
cause: result.errors
|
|
53
53
|
});
|
|
54
|
+
// Log under `err` — see controlThrow: only the `err` key runs the pino error
|
|
55
|
+
// serializer, so `error` would drop the message from the log line.
|
|
54
56
|
logger.error({
|
|
55
57
|
event: 'error_validate_schema',
|
|
56
58
|
stepId: step.stepId,
|
|
57
|
-
error
|
|
59
|
+
err: error
|
|
58
60
|
});
|
|
59
61
|
return {
|
|
60
62
|
status: 'error',
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/ import { type } from '@lowdefy/helpers';
|
|
16
|
+
// True for a session-less caller on an app that has auth configured, outside a
|
|
17
|
+
// system run. This is the one caller who must get the same answer for a missing
|
|
18
|
+
// id as for a protected one - "authenticate" - or a logged-out client can
|
|
19
|
+
// enumerate endpoint ids by response difference (500/does-not-exist for a miss
|
|
20
|
+
// versus 401 for a protected id). "No user" is not enough on its own: it is also
|
|
21
|
+
// "no auth in this app" and "the engine talking to itself" (scheduled, webhook
|
|
22
|
+
// and detached runs), and neither should be told to authenticate against
|
|
23
|
+
// nothing. auth.json is a build artifact the build always writes.
|
|
24
|
+
async function isUnauthenticatedHuman({ readConfigFile, system, user }) {
|
|
25
|
+
if (!type.isNone(user) || system === true) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const authConfig = await readConfigFile('auth.json');
|
|
29
|
+
return authConfig?.configured === true;
|
|
30
|
+
}
|
|
31
|
+
export default isUnauthenticatedHuman;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/ import { type } from '@lowdefy/helpers';
|
|
16
|
+
import { ConfigError } from '@lowdefy/errors';
|
|
17
|
+
// The environment a cron run resolves its schedules for. A forwarded request names it in the
|
|
18
|
+
// x-lowdefy-cron-environment header; a request without one is the deployment running its own crons,
|
|
19
|
+
// which is the environment declared without a url (the one Vercel fires crons on). Without
|
|
20
|
+
// config.cron there are no environments and the endpoint's plain schedules apply.
|
|
21
|
+
function resolveCronEnvironment({ config, environment }) {
|
|
22
|
+
const environments = config?.cron?.environments;
|
|
23
|
+
if (type.isNone(environments)) {
|
|
24
|
+
if (!type.isNone(environment)) {
|
|
25
|
+
throw new ConfigError(`Cron environment "${environment}" is not configured: lowdefy.config.cron.environments is not defined.`);
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
if (!type.isNone(environment)) {
|
|
30
|
+
if (!type.isObject(environments[environment])) {
|
|
31
|
+
throw new ConfigError(`Cron environment "${environment}" is not declared in lowdefy.config.cron.environments.`);
|
|
32
|
+
}
|
|
33
|
+
return environment;
|
|
34
|
+
}
|
|
35
|
+
return Object.keys(environments).find((name)=>type.isObject(environments[name]) && type.isUndefined(environments[name].url));
|
|
36
|
+
}
|
|
37
|
+
export default resolveCronEnvironment;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/ import { serializer } from '@lowdefy/helpers';
|
|
16
|
+
import buildEndpointResult from '../../response/buildEndpointResult.js';
|
|
17
|
+
import createAuthorize from '../../context/createAuthorize.js';
|
|
18
|
+
import createEvaluateOperators from '../../context/createEvaluateOperators.js';
|
|
19
|
+
import getEndpointConfig from './getEndpointConfig.js';
|
|
20
|
+
import runRoutine from './runRoutine.js';
|
|
21
|
+
// Runs an endpoint invoked through the detached route (a CallApi step with
|
|
22
|
+
// `detached: true` fire-and-forgets an HTTP call back to the deployment, so the
|
|
23
|
+
// target runs in its OWN function invocation with a fresh duration budget).
|
|
24
|
+
// Like a cron run this is authorized by the transport layer (CRON_SECRET) and
|
|
25
|
+
// executes as a system context: no user session, `_user` resolves to undefined,
|
|
26
|
+
// and InternalApi endpoints are callable. Payload arrives serialized (dates
|
|
27
|
+
// etc. survive the HTTP hop via @lowdefy/helpers serializer).
|
|
28
|
+
async function runDetachedEndpoint(context, { endpointId, payload }) {
|
|
29
|
+
const { logger } = context;
|
|
30
|
+
context.endpointId = endpointId;
|
|
31
|
+
context.evaluateOperators = createEvaluateOperators(context);
|
|
32
|
+
logger.debug({
|
|
33
|
+
event: 'debug_detached_endpoint',
|
|
34
|
+
endpointId
|
|
35
|
+
});
|
|
36
|
+
const endpointConfig = await getEndpointConfig(context, {
|
|
37
|
+
endpointId
|
|
38
|
+
});
|
|
39
|
+
// Force a system context regardless of any session cookie sent with the request.
|
|
40
|
+
// system: true — nested CallApi steps are authorized like function calls (the run
|
|
41
|
+
// was already authorized at the transport layer), not re-gated on a user session.
|
|
42
|
+
context.session = undefined;
|
|
43
|
+
context.user = undefined;
|
|
44
|
+
context.system = true;
|
|
45
|
+
context.authorize = createAuthorize({
|
|
46
|
+
session: undefined,
|
|
47
|
+
system: true
|
|
48
|
+
});
|
|
49
|
+
const routineContext = {
|
|
50
|
+
steps: {},
|
|
51
|
+
payload: serializer.deserialize(payload ?? {}),
|
|
52
|
+
arrayIndices: [],
|
|
53
|
+
items: {},
|
|
54
|
+
state: {},
|
|
55
|
+
endpointDepth: 0
|
|
56
|
+
};
|
|
57
|
+
const { error, response, status } = await runRoutine(context, routineContext, {
|
|
58
|
+
routine: endpointConfig.routine
|
|
59
|
+
});
|
|
60
|
+
return buildEndpointResult(context, {
|
|
61
|
+
error,
|
|
62
|
+
response,
|
|
63
|
+
status
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export default runDetachedEndpoint;
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
See the License for the specific language governing permissions and
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/ import { type } from '@lowdefy/helpers';
|
|
16
|
+
import handleAgentCall from './handleAgentCall.js';
|
|
16
17
|
import handleControl from './control/handleControl.js';
|
|
17
18
|
import handleEndpointCall from './handleEndpointCall.js';
|
|
19
|
+
import handleRenderNotification from './handleRenderNotification.js';
|
|
18
20
|
import handleRequest from './handleRequest.js';
|
|
19
21
|
import handleValidateSchema from './handleValidateSchema.js';
|
|
20
22
|
async function runRoutine(context, routineContext, { routine }) {
|
|
@@ -35,6 +37,16 @@ async function runRoutine(context, routineContext, { routine }) {
|
|
|
35
37
|
step: routine
|
|
36
38
|
});
|
|
37
39
|
}
|
|
40
|
+
if (routine.id?.startsWith?.('agent:')) {
|
|
41
|
+
return await handleAgentCall(context, routineContext, {
|
|
42
|
+
step: routine
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (routine.id?.startsWith?.('notification:')) {
|
|
46
|
+
return await handleRenderNotification(context, routineContext, {
|
|
47
|
+
step: routine
|
|
48
|
+
});
|
|
49
|
+
}
|
|
38
50
|
return await handleControl(context, routineContext, {
|
|
39
51
|
control: routine
|
|
40
52
|
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/ import { serializer } from '@lowdefy/helpers';
|
|
16
|
+
import createAuthorize from '../../context/createAuthorize.js';
|
|
17
|
+
import buildEndpointResult from '../../response/buildEndpointResult.js';
|
|
18
|
+
import createEvaluateOperators from '../../context/createEvaluateOperators.js';
|
|
19
|
+
import findSchedule from './findSchedule.js';
|
|
20
|
+
import getEndpointConfig from './getEndpointConfig.js';
|
|
21
|
+
import getEnvironmentSchedules from './getEnvironmentSchedules.js';
|
|
22
|
+
import resolveCronEnvironment from './resolveCronEnvironment.js';
|
|
23
|
+
import runRoutine from './runRoutine.js';
|
|
24
|
+
import scheduleBackground from './scheduleBackground.js';
|
|
25
|
+
// Runs an endpoint routine on a schedule (cron). Unlike callEndpoint this does NOT check the
|
|
26
|
+
// endpoint's `auth` config and does NOT block InternalApi: a cron run is authorized by the transport
|
|
27
|
+
// layer (CRON_SECRET) plus the endpoint declaring the firing schedule, and runs as a system context
|
|
28
|
+
// (no user session, so `_user` resolves to undefined). An InternalApi with schedules is therefore a
|
|
29
|
+
// cron-only endpoint that is never client-callable.
|
|
30
|
+
// `environment` (x-lowdefy-cron-environment, set on requests forwarded from the production
|
|
31
|
+
// deployment) selects which environment's schedules apply; see resolveCronEnvironment.
|
|
32
|
+
async function runScheduledEndpoint(context, { endpointId, cron, environment }) {
|
|
33
|
+
const { logger } = context;
|
|
34
|
+
context.endpointId = endpointId;
|
|
35
|
+
context.evaluateOperators = createEvaluateOperators(context);
|
|
36
|
+
const cronEnvironment = resolveCronEnvironment({
|
|
37
|
+
config: context.config,
|
|
38
|
+
environment
|
|
39
|
+
});
|
|
40
|
+
logger.debug({
|
|
41
|
+
event: 'debug_scheduled_endpoint',
|
|
42
|
+
endpointId,
|
|
43
|
+
cron,
|
|
44
|
+
environment: cronEnvironment
|
|
45
|
+
});
|
|
46
|
+
const endpointConfig = await getEndpointConfig(context, {
|
|
47
|
+
endpointId
|
|
48
|
+
});
|
|
49
|
+
const schedules = getEnvironmentSchedules({
|
|
50
|
+
endpointConfig,
|
|
51
|
+
environment: cronEnvironment
|
|
52
|
+
});
|
|
53
|
+
const schedule = findSchedule({
|
|
54
|
+
schedules,
|
|
55
|
+
cron,
|
|
56
|
+
endpointId,
|
|
57
|
+
environment: cronEnvironment
|
|
58
|
+
});
|
|
59
|
+
// Force a system context regardless of any session cookie sent with the request.
|
|
60
|
+
// system: true — nested CallApi steps are authorized like function calls (the run
|
|
61
|
+
// was already authorized at the transport layer), not re-gated on a user session.
|
|
62
|
+
context.session = undefined;
|
|
63
|
+
context.user = undefined;
|
|
64
|
+
context.system = true;
|
|
65
|
+
context.authorize = createAuthorize({
|
|
66
|
+
session: undefined,
|
|
67
|
+
system: true
|
|
68
|
+
});
|
|
69
|
+
const routineContext = {
|
|
70
|
+
steps: {},
|
|
71
|
+
payload: schedule.payload ?? {},
|
|
72
|
+
arrayIndices: [],
|
|
73
|
+
items: {},
|
|
74
|
+
state: {},
|
|
75
|
+
endpointDepth: 0
|
|
76
|
+
};
|
|
77
|
+
// async: true — acknowledge the cron trigger immediately and run in the
|
|
78
|
+
// background; transport auth (CRON_SECRET) already passed at the route.
|
|
79
|
+
if (endpointConfig.async === true) {
|
|
80
|
+
scheduleBackground(context, {
|
|
81
|
+
event: 'background_scheduled_endpoint',
|
|
82
|
+
endpointId
|
|
83
|
+
}, ()=>runRoutine(context, routineContext, {
|
|
84
|
+
routine: endpointConfig.routine
|
|
85
|
+
}));
|
|
86
|
+
return {
|
|
87
|
+
error: null,
|
|
88
|
+
response: serializer.serialize({
|
|
89
|
+
accepted: true
|
|
90
|
+
}),
|
|
91
|
+
status: 'accepted',
|
|
92
|
+
success: true
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const { error, response, status } = await runRoutine(context, routineContext, {
|
|
96
|
+
routine: endpointConfig.routine
|
|
97
|
+
});
|
|
98
|
+
return buildEndpointResult(context, {
|
|
99
|
+
error,
|
|
100
|
+
response,
|
|
101
|
+
status
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
export default runScheduledEndpoint;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/ import { ConfigError } from '@lowdefy/errors';
|
|
16
|
+
import buildEndpointResult from '../../response/buildEndpointResult.js';
|
|
17
|
+
import createAuthorize from '../../context/createAuthorize.js';
|
|
18
|
+
import createEvaluateOperators from '../../context/createEvaluateOperators.js';
|
|
19
|
+
import getEndpointConfig from './getEndpointConfig.js';
|
|
20
|
+
import runRoutine from './runRoutine.js';
|
|
21
|
+
// Runs an endpoint declared `webhook: true` — a third-party webhook receiver
|
|
22
|
+
// (SNS, Event Grid, Stripe, ...) served on the standard /api/endpoints route,
|
|
23
|
+
// but taking the request RAW: bodies are the caller's own format, not
|
|
24
|
+
// Lowdefy's { payload } envelope, so the routine receives
|
|
25
|
+
// { body, query, headers } as its payload and its return value is sent back
|
|
26
|
+
// verbatim (handshakes require exact response shapes). Only endpoints that
|
|
27
|
+
// opt in are runnable here (a missing flag reads as a missing endpoint — no
|
|
28
|
+
// probing). The transport is public by design: authenticating the caller
|
|
29
|
+
// (shared-secret query param, signature header) is the webhook routine's own
|
|
30
|
+
// first step. Executes as a system context.
|
|
31
|
+
async function runWebhookEndpoint(context, { endpointId, body, query, headers }) {
|
|
32
|
+
const { logger } = context;
|
|
33
|
+
context.endpointId = endpointId;
|
|
34
|
+
context.evaluateOperators = createEvaluateOperators(context);
|
|
35
|
+
logger.debug({
|
|
36
|
+
event: 'debug_webhook_endpoint',
|
|
37
|
+
endpointId
|
|
38
|
+
});
|
|
39
|
+
const endpointConfig = await getEndpointConfig(context, {
|
|
40
|
+
endpointId
|
|
41
|
+
});
|
|
42
|
+
if (endpointConfig.webhook !== true) {
|
|
43
|
+
const err = new ConfigError(`API Endpoint "${endpointId}" does not exist.`);
|
|
44
|
+
logger.debug({
|
|
45
|
+
params: {
|
|
46
|
+
endpointId
|
|
47
|
+
},
|
|
48
|
+
err
|
|
49
|
+
}, err.message);
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
// Force a system context regardless of any session cookie sent with the request.
|
|
53
|
+
// system: true — nested CallApi steps are authorized like function calls (the run
|
|
54
|
+
// was already authorized at the transport layer), not re-gated on a user session.
|
|
55
|
+
context.session = undefined;
|
|
56
|
+
context.user = undefined;
|
|
57
|
+
context.system = true;
|
|
58
|
+
context.authorize = createAuthorize({
|
|
59
|
+
session: undefined,
|
|
60
|
+
system: true
|
|
61
|
+
});
|
|
62
|
+
const routineContext = {
|
|
63
|
+
steps: {},
|
|
64
|
+
payload: {
|
|
65
|
+
body: body ?? null,
|
|
66
|
+
query: query ?? {},
|
|
67
|
+
headers: headers ?? {}
|
|
68
|
+
},
|
|
69
|
+
arrayIndices: [],
|
|
70
|
+
items: {},
|
|
71
|
+
state: {},
|
|
72
|
+
endpointDepth: 0
|
|
73
|
+
};
|
|
74
|
+
const { error, response, status } = await runRoutine(context, routineContext, {
|
|
75
|
+
routine: endpointConfig.routine
|
|
76
|
+
});
|
|
77
|
+
return buildEndpointResult(context, {
|
|
78
|
+
error,
|
|
79
|
+
response,
|
|
80
|
+
status
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export default runWebhookEndpoint;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/ // Run work after the response is sent. Platforms that reap the invocation once
|
|
16
|
+
// the response is flushed (Vercel fluid compute) inject context.waitUntil in
|
|
17
|
+
// their server's apiContext middleware to keep the invocation alive until the
|
|
18
|
+
// promise settles (bounded by the function's maxDuration); locally /
|
|
19
|
+
// self-hosted there is no waitUntil and the promise simply runs detached on
|
|
20
|
+
// the still-alive Node process.
|
|
21
|
+
//
|
|
22
|
+
// The outcome only exists in logs — completion and failure are logged through
|
|
23
|
+
// the request logger so they reach the platform log stream (and any log
|
|
24
|
+
// drains); a background failure must never surface as an unhandled rejection.
|
|
25
|
+
function scheduleBackground(context, { event, endpointId }, fn) {
|
|
26
|
+
const { logger } = context;
|
|
27
|
+
const promise = (async ()=>{
|
|
28
|
+
try {
|
|
29
|
+
const result = await fn();
|
|
30
|
+
logger.info({
|
|
31
|
+
event: `${event}_done`,
|
|
32
|
+
endpointId,
|
|
33
|
+
status: result?.status
|
|
34
|
+
});
|
|
35
|
+
} catch (err) {
|
|
36
|
+
logger.error({
|
|
37
|
+
event: `${event}_failed`,
|
|
38
|
+
endpointId,
|
|
39
|
+
err
|
|
40
|
+
}, err.message);
|
|
41
|
+
}
|
|
42
|
+
})();
|
|
43
|
+
if (context.waitUntil) {
|
|
44
|
+
context.waitUntil(promise);
|
|
45
|
+
}
|
|
46
|
+
return promise;
|
|
47
|
+
}
|
|
48
|
+
export default scheduleBackground;
|