@lowdefy/api 5.6.0 → 6.1.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.
Files changed (72) hide show
  1. package/dist/context/createAuthorize.js +4 -1
  2. package/dist/context/createEvaluateOperators.js +3 -1
  3. package/dist/context/getCurrentEnvironment.js +27 -0
  4. package/dist/context/resolveStrategyCaller.js +56 -0
  5. package/dist/index.js +13 -2
  6. package/dist/response/buildEndpointResult.js +3 -1
  7. package/dist/response/createWireProjection.js +88 -0
  8. package/dist/response/normalizeErrorSources.js +10 -13
  9. package/dist/response/redactErrorResponse.js +42 -8
  10. package/dist/response/redactResponse.js +10 -10
  11. package/dist/routes/agent/callAgent.js +7 -184
  12. package/dist/routes/agent/prepareAgent.js +179 -0
  13. package/dist/routes/auth/createLogger.js +2 -5
  14. package/dist/routes/auth/createPrefixedCookies.js +52 -0
  15. package/dist/routes/auth/defaultAuthUrl.js +31 -0
  16. package/dist/routes/auth/{getNextAuthConfig.js → getAuthConfig.js} +31 -16
  17. package/dist/routes/auth/resolveCookies.js +37 -0
  18. package/dist/routes/auth/strategies/createAuthStrategies.js +77 -0
  19. package/dist/routes/auth/strategies/getAuthStrategies.js +31 -0
  20. package/dist/routes/endpoints/authorizeApiEndpoint.js +8 -2
  21. package/dist/routes/endpoints/callEndpoint.js +27 -3
  22. package/dist/routes/endpoints/control/controlFor.js +4 -7
  23. package/dist/routes/endpoints/control/controlIf.js +4 -8
  24. package/dist/routes/endpoints/control/controlLog.js +6 -14
  25. package/dist/routes/endpoints/control/controlParallelFor.js +4 -7
  26. package/dist/routes/endpoints/control/controlReject.js +16 -16
  27. package/dist/routes/endpoints/control/controlReturn.js +4 -9
  28. package/dist/routes/endpoints/control/controlSetState.js +4 -8
  29. package/dist/routes/endpoints/control/controlSwitch.js +4 -8
  30. package/dist/routes/endpoints/control/controlThrow.js +21 -20
  31. package/dist/routes/endpoints/control/controlTry.js +20 -10
  32. package/dist/routes/endpoints/evaluateRoutineOperators.js +26 -0
  33. package/dist/routes/endpoints/findSchedule.js +35 -0
  34. package/dist/routes/endpoints/forwardScheduledEndpoint.js +111 -0
  35. package/dist/routes/endpoints/getEndpointConfig.js +9 -3
  36. package/dist/routes/endpoints/getEnvironmentSchedules.js +27 -0
  37. package/dist/routes/endpoints/handleAgentCall.js +80 -0
  38. package/dist/routes/endpoints/handleEndpointCall.js +53 -8
  39. package/dist/routes/endpoints/handleRenderNotification.js +196 -0
  40. package/dist/routes/endpoints/handleRequest.js +1 -5
  41. package/dist/routes/endpoints/handleValidateSchema.js +14 -10
  42. package/dist/routes/endpoints/invokeEndpoint.js +1 -0
  43. package/dist/routes/endpoints/isUnauthenticatedHuman.js +31 -0
  44. package/dist/routes/endpoints/operatorScope.js +27 -0
  45. package/dist/routes/endpoints/resolveCronEnvironment.js +38 -0
  46. package/dist/routes/endpoints/runDetachedEndpoint.js +67 -0
  47. package/dist/routes/endpoints/runRoutine.js +12 -0
  48. package/dist/routes/endpoints/runScheduledEndpoint.js +105 -0
  49. package/dist/routes/endpoints/runWebhookEndpoint.js +84 -0
  50. package/dist/routes/endpoints/scheduleBackground.js +48 -0
  51. package/dist/routes/endpoints/test/runTest.js +17 -2
  52. package/dist/routes/mcp/createMcpServer.js +178 -0
  53. package/dist/routes/notifications/derivePreview.js +30 -0
  54. package/dist/routes/notifications/getNotificationConfig.js +32 -0
  55. package/dist/routes/notifications/resolveNotificationLinks.js +72 -0
  56. package/dist/routes/notifications/resolveThemeLogo.js +35 -0
  57. package/dist/routes/page/dynamic/resolveDynamicContent.js +172 -0
  58. package/dist/routes/page/dynamic/unescapeOperators.js +36 -0
  59. package/dist/routes/page/dynamic/validateFragment.js +104 -0
  60. package/dist/routes/page/getPageConfig.js +16 -6
  61. package/dist/routes/request/callRequest.js +9 -3
  62. package/dist/routes/request/callRequestResolver.js +6 -0
  63. package/dist/routes/request/evaluateOperators.js +8 -12
  64. package/dist/routes/websocket/authorizeWebsocket.js +27 -0
  65. package/dist/routes/websocket/createChannelRegistry.js +283 -0
  66. package/dist/routes/websocket/createWebSocketConnection.js +143 -0
  67. package/dist/routes/websocket/getWebsocketConfig.js +30 -0
  68. package/dist/routes/websocket/getWebsocketResolver.js +33 -0
  69. package/dist/routes/websocket/prepareChannel.js +72 -0
  70. package/dist/test/testContext.js +7 -3
  71. package/package.json +12 -10
  72. package/dist/response/omitErrorProps.js +0 -51
@@ -12,23 +12,68 @@
12
12
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
- */ import addStepResult from './addStepResult.js';
15
+ */ import { serializer } from '@lowdefy/helpers';
16
+ import { ConfigError } from '@lowdefy/errors';
17
+ import addStepResult from './addStepResult.js';
16
18
  import invokeEndpoint from './invokeEndpoint.js';
19
+ import scheduleBackground from './scheduleBackground.js';
20
+ import evaluateRoutineOperators from './evaluateRoutineOperators.js';
17
21
  async function handleEndpointCall(context, routineContext, { step }) {
18
- const { logger, evaluateOperators } = context;
22
+ const { logger } = context;
19
23
  logger.debug({
20
24
  event: 'debug_start_endpoint_call',
21
25
  step
22
26
  });
23
27
  // Evaluate operators in step.properties (resolves endpointId, payload)
24
- const evaluatedProperties = evaluateOperators({
28
+ const evaluatedProperties = evaluateRoutineOperators(context, routineContext, {
25
29
  input: step.properties,
26
- items: routineContext.items,
27
- location: step.stepId,
28
- payload: routineContext.payload,
29
- state: routineContext.state,
30
- steps: routineContext.steps
30
+ location: step.stepId
31
31
  });
32
+ // detached: true — fire-and-forget the call back through the deployment's
33
+ // /api/detached route, so the target runs in its OWN function invocation
34
+ // with a fresh duration budget (chainable bounded work without a queue).
35
+ // At-most-once, no retry: targets must be idempotent. The dispatch promise
36
+ // rides the platform request context so the outgoing request always leaves
37
+ // before the invocation is reaped; the target's own outcome exists only in
38
+ // its logs and whatever its routine writes.
39
+ if (evaluatedProperties.detached === true) {
40
+ if (!process.env.CRON_SECRET) {
41
+ throw new ConfigError('CallApi with "detached: true" requires the CRON_SECRET environment variable — the /api/detached route fails closed without it.');
42
+ }
43
+ if (!context.origin) {
44
+ throw new ConfigError('Detached endpoint calls require the request origin on context.');
45
+ }
46
+ const targetEndpointId = evaluatedProperties.endpointId;
47
+ scheduleBackground(context, {
48
+ event: 'detached_dispatch',
49
+ endpointId: targetEndpointId
50
+ }, ()=>fetch(`${context.origin}/api/detached/${targetEndpointId}`, {
51
+ method: 'POST',
52
+ headers: {
53
+ 'content-type': 'application/json',
54
+ authorization: `Bearer ${process.env.CRON_SECRET}`
55
+ },
56
+ body: JSON.stringify({
57
+ payload: serializer.serialize(evaluatedProperties.payload ?? {})
58
+ })
59
+ }));
60
+ addStepResult(context, routineContext, {
61
+ result: {
62
+ detached: true,
63
+ endpointId: targetEndpointId
64
+ },
65
+ stepId: step.stepId
66
+ });
67
+ logger.debug({
68
+ event: 'debug_end_endpoint_call',
69
+ stepId: step.stepId,
70
+ targetEndpointId,
71
+ detached: true
72
+ });
73
+ return {
74
+ status: 'continue'
75
+ };
76
+ }
32
77
  const result = await invokeEndpoint(context, {
33
78
  endpointId: evaluatedProperties.endpointId,
34
79
  payload: evaluatedProperties.payload,
@@ -0,0 +1,196 @@
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 getCurrentEnvironment from '../../context/getCurrentEnvironment.js';
20
+ import derivePreview from '../notifications/derivePreview.js';
21
+ import getNotificationConfig from '../notifications/getNotificationConfig.js';
22
+ import resolveNotificationLinks from '../notifications/resolveNotificationLinks.js';
23
+ import resolveThemeLogo from '../notifications/resolveThemeLogo.js';
24
+ import evaluateRoutineOperators from './evaluateRoutineOperators.js';
25
+ function itemHasPageLinks(item, dataKeys) {
26
+ const links = Object.values(item.links ?? {});
27
+ const arrayLinks = dataKeys.flatMap((key)=>(type.isArray(item[key]) ? item[key] : []).map((entry)=>entry?.link));
28
+ return [
29
+ ...links,
30
+ ...arrayLinks
31
+ ].some((link)=>type.isObject(link) && !type.isNone(link.pageId));
32
+ }
33
+ async function handleRenderNotification(context, routineContext, { step }) {
34
+ const { logger } = context;
35
+ logger.debug({
36
+ event: 'debug_start_render_notification',
37
+ step
38
+ });
39
+ const evaluatedProperties = evaluateRoutineOperators(context, routineContext, {
40
+ input: step.properties,
41
+ location: step.stepId
42
+ });
43
+ const { notificationId, data, landingPage, recordId } = evaluatedProperties;
44
+ let { serverUrl } = evaluatedProperties;
45
+ if (!type.isString(notificationId)) {
46
+ throw new ConfigError(`RenderNotification step "${step.stepId}" properties.notificationId must evaluate to a string. Received ${JSON.stringify(notificationId)}.`, {
47
+ configKey: step['~k']
48
+ });
49
+ }
50
+ if (type.isArray(data)) {
51
+ 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.`, {
52
+ configKey: step['~k']
53
+ });
54
+ }
55
+ if (!type.isObject(data)) {
56
+ throw new ConfigError(`RenderNotification step "${step.stepId}" properties.data must evaluate to an object. Received ${JSON.stringify(data)}.`, {
57
+ configKey: step['~k']
58
+ });
59
+ }
60
+ if (!type.isNone(serverUrl)) {
61
+ if (!type.isString(serverUrl) || serverUrl === '') {
62
+ throw new ConfigError(`RenderNotification step "${step.stepId}" properties.serverUrl must evaluate to a non-empty string. Received ${JSON.stringify(serverUrl)}.`, {
63
+ configKey: step['~k']
64
+ });
65
+ }
66
+ } else {
67
+ // The current environment's url is the deployment origin, so links need no per-step wiring.
68
+ // On the dev server the request origin is the local app; in production it is never used, since
69
+ // a spoofed Host header would then steer the links in outgoing mail.
70
+ serverUrl = getCurrentEnvironment({
71
+ config: context.config
72
+ })?.url ?? (context.dev === true ? context.origin : undefined);
73
+ }
74
+ if (!type.isNone(serverUrl)) {
75
+ serverUrl = serverUrl.replace(/\/$/, '');
76
+ }
77
+ if (!type.isNone(landingPage) && !type.isString(landingPage)) {
78
+ throw new ConfigError(`RenderNotification step "${step.stepId}" properties.landingPage must evaluate to a string. Received ${JSON.stringify(landingPage)}.`, {
79
+ configKey: step['~k']
80
+ });
81
+ }
82
+ if (!type.isNone(recordId) && !type.isString(recordId)) {
83
+ throw new ConfigError(`RenderNotification step "${step.stepId}" properties.recordId must evaluate to a string. Received ${JSON.stringify(recordId)}.`, {
84
+ configKey: step['~k']
85
+ });
86
+ }
87
+ const notificationConfig = await getNotificationConfig(context, {
88
+ notificationId,
89
+ configKey: step['~k']
90
+ });
91
+ const configKey = notificationConfig['~k'];
92
+ const Template = context.notifications[notificationConfig.type];
93
+ if (!Template) {
94
+ throw new ConfigError(`Notification template type "${notificationConfig.type}" can not be found.`, {
95
+ configKey
96
+ });
97
+ }
98
+ const { renderEmail, interpolateProperties } = context;
99
+ if (!type.isFunction(renderEmail) || !type.isFunction(interpolateProperties)) {
100
+ // plugins/notifications.js exports real implementations whenever the app
101
+ // has notifications built — reaching this means the build artifact and
102
+ // config are out of sync.
103
+ throw new ConfigError('Email rendering is not available. Rebuild the app — @lowdefy/email-templates is installed when "notifications:" is configured.', {
104
+ configKey
105
+ });
106
+ }
107
+ const app = await context.readConfigFile('app.json') ?? {};
108
+ const basePath = context.config?.basePath ?? '';
109
+ // Resolve after the merge so a per-notification theme.logo override also
110
+ // resolves; precedence stays notification theme over app.email.
111
+ const theme = resolveThemeLogo({
112
+ theme: {
113
+ ...app.email ?? {},
114
+ ...notificationConfig.theme ?? {}
115
+ },
116
+ serverUrl,
117
+ basePath
118
+ });
119
+ if (itemHasPageLinks(data, Template.dataKeys ?? [])) {
120
+ if (type.isNone(serverUrl)) {
121
+ throw new ConfigError(`Notification "${notificationId}" has links but no server URL is available. Set the serverUrl step property, or the url of the current environment in config.environments.`, {
122
+ configKey: step['~k']
123
+ });
124
+ }
125
+ // Landing URLs embed the record id — without it the landing page cannot
126
+ // resolve the record and every link in the email would be dead.
127
+ if (!type.isNone(landingPage) && type.isNone(recordId)) {
128
+ throw new ConfigError(`RenderNotification step "${step.stepId}" properties.landingPage requires properties.recordId to compose landing URLs.`, {
129
+ configKey: step['~k']
130
+ });
131
+ }
132
+ }
133
+ const resolvedItem = resolveNotificationLinks({
134
+ item: data,
135
+ dataKeys: Template.dataKeys ?? [],
136
+ serverUrl: serverUrl ?? '',
137
+ basePath,
138
+ landingPage,
139
+ recordId
140
+ });
141
+ let interpolated;
142
+ try {
143
+ interpolated = interpolateProperties({
144
+ properties: notificationConfig.properties,
145
+ data: resolvedItem,
146
+ markdownProperties: Template.markdownProperties ?? []
147
+ });
148
+ } catch (error) {
149
+ throw new ConfigError(`Notification "${notificationId}" template interpolation failed: ${error.message}`, {
150
+ configKey,
151
+ cause: error
152
+ });
153
+ }
154
+ if (Template.schema) {
155
+ const { valid, errors } = validate({
156
+ schema: Template.schema,
157
+ data: interpolated,
158
+ returnErrors: true
159
+ });
160
+ if (!valid) {
161
+ throw new ConfigError(`Notification "${notificationId}" properties do not match template "${notificationConfig.type}" schema: ${errors?.[0]?.message}.`, {
162
+ configKey
163
+ });
164
+ }
165
+ }
166
+ const { html, text } = await renderEmail({
167
+ Template,
168
+ properties: interpolated,
169
+ data: resolvedItem,
170
+ theme,
171
+ links: resolvedItem.links ?? {}
172
+ });
173
+ const result = {
174
+ subject: interpolated.subject,
175
+ title: interpolated.title ?? interpolated.subject,
176
+ preview: derivePreview({
177
+ properties: interpolated
178
+ }),
179
+ html,
180
+ text,
181
+ data: resolvedItem
182
+ };
183
+ addStepResult(context, routineContext, {
184
+ result,
185
+ stepId: step.stepId
186
+ });
187
+ logger.debug({
188
+ event: 'debug_end_render_notification',
189
+ stepId: step.stepId,
190
+ notificationId
191
+ });
192
+ return {
193
+ status: 'continue'
194
+ };
195
+ }
196
+ export default handleRenderNotification;
@@ -23,7 +23,6 @@ import getRequestResolver from '../request/getRequestResolver.js';
23
23
  import validateSchemas from '../request/validateSchemas.js';
24
24
  async function handleRequest(context, routineContext, { request }) {
25
25
  const { logger } = context;
26
- const { items } = routineContext;
27
26
  logger.debug({
28
27
  event: 'debug_start_request',
29
28
  request
@@ -42,11 +41,8 @@ async function handleRequest(context, routineContext, { request }) {
42
41
  });
43
42
  const { connectionProperties, requestProperties } = evaluateOperators(context, {
44
43
  connectionConfig,
45
- items,
46
- payload: routineContext.payload,
47
44
  requestConfig,
48
- state: routineContext.state,
49
- steps: routineContext.steps
45
+ routineContext
50
46
  });
51
47
  checkConnectionRead(context, {
52
48
  connectionConfig,
@@ -13,7 +13,9 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { validate } from '@lowdefy/ajv';
16
+ import { UserError } from '@lowdefy/errors';
16
17
  import addStepResult from './addStepResult.js';
18
+ import evaluateRoutineOperators from './evaluateRoutineOperators.js';
17
19
  function buildErrorMessage(errors, stepId) {
18
20
  const first = errors?.[0];
19
21
  if (!first) return `ValidateSchema step "${stepId}" failed.`;
@@ -21,17 +23,14 @@ function buildErrorMessage(errors, stepId) {
21
23
  return `ValidateSchema step "${stepId}" failed at ${path}: ${first.message}.`;
22
24
  }
23
25
  async function handleValidateSchema(context, routineContext, { step }) {
24
- const { logger, evaluateOperators } = context;
26
+ const { logger } = context;
25
27
  logger.debug({
26
28
  event: 'debug_start_validate_schema',
27
29
  step
28
30
  });
29
- const evaluatedProperties = evaluateOperators({
31
+ const evaluatedProperties = evaluateRoutineOperators(context, routineContext, {
30
32
  input: step.properties,
31
- items: routineContext.items,
32
- location: step.stepId,
33
- payload: routineContext.payload,
34
- steps: routineContext.steps
33
+ location: step.stepId
35
34
  });
36
35
  const { schema, data, throwOnInvalid = true } = evaluatedProperties;
37
36
  const { valid, errors } = validate({
@@ -48,13 +47,18 @@ async function handleValidateSchema(context, routineContext, { step }) {
48
47
  stepId: step.stepId
49
48
  });
50
49
  if (!valid && throwOnInvalid) {
51
- const error = new Error(buildErrorMessage(result.errors, step.stepId), {
52
- cause: result.errors
53
- });
50
+ // A UserError, so the caller sees which field failed: the message is built from the schema
51
+ // path and rule, never the data, and every other server error reaches the client generic.
52
+ // No cause: a UserError's cause crosses the wire as author data, and the ajv errors carry
53
+ // server schema detail. They stay in the step result and the log.
54
+ const error = new UserError(buildErrorMessage(result.errors, step.stepId));
55
+ // Log under `err` — see controlThrow: only the `err` key runs the pino error
56
+ // serializer, so `error` would drop the message from the log line.
54
57
  logger.error({
55
58
  event: 'error_validate_schema',
56
59
  stepId: step.stepId,
57
- error
60
+ err: error,
61
+ errors: result.errors
58
62
  });
59
63
  return {
60
64
  status: 'error',
@@ -30,6 +30,7 @@ async function invokeEndpoint(context, { endpointId, payload, endpointDepth }) {
30
30
  steps: {},
31
31
  payload: payload ?? {},
32
32
  arrayIndices: [],
33
+ error: null,
33
34
  items: {},
34
35
  state: {},
35
36
  endpointDepth: endpointDepth + 1
@@ -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,27 @@
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
+ */ // The routine fields operators can read. A field added to the routine frame goes here, so every
16
+ // routine step and control sees it rather than only the ones that remembered to pass it.
17
+ function operatorScope(routineContext) {
18
+ const { error, items, payload, state, steps } = routineContext;
19
+ return {
20
+ error: error ?? null,
21
+ items,
22
+ payload,
23
+ state,
24
+ steps
25
+ };
26
+ }
27
+ export default operatorScope;
@@ -0,0 +1,38 @@
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 current environment (config.environment). Without config.environments there are no
20
+ // environments and the endpoint's plain schedules apply; without a current environment the
21
+ // `default` schedules apply.
22
+ function resolveCronEnvironment({ config, environment }) {
23
+ const environments = config?.environments;
24
+ if (type.isNone(environments)) {
25
+ if (!type.isNone(environment)) {
26
+ throw new ConfigError(`Cron environment "${environment}" is not configured: config.environments is not defined.`);
27
+ }
28
+ return undefined;
29
+ }
30
+ if (!type.isNone(environment)) {
31
+ if (!type.isObject(environments[environment])) {
32
+ throw new ConfigError(`Cron environment "${environment}" is not declared in config.environments.`);
33
+ }
34
+ return environment;
35
+ }
36
+ return config.environment;
37
+ }
38
+ export default resolveCronEnvironment;
@@ -0,0 +1,67 @@
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
+ error: null,
54
+ items: {},
55
+ state: {},
56
+ endpointDepth: 0
57
+ };
58
+ const { error, response, status } = await runRoutine(context, routineContext, {
59
+ routine: endpointConfig.routine
60
+ });
61
+ return buildEndpointResult(context, {
62
+ error,
63
+ response,
64
+ status
65
+ });
66
+ }
67
+ 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,105 @@
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
+ error: null,
74
+ items: {},
75
+ state: {},
76
+ endpointDepth: 0
77
+ };
78
+ // async: true — acknowledge the cron trigger immediately and run in the
79
+ // background; transport auth (CRON_SECRET) already passed at the route.
80
+ if (endpointConfig.async === true) {
81
+ scheduleBackground(context, {
82
+ event: 'background_scheduled_endpoint',
83
+ endpointId
84
+ }, ()=>runRoutine(context, routineContext, {
85
+ routine: endpointConfig.routine
86
+ }));
87
+ return {
88
+ error: null,
89
+ response: serializer.serialize({
90
+ accepted: true
91
+ }),
92
+ status: 'accepted',
93
+ success: true
94
+ };
95
+ }
96
+ const { error, response, status } = await runRoutine(context, routineContext, {
97
+ routine: endpointConfig.routine
98
+ });
99
+ return buildEndpointResult(context, {
100
+ error,
101
+ response,
102
+ status
103
+ });
104
+ }
105
+ export default runScheduledEndpoint;