@lowdefy/api 6.0.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 (46) hide show
  1. package/dist/context/createEvaluateOperators.js +3 -1
  2. package/dist/context/getCurrentEnvironment.js +27 -0
  3. package/dist/index.js +2 -1
  4. package/dist/response/buildEndpointResult.js +3 -1
  5. package/dist/response/createWireProjection.js +88 -0
  6. package/dist/response/normalizeErrorSources.js +10 -13
  7. package/dist/response/redactErrorResponse.js +42 -8
  8. package/dist/response/redactResponse.js +10 -10
  9. package/dist/routes/agent/prepareAgent.js +6 -0
  10. package/dist/routes/auth/defaultAuthUrl.js +31 -0
  11. package/dist/routes/auth/getAuthConfig.js +5 -1
  12. package/dist/routes/endpoints/callEndpoint.js +1 -0
  13. package/dist/routes/endpoints/control/controlFor.js +4 -7
  14. package/dist/routes/endpoints/control/controlIf.js +4 -8
  15. package/dist/routes/endpoints/control/controlLog.js +6 -14
  16. package/dist/routes/endpoints/control/controlParallelFor.js +4 -7
  17. package/dist/routes/endpoints/control/controlReject.js +13 -15
  18. package/dist/routes/endpoints/control/controlReturn.js +4 -9
  19. package/dist/routes/endpoints/control/controlSetState.js +4 -8
  20. package/dist/routes/endpoints/control/controlSwitch.js +4 -8
  21. package/dist/routes/endpoints/control/controlThrow.js +17 -19
  22. package/dist/routes/endpoints/control/controlTry.js +20 -10
  23. package/dist/routes/endpoints/evaluateRoutineOperators.js +26 -0
  24. package/dist/routes/endpoints/forwardScheduledEndpoint.js +19 -14
  25. package/dist/routes/endpoints/getEnvironmentSchedules.js +5 -7
  26. package/dist/routes/endpoints/handleAgentCall.js +4 -7
  27. package/dist/routes/endpoints/handleEndpointCall.js +4 -7
  28. package/dist/routes/endpoints/handleRenderNotification.js +15 -8
  29. package/dist/routes/endpoints/handleRequest.js +1 -5
  30. package/dist/routes/endpoints/handleValidateSchema.js +12 -10
  31. package/dist/routes/endpoints/invokeEndpoint.js +1 -0
  32. package/dist/routes/endpoints/operatorScope.js +27 -0
  33. package/dist/routes/endpoints/resolveCronEnvironment.js +7 -6
  34. package/dist/routes/endpoints/runDetachedEndpoint.js +1 -0
  35. package/dist/routes/endpoints/runScheduledEndpoint.js +1 -0
  36. package/dist/routes/endpoints/runWebhookEndpoint.js +1 -0
  37. package/dist/routes/endpoints/test/runTest.js +17 -2
  38. package/dist/routes/mcp/createMcpServer.js +20 -2
  39. package/dist/routes/request/callRequest.js +9 -3
  40. package/dist/routes/request/callRequestResolver.js +6 -0
  41. package/dist/routes/request/evaluateOperators.js +8 -12
  42. package/dist/routes/websocket/createChannelRegistry.js +19 -5
  43. package/dist/routes/websocket/createWebSocketConnection.js +15 -3
  44. package/dist/test/testContext.js +4 -2
  45. package/package.json +9 -9
  46. package/dist/response/omitErrorProps.js +0 -51
@@ -12,7 +12,8 @@
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 runRoutine from '../runRoutine.js';
15
+ */ import { projectCaughtError } from '@lowdefy/helpers';
16
+ import runRoutine from '../runRoutine.js';
16
17
  async function controlTry(context, routineContext, { control }) {
17
18
  context.logger.debug({
18
19
  event: 'debug_control_try'
@@ -20,15 +21,24 @@ async function controlTry(context, routineContext, { control }) {
20
21
  let res = await runRoutine(context, routineContext, {
21
22
  routine: control[':try']
22
23
  });
23
- if (res.status === 'error') {
24
- if (control[':catch']) {
25
- context.logger.debug({
26
- event: 'debug_control_catch'
27
- });
28
- res = await runRoutine(context, routineContext, {
29
- routine: control[':catch']
30
- });
31
- }
24
+ if (res.status === 'error' && control[':catch']) {
25
+ context.logger.debug({
26
+ event: 'debug_control_catch'
27
+ });
28
+ // A child context rather than a field set on routineContext: the same object is handed to
29
+ // :try, :finally and every :parallel branch, so writing to it would let an inner :try clobber
30
+ // the outer catch's error and parallel branches read each other's. steps and state are still
31
+ // shared because routines mutate them in place. The projection is built once here, never the
32
+ // raw error, so no operator in the catch can read its received value or source.
33
+ const error = projectCaughtError(res.error, {
34
+ scrub: context.scrubSecrets
35
+ });
36
+ res = await runRoutine(context, {
37
+ ...routineContext,
38
+ error
39
+ }, {
40
+ routine: control[':catch']
41
+ });
32
42
  }
33
43
  if (control[':finally']) {
34
44
  context.logger.debug({
@@ -0,0 +1,26 @@
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 operatorScope from './operatorScope.js';
16
+ // Every routine step evaluates against the same frame. Taking the frame from routineContext in one
17
+ // place means a step handler cannot drop part of it (state, items, loop indices).
18
+ function evaluateRoutineOperators(context, routineContext, { input, location }) {
19
+ return context.evaluateOperators({
20
+ arrayIndices: routineContext.arrayIndices,
21
+ input,
22
+ location,
23
+ ...operatorScope(routineContext)
24
+ });
25
+ }
26
+ export default evaluateRoutineOperators;
@@ -18,29 +18,34 @@ import findSchedule from './findSchedule.js';
18
18
  import getEndpointConfig from './getEndpointConfig.js';
19
19
  import getEnvironmentSchedules from './getEnvironmentSchedules.js';
20
20
  import scheduleBackground from './scheduleBackground.js';
21
- // Vercel fires cron jobs only on the production deployment, so the schedules of every other
22
- // environment are registered there as /api/cron-forward/<environment>/<endpointId> jobs. When one
23
- // fires, this pings the environment's own /api/cron/<endpointId> with that environment's
24
- // CRON_SECRET (a Lowdefy secret named in config.cron.environments) so the environment runs its own
25
- // code, and answers Vercel immediately: the ping is fire-and-forget, kept alive by scheduleBackground
26
- // and bounded by the function duration, and its outcome exists only in the logs.
21
+ // Vercel fires cron jobs only on the production deployment, so the schedules of every environment
22
+ // with a cron.secret are registered there as /api/cron-forward/<environment>/<endpointId> jobs. When
23
+ // one fires, this pings the environment's own /api/cron/<endpointId> at its url with that
24
+ // environment's CRON_SECRET (the Lowdefy secret named by config.environments.<env>.cron.secret) so
25
+ // the environment runs its own code, and answers Vercel immediately: the ping is fire-and-forget,
26
+ // kept alive by scheduleBackground and bounded by the function duration, and its outcome exists only
27
+ // in the logs.
27
28
  async function forwardScheduledEndpoint(context, { environment, endpointId, cron }) {
28
29
  const { config, logger, secrets } = context;
29
- const target = config?.cron?.environments?.[environment];
30
+ const target = config?.environments?.[environment];
30
31
  if (!type.isObject(target)) {
31
- throw new ConfigError(`Cron environment "${environment}" is not declared in lowdefy.config.cron.environments.`);
32
+ throw new ConfigError(`Cron environment "${environment}" is not declared in config.environments.`);
32
33
  }
33
- if (type.isUndefined(target.url)) {
34
- throw new ConfigError(`Cron environment "${environment}" has no url to forward to: it is the environment that runs the crons.`);
34
+ if (environment === config.environment) {
35
+ throw new ConfigError(`Cron environment "${environment}" is this deployment's own environment: its crons run here, not forwarded.`);
35
36
  }
36
- if (target.enabled === false) {
37
+ if (target.cron?.enabled === false) {
37
38
  throw new ConfigError(`Cron environment "${environment}" is disabled.`);
38
39
  }
39
- const secret = secrets?.[target.secret];
40
+ const secretName = target.cron?.secret;
41
+ if (type.isUndefined(secretName)) {
42
+ throw new ConfigError(`Cron environment "${environment}" has no cron.secret: crons are only forwarded to environments with one.`);
43
+ }
44
+ const secret = secrets?.[secretName];
40
45
  if (!type.isString(secret) || secret === '') {
41
- throw new ConfigError(`Secret "${target.secret}" holding the CRON_SECRET of cron environment "${environment}" is not set. Set the LOWDEFY_SECRET_${target.secret} environment variable on this deployment.`);
46
+ throw new ConfigError(`Secret "${secretName}" holding the CRON_SECRET of cron environment "${environment}" is not set. Set the LOWDEFY_SECRET_${secretName} environment variable on this deployment.`);
42
47
  }
43
- // The same build is deployed to every environment, so the local artifact says whether the target
48
+ // Every environment builds from the same config, so the local artifact says whether the target
44
49
  // declares the firing schedule: fail here with a config error instead of having the target 500.
45
50
  const endpointConfig = await getEndpointConfig(context, {
46
51
  endpointId
@@ -13,17 +13,15 @@
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
- // With config.cron.environments declared the build resolves `schedules` onto every environment
17
- // (schedules.<name>, defaults inherited), so an environment run reads its own list; without
18
- // environments the endpoint carries a plain array.
16
+ // With config.environments declared the build resolves `schedules` onto every environment
17
+ // (schedules.<name>, defaults inherited, plus schedules.default), so an environment run reads its
18
+ // own list and a deployment with no current environment reads the default; without environments
19
+ // the endpoint carries a plain array.
19
20
  function getEnvironmentSchedules({ endpointConfig, environment }) {
20
21
  const schedules = endpointConfig.schedules;
21
22
  if (type.isArray(schedules)) {
22
23
  return schedules;
23
24
  }
24
- if (environment === undefined) {
25
- return [];
26
- }
27
- return schedules?.[environment] ?? [];
25
+ return schedules?.[environment ?? 'default'] ?? [];
28
26
  }
29
27
  export default getEnvironmentSchedules;
@@ -16,20 +16,17 @@
16
16
  import { type } from '@lowdefy/helpers';
17
17
  import addStepResult from './addStepResult.js';
18
18
  import prepareAgent from '../agent/prepareAgent.js';
19
+ import evaluateRoutineOperators from './evaluateRoutineOperators.js';
19
20
  async function handleAgentCall(context, routineContext, { step }) {
20
- const { logger, evaluateOperators } = context;
21
+ const { logger } = context;
21
22
  logger.debug({
22
23
  event: 'debug_start_agent_call',
23
24
  step
24
25
  });
25
26
  // Evaluate operators in step.properties (resolves agentId, prompt)
26
- const evaluatedProperties = evaluateOperators({
27
+ const evaluatedProperties = evaluateRoutineOperators(context, routineContext, {
27
28
  input: step.properties,
28
- items: routineContext.items,
29
- location: step.stepId,
30
- payload: routineContext.payload,
31
- state: routineContext.state,
32
- steps: routineContext.steps
29
+ location: step.stepId
33
30
  });
34
31
  const { agentId, prompt } = evaluatedProperties;
35
32
  if (!type.isString(agentId)) {
@@ -17,20 +17,17 @@ import { ConfigError } from '@lowdefy/errors';
17
17
  import addStepResult from './addStepResult.js';
18
18
  import invokeEndpoint from './invokeEndpoint.js';
19
19
  import scheduleBackground from './scheduleBackground.js';
20
+ import evaluateRoutineOperators from './evaluateRoutineOperators.js';
20
21
  async function handleEndpointCall(context, routineContext, { step }) {
21
- const { logger, evaluateOperators } = context;
22
+ const { logger } = context;
22
23
  logger.debug({
23
24
  event: 'debug_start_endpoint_call',
24
25
  step
25
26
  });
26
27
  // Evaluate operators in step.properties (resolves endpointId, payload)
27
- const evaluatedProperties = evaluateOperators({
28
+ const evaluatedProperties = evaluateRoutineOperators(context, routineContext, {
28
29
  input: step.properties,
29
- items: routineContext.items,
30
- location: step.stepId,
31
- payload: routineContext.payload,
32
- state: routineContext.state,
33
- steps: routineContext.steps
30
+ location: step.stepId
34
31
  });
35
32
  // detached: true — fire-and-forget the call back through the deployment's
36
33
  // /api/detached route, so the target runs in its OWN function invocation
@@ -16,10 +16,12 @@
16
16
  import { ConfigError } from '@lowdefy/errors';
17
17
  import { type } from '@lowdefy/helpers';
18
18
  import addStepResult from './addStepResult.js';
19
+ import getCurrentEnvironment from '../../context/getCurrentEnvironment.js';
19
20
  import derivePreview from '../notifications/derivePreview.js';
20
21
  import getNotificationConfig from '../notifications/getNotificationConfig.js';
21
22
  import resolveNotificationLinks from '../notifications/resolveNotificationLinks.js';
22
23
  import resolveThemeLogo from '../notifications/resolveThemeLogo.js';
24
+ import evaluateRoutineOperators from './evaluateRoutineOperators.js';
23
25
  function itemHasPageLinks(item, dataKeys) {
24
26
  const links = Object.values(item.links ?? {});
25
27
  const arrayLinks = dataKeys.flatMap((key)=>(type.isArray(item[key]) ? item[key] : []).map((entry)=>entry?.link));
@@ -29,18 +31,14 @@ function itemHasPageLinks(item, dataKeys) {
29
31
  ].some((link)=>type.isObject(link) && !type.isNone(link.pageId));
30
32
  }
31
33
  async function handleRenderNotification(context, routineContext, { step }) {
32
- const { logger, evaluateOperators } = context;
34
+ const { logger } = context;
33
35
  logger.debug({
34
36
  event: 'debug_start_render_notification',
35
37
  step
36
38
  });
37
- const evaluatedProperties = evaluateOperators({
39
+ const evaluatedProperties = evaluateRoutineOperators(context, routineContext, {
38
40
  input: step.properties,
39
- items: routineContext.items,
40
- location: step.stepId,
41
- payload: routineContext.payload,
42
- state: routineContext.state,
43
- steps: routineContext.steps
41
+ location: step.stepId
44
42
  });
45
43
  const { notificationId, data, landingPage, recordId } = evaluatedProperties;
46
44
  let { serverUrl } = evaluatedProperties;
@@ -65,6 +63,15 @@ async function handleRenderNotification(context, routineContext, { step }) {
65
63
  configKey: step['~k']
66
64
  });
67
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)) {
68
75
  serverUrl = serverUrl.replace(/\/$/, '');
69
76
  }
70
77
  if (!type.isNone(landingPage) && !type.isString(landingPage)) {
@@ -111,7 +118,7 @@ async function handleRenderNotification(context, routineContext, { step }) {
111
118
  });
112
119
  if (itemHasPageLinks(data, Template.dataKeys ?? [])) {
113
120
  if (type.isNone(serverUrl)) {
114
- throw new ConfigError(`Notification "${notificationId}" has links but no server URL is available. Set the serverUrl step property.`, {
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.`, {
115
122
  configKey: step['~k']
116
123
  });
117
124
  }
@@ -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,15 +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));
54
55
  // Log under `err` — see controlThrow: only the `err` key runs the pino error
55
56
  // serializer, so `error` would drop the message from the log line.
56
57
  logger.error({
57
58
  event: 'error_validate_schema',
58
59
  stepId: step.stepId,
59
- err: error
60
+ err: error,
61
+ errors: result.errors
60
62
  });
61
63
  return {
62
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,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;
@@ -16,22 +16,23 @@
16
16
  import { ConfigError } from '@lowdefy/errors';
17
17
  // The environment a cron run resolves its schedules for. A forwarded request names it in the
18
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.
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.
21
22
  function resolveCronEnvironment({ config, environment }) {
22
- const environments = config?.cron?.environments;
23
+ const environments = config?.environments;
23
24
  if (type.isNone(environments)) {
24
25
  if (!type.isNone(environment)) {
25
- throw new ConfigError(`Cron environment "${environment}" is not configured: lowdefy.config.cron.environments is not defined.`);
26
+ throw new ConfigError(`Cron environment "${environment}" is not configured: config.environments is not defined.`);
26
27
  }
27
28
  return undefined;
28
29
  }
29
30
  if (!type.isNone(environment)) {
30
31
  if (!type.isObject(environments[environment])) {
31
- throw new ConfigError(`Cron environment "${environment}" is not declared in lowdefy.config.cron.environments.`);
32
+ throw new ConfigError(`Cron environment "${environment}" is not declared in config.environments.`);
32
33
  }
33
34
  return environment;
34
35
  }
35
- return Object.keys(environments).find((name)=>type.isObject(environments[name]) && type.isUndefined(environments[name].url));
36
+ return config.environment;
36
37
  }
37
38
  export default resolveCronEnvironment;
@@ -50,6 +50,7 @@ async function runDetachedEndpoint(context, { endpointId, payload }) {
50
50
  steps: {},
51
51
  payload: serializer.deserialize(payload ?? {}),
52
52
  arrayIndices: [],
53
+ error: null,
53
54
  items: {},
54
55
  state: {},
55
56
  endpointDepth: 0
@@ -70,6 +70,7 @@ async function runScheduledEndpoint(context, { endpointId, cron, environment })
70
70
  steps: {},
71
71
  payload: schedule.payload ?? {},
72
72
  arrayIndices: [],
73
+ error: null,
73
74
  items: {},
74
75
  state: {},
75
76
  endpointDepth: 0
@@ -67,6 +67,7 @@ async function runWebhookEndpoint(context, { endpointId, body, query, headers })
67
67
  headers: headers ?? {}
68
68
  },
69
69
  arrayIndices: [],
70
+ error: null,
70
71
  items: {},
71
72
  state: {},
72
73
  endpointDepth: 0
@@ -1,4 +1,18 @@
1
- import { jest } from '@jest/globals';
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 { jest } from '@jest/globals';
2
16
  import { wait } from '@lowdefy/helpers';
3
17
  import { operatorsServer } from '@lowdefy/operators-js';
4
18
  import createEvaluateOperators from '../../../context/createEvaluateOperators.js';
@@ -7,7 +21,7 @@ import testContext from '../../../test/testContext.js';
7
21
  // const { _date, _eq, _payload, _secret, _user } = operatorsServer;
8
22
  const operators = {
9
23
  ...operatorsServer,
10
- _error: ()=>{
24
+ _throw_test: ()=>{
11
25
  throw new Error('Test error.');
12
26
  }
13
27
  };
@@ -89,6 +103,7 @@ async function runTest({ routine, payload = {} }) {
89
103
  const routineContext = {
90
104
  steps: {},
91
105
  payload,
106
+ error: null,
92
107
  items: {},
93
108
  arrayIndices: [],
94
109
  state: {},
@@ -17,6 +17,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
17
17
  import { AuthenticationError } from '@lowdefy/errors';
18
18
  import { serializer, type } from '@lowdefy/helpers';
19
19
  import callEndpoint from '../endpoints/callEndpoint.js';
20
+ import createWireProjection from '../../response/createWireProjection.js';
20
21
  import isUnauthenticatedHuman from '../endpoints/isUnauthenticatedHuman.js';
21
22
  // LLM-safe tool names use the same rule as buildAgents tool naming.
22
23
  function toToolName(id) {
@@ -102,11 +103,25 @@ async function createMcpServer({ context }) {
102
103
  });
103
104
  if (!success) {
104
105
  const deserialized = serializer.deserialize(error);
106
+ let text = deserialized?.message ?? 'Endpoint failed.';
107
+ // The dev MCP route serves a coding agent, which is a dev tool: it gets the real
108
+ // message and the config location so it can go straight to the YAML. A prod MCP
109
+ // client is an end-user reader and keeps the wire message.
110
+ if (context.mode === 'dev' && !type.isNone(error?.devError)) {
111
+ const devError = serializer.deserialize(error.devError);
112
+ text = devError.message;
113
+ if (!type.isNone(devError.source)) {
114
+ text = `${text} (at ${devError.source})`;
115
+ }
116
+ if (!type.isNone(devError.hint)) {
117
+ text = `${text} Hint: ${devError.hint}`;
118
+ }
119
+ }
105
120
  return {
106
121
  content: [
107
122
  {
108
123
  type: 'text',
109
- text: deserialized?.message ?? 'Endpoint failed.'
124
+ text
110
125
  }
111
126
  ],
112
127
  isError: true
@@ -144,11 +159,14 @@ async function createMcpServer({ context }) {
144
159
  } else {
145
160
  context.logger.error(error);
146
161
  }
162
+ // This error never went through an endpoint result, so it has not been projected yet.
163
+ // The projection keeps an AuthenticationError's message.
164
+ const text = context.mode === 'dev' ? error.message : createWireProjection(context)(error).message;
147
165
  return {
148
166
  content: [
149
167
  {
150
168
  type: 'text',
151
- text: error.message
169
+ text
152
170
  }
153
171
  ],
154
172
  isError: true
@@ -59,10 +59,16 @@ async function callRequest(context, { blockId, pageId, payload, requestId }) {
59
59
  });
60
60
  const { connectionProperties, requestProperties } = evaluateOperators(context, {
61
61
  connectionConfig,
62
- payload: requestPayload,
63
62
  requestConfig,
64
- state: {},
65
- steps: {}
63
+ // A page request runs outside any routine, so it evaluates against an empty frame.
64
+ routineContext: {
65
+ arrayIndices: [],
66
+ error: null,
67
+ items: {},
68
+ payload: requestPayload,
69
+ state: {},
70
+ steps: {}
71
+ }
66
72
  });
67
73
  checkConnectionRead(context, {
68
74
  connectionConfig,
@@ -13,6 +13,7 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { RequestError, ServiceError } from '@lowdefy/errors';
16
+ import getCurrentEnvironment from '../../context/getCurrentEnvironment.js';
16
17
  import invokeEndpoint from '../endpoints/invokeEndpoint.js';
17
18
  async function callRequestResolver(context, { connectionProperties, endpointDepth, requestConfig, requestProperties, requestResolver }) {
18
19
  const { blockId, endpointId, logger, pageId, payload } = context;
@@ -49,6 +50,11 @@ async function callRequestResolver(context, { connectionProperties, endpointDept
49
50
  connection: connectionProperties,
50
51
  connectionId: requestConfig.connectionId,
51
52
  endpointId,
53
+ // The deployment's current environment ({ name, url, email, ... }) or null, so connection
54
+ // types can apply environment settings such as the email delivery filter.
55
+ environment: getCurrentEnvironment({
56
+ config: context.config
57
+ }),
52
58
  pageId,
53
59
  payload,
54
60
  request: requestProperties,
@@ -12,21 +12,17 @@
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
- */ function evaluateOperators({ evaluateOperators }, { connectionConfig, items, payload, requestConfig, state, steps }) {
16
- const connectionProperties = evaluateOperators({
15
+ */ import evaluateRoutineOperators from '../endpoints/evaluateRoutineOperators.js';
16
+ // Connection and request properties evaluate against the same frame, so `_item` in a connection's
17
+ // properties resolves inside a `:for` loop just like it does in the request's.
18
+ function evaluateOperators(context, { connectionConfig, requestConfig, routineContext }) {
19
+ const connectionProperties = evaluateRoutineOperators(context, routineContext, {
17
20
  input: connectionConfig.properties || {},
18
- location: connectionConfig.connectionId,
19
- payload,
20
- state,
21
- steps
21
+ location: connectionConfig.connectionId
22
22
  });
23
- const requestProperties = evaluateOperators({
23
+ const requestProperties = evaluateRoutineOperators(context, routineContext, {
24
24
  input: requestConfig.properties || {},
25
- items,
26
- location: requestConfig.stepId ?? requestConfig.requestId,
27
- payload,
28
- state,
29
- steps
25
+ location: requestConfig.stepId ?? requestConfig.requestId
30
26
  });
31
27
  return {
32
28
  connectionProperties,
@@ -15,6 +15,7 @@
15
15
  */ import crypto from 'crypto';
16
16
  import { serializer } from '@lowdefy/helpers';
17
17
  import { ConfigError, PluginError, ServiceError } from '@lowdefy/errors';
18
+ import createWireProjection from '../../response/createWireProjection.js';
18
19
  import prepareChannel from './prepareChannel.js';
19
20
  const MAX_RETRIES = 5;
20
21
  const RETRY_BASE_MS = 1000;
@@ -39,6 +40,23 @@ function createChannelRegistry() {
39
40
  subscriber.send(message);
40
41
  });
41
42
  }
43
+ // Config reads a broadcast error only as a message string, so it gets the wire
44
+ // message alone - no requestId, since no single request raised it, and no
45
+ // devError, since the dev terminal already logs it in full. The channel's
46
+ // context belongs to whoever subscribed first, so the message is translated
47
+ // with each subscriber's own locale.
48
+ function broadcastError(channel, error) {
49
+ channel.subscribers.forEach((subscriber)=>{
50
+ const { message } = createWireProjection({
51
+ i18n: subscriber.i18n
52
+ })(error);
53
+ subscriber.send(JSON.stringify({
54
+ type: 'error',
55
+ websocketId: channel.websocketId,
56
+ message
57
+ }));
58
+ });
59
+ }
42
60
  function wrapResolverError({ channel, error }) {
43
61
  if (!error.configKey) {
44
62
  error.configKey = channel.websocketConfig['~k'];
@@ -86,11 +104,7 @@ function createChannelRegistry() {
86
104
  err: wrapped
87
105
  }, wrapped.message);
88
106
  channel.context.handleError(wrapped);
89
- broadcast(channel, {
90
- type: 'error',
91
- websocketId: channel.websocketId,
92
- message: wrapped.message
93
- });
107
+ broadcastError(channel, wrapped);
94
108
  // A run that stayed healthy resets the backoff window.
95
109
  if (Date.now() - startedAt > HEALTHY_RESET_MS) {
96
110
  channel.retryCount = 0;