@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.
Files changed (47) hide show
  1. package/dist/context/createAuthorize.js +4 -1
  2. package/dist/context/resolveStrategyCaller.js +56 -0
  3. package/dist/index.js +12 -2
  4. package/dist/routes/agent/callAgent.js +7 -184
  5. package/dist/routes/agent/prepareAgent.js +173 -0
  6. package/dist/routes/auth/createLogger.js +2 -5
  7. package/dist/routes/auth/createPrefixedCookies.js +52 -0
  8. package/dist/routes/auth/{getNextAuthConfig.js → getAuthConfig.js} +27 -16
  9. package/dist/routes/auth/resolveCookies.js +37 -0
  10. package/dist/routes/auth/strategies/createAuthStrategies.js +77 -0
  11. package/dist/routes/auth/strategies/getAuthStrategies.js +31 -0
  12. package/dist/routes/endpoints/authorizeApiEndpoint.js +8 -2
  13. package/dist/routes/endpoints/callEndpoint.js +26 -3
  14. package/dist/routes/endpoints/control/controlReject.js +3 -1
  15. package/dist/routes/endpoints/control/controlThrow.js +4 -1
  16. package/dist/routes/endpoints/findSchedule.js +35 -0
  17. package/dist/routes/endpoints/forwardScheduledEndpoint.js +106 -0
  18. package/dist/routes/endpoints/getEndpointConfig.js +9 -3
  19. package/dist/routes/endpoints/getEnvironmentSchedules.js +29 -0
  20. package/dist/routes/endpoints/handleAgentCall.js +83 -0
  21. package/dist/routes/endpoints/handleEndpointCall.js +49 -1
  22. package/dist/routes/endpoints/handleRenderNotification.js +189 -0
  23. package/dist/routes/endpoints/handleValidateSchema.js +3 -1
  24. package/dist/routes/endpoints/isUnauthenticatedHuman.js +31 -0
  25. package/dist/routes/endpoints/resolveCronEnvironment.js +37 -0
  26. package/dist/routes/endpoints/runDetachedEndpoint.js +66 -0
  27. package/dist/routes/endpoints/runRoutine.js +12 -0
  28. package/dist/routes/endpoints/runScheduledEndpoint.js +104 -0
  29. package/dist/routes/endpoints/runWebhookEndpoint.js +83 -0
  30. package/dist/routes/endpoints/scheduleBackground.js +48 -0
  31. package/dist/routes/mcp/createMcpServer.js +160 -0
  32. package/dist/routes/notifications/derivePreview.js +30 -0
  33. package/dist/routes/notifications/getNotificationConfig.js +32 -0
  34. package/dist/routes/notifications/resolveNotificationLinks.js +72 -0
  35. package/dist/routes/notifications/resolveThemeLogo.js +35 -0
  36. package/dist/routes/page/dynamic/resolveDynamicContent.js +172 -0
  37. package/dist/routes/page/dynamic/unescapeOperators.js +36 -0
  38. package/dist/routes/page/dynamic/validateFragment.js +104 -0
  39. package/dist/routes/page/getPageConfig.js +16 -6
  40. package/dist/routes/websocket/authorizeWebsocket.js +27 -0
  41. package/dist/routes/websocket/createChannelRegistry.js +269 -0
  42. package/dist/routes/websocket/createWebSocketConnection.js +131 -0
  43. package/dist/routes/websocket/getWebsocketConfig.js +30 -0
  44. package/dist/routes/websocket/getWebsocketResolver.js +33 -0
  45. package/dist/routes/websocket/prepareChannel.js +72 -0
  46. package/dist/test/testContext.js +4 -2
  47. package/package.json +12 -10
@@ -0,0 +1,77 @@
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 { ServerParser } from '@lowdefy/operators';
16
+ import { _app, _secret } from '@lowdefy/operators-js/operators/server';
17
+ import { type } from '@lowdefy/helpers';
18
+ import { ConfigError } from '@lowdefy/errors';
19
+ // Builds the API auth strategy verifiers from the auth.json build artifact.
20
+ // Build has validated the config and written all defaults, so this resolves
21
+ // the _secret operators and constructs each strategy type's verifier.
22
+ // Verifier factories warn on short static keys at construction, so key
23
+ // strength surfaces at startup, never as a build failure.
24
+ function createAuthStrategies({ appMeta, authJson, logger, plugins, secrets }) {
25
+ if (!type.isArray(authJson.strategies) || authJson.strategies.length === 0) {
26
+ return [];
27
+ }
28
+ const operatorsParser = new ServerParser({
29
+ lowdefyApp: appMeta,
30
+ operators: {
31
+ _app,
32
+ _secret
33
+ },
34
+ secrets,
35
+ user: {}
36
+ });
37
+ const { output: strategiesConfig, errors: operatorErrors } = operatorsParser.parse({
38
+ input: authJson.strategies,
39
+ location: 'auth.strategies',
40
+ payload: {}
41
+ });
42
+ if (operatorErrors.length > 0) {
43
+ // Startup fails on the first error; log the rest so they can all be
44
+ // fixed in one pass instead of one boot per error.
45
+ operatorErrors.slice(1).forEach((error)=>logger.error(error));
46
+ throw operatorErrors[0];
47
+ }
48
+ return strategiesConfig.map((strategy)=>{
49
+ const strategyPlugin = plugins.strategies[strategy.type];
50
+ if (type.isNone(strategyPlugin)) {
51
+ throw new ConfigError(`Auth strategy type "${strategy.type}" not found at strategy "${strategy.id}".`, {
52
+ configKey: strategy['~k']
53
+ });
54
+ }
55
+ let verify;
56
+ try {
57
+ verify = strategyPlugin({
58
+ logger,
59
+ properties: strategy.properties,
60
+ strategyId: strategy.id
61
+ });
62
+ } catch (error) {
63
+ throw new ConfigError(error.message, {
64
+ cause: error,
65
+ configKey: strategy['~k']
66
+ });
67
+ }
68
+ return {
69
+ attributes: strategy.attributes,
70
+ id: strategy.id,
71
+ roles: strategy.roles,
72
+ type: strategy.type,
73
+ verify
74
+ };
75
+ });
76
+ }
77
+ export default createAuthStrategies;
@@ -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 createAuthStrategies from './createAuthStrategies.js';
16
+ let strategies;
17
+ // The strategy verifiers are constructed once per process at first use -
18
+ // the server middleware tries them in config order on every request that
19
+ // resolves no session.
20
+ function getAuthStrategies({ appMeta, authJson, logger, plugins, secrets }) {
21
+ if (strategies) return strategies;
22
+ strategies = createAuthStrategies({
23
+ appMeta,
24
+ authJson,
25
+ logger,
26
+ plugins,
27
+ secrets
28
+ });
29
+ return strategies;
30
+ }
31
+ export default getAuthStrategies;
@@ -12,14 +12,20 @@
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 { ConfigError } from '@lowdefy/errors';
16
- function authorizeApiEndpoint({ authorize, logger }, { endpointConfig }) {
15
+ */ import { AuthenticationError, ConfigError } from '@lowdefy/errors';
16
+ import { type } from '@lowdefy/helpers';
17
+ function authorizeApiEndpoint({ authorize, logger, user }, { endpointConfig }) {
17
18
  if (!authorize(endpointConfig)) {
18
19
  logger.debug({
19
20
  event: 'debug_api_authorize',
20
21
  authorized: false,
21
22
  auth_config: endpointConfig.auth
22
23
  });
24
+ // Unauthenticated on a protected endpoint - 401 tells the caller to fix
25
+ // its credentials. Wrong roles stay opaque below.
26
+ if (type.isNone(user)) {
27
+ throw new AuthenticationError(`Authentication required for API endpoint "${endpointConfig.endpointId}".`);
28
+ }
23
29
  throw new ConfigError(`API Endpoint "${endpointConfig.endpointId}" does not exist.`);
24
30
  }
25
31
  logger.debug({
@@ -13,12 +13,14 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { serializer } from '@lowdefy/helpers';
16
- import { ConfigError } from '@lowdefy/errors';
16
+ import { AuthenticationError, ConfigError } from '@lowdefy/errors';
17
17
  import authorizeApiEndpoint from './authorizeApiEndpoint.js';
18
18
  import buildEndpointResult from '../../response/buildEndpointResult.js';
19
19
  import createEvaluateOperators from '../../context/createEvaluateOperators.js';
20
20
  import getEndpointConfig from './getEndpointConfig.js';
21
+ import isUnauthenticatedHuman from './isUnauthenticatedHuman.js';
21
22
  import runRoutine from './runRoutine.js';
23
+ import scheduleBackground from './scheduleBackground.js';
22
24
  async function callEndpoint(context, { blockId, endpointId, pageId, payload }) {
23
25
  const { logger } = context;
24
26
  context.blockId = blockId;
@@ -35,9 +37,11 @@ async function callEndpoint(context, { blockId, endpointId, pageId, payload }) {
35
37
  const endpointConfig = await getEndpointConfig(context, {
36
38
  endpointId
37
39
  });
38
- // Block HTTP access to InternalApi endpoints same error as missing endpoint
40
+ // Block HTTP access to InternalApi endpoints - same error as a missing
41
+ // endpoint, including the unauthenticated fork, so an internal endpoint is
42
+ // indistinguishable from one that does not exist on both paths.
39
43
  if (endpointConfig.type === 'InternalApi') {
40
- const err = new ConfigError(`API Endpoint "${endpointId}" does not exist.`);
44
+ const err = await isUnauthenticatedHuman(context) ? new AuthenticationError(`Authentication required for API endpoint "${endpointId}".`) : new ConfigError(`API Endpoint "${endpointId}" does not exist.`);
41
45
  logger.debug({
42
46
  params: {
43
47
  endpointId
@@ -57,6 +61,25 @@ async function callEndpoint(context, { blockId, endpointId, pageId, payload }) {
57
61
  state: {},
58
62
  endpointDepth: 0
59
63
  };
64
+ // async: true — acknowledge now, run the routine in the background.
65
+ // Auth was already checked above; the outcome lands in logs (scheduleBackground)
66
+ // and in whatever the routine itself records.
67
+ if (endpointConfig.async === true) {
68
+ scheduleBackground(context, {
69
+ event: 'background_endpoint',
70
+ endpointId
71
+ }, ()=>runRoutine(context, routineContext, {
72
+ routine: endpointConfig.routine
73
+ }));
74
+ return {
75
+ error: null,
76
+ response: serializer.serialize({
77
+ accepted: true
78
+ }),
79
+ status: 'accepted',
80
+ success: true
81
+ };
82
+ }
60
83
  const { error, response, status } = await runRoutine(context, routineContext, {
61
84
  routine: endpointConfig.routine
62
85
  });
@@ -37,9 +37,11 @@ async function controlReject(context, routineContext, { control }) {
37
37
  cause,
38
38
  isReject: true
39
39
  });
40
+ // Log under `err` — see controlThrow: only the `err` key runs the pino error
41
+ // serializer, so `error` would drop the message from the log line.
40
42
  context.logger.warn({
41
43
  event: 'warn_control_reject',
42
- error
44
+ err: error
43
45
  });
44
46
  return {
45
47
  status: 'reject',
@@ -36,9 +36,12 @@ async function controlThrow(context, routineContext, { control }) {
36
36
  const error = new UserError(message, {
37
37
  cause
38
38
  });
39
+ // Log under `err` — the pino error serializer (createNodeLogger) is registered
40
+ // for the `err` key only; an Error passed as `error` is JSON-dumped without its
41
+ // non-enumerable `message`/`stack`, producing a log line with no message.
39
42
  context.logger.error({
40
43
  event: 'error_control_throw',
41
- error
44
+ err: error
42
45
  });
43
46
  return {
44
47
  status: 'error',
@@ -0,0 +1,35 @@
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
+ // Vercel sends the firing expression in x-vercel-cron-schedule; cron expressions are unique per
17
+ // endpoint and environment (enforced at build) so this is unambiguous. Without a cron (e.g. local
18
+ // testing) fall back to the single schedule, otherwise require it to disambiguate the payload.
19
+ function findSchedule({ schedules, cron, endpointId, environment }) {
20
+ const where = environment === undefined ? '' : ` for environment "${environment}"`;
21
+ if (!Array.isArray(schedules) || schedules.length === 0) {
22
+ throw new ConfigError(`API Endpoint "${endpointId}" is not scheduled${where}.`);
23
+ }
24
+ let schedule;
25
+ if (cron) {
26
+ schedule = schedules.find((s)=>s.cron === cron);
27
+ } else if (schedules.length === 1) {
28
+ schedule = schedules[0];
29
+ }
30
+ if (!schedule) {
31
+ throw new ConfigError(`No schedule matching cron "${cron}" for API Endpoint "${endpointId}"${where}.`);
32
+ }
33
+ return schedule;
34
+ }
35
+ export default findSchedule;
@@ -0,0 +1,106 @@
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, type } from '@lowdefy/helpers';
16
+ import { ConfigError } from '@lowdefy/errors';
17
+ import findSchedule from './findSchedule.js';
18
+ import getEndpointConfig from './getEndpointConfig.js';
19
+ import getEnvironmentSchedules from './getEnvironmentSchedules.js';
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.
27
+ async function forwardScheduledEndpoint(context, { environment, endpointId, cron }) {
28
+ const { config, logger, secrets } = context;
29
+ const target = config?.cron?.environments?.[environment];
30
+ if (!type.isObject(target)) {
31
+ throw new ConfigError(`Cron environment "${environment}" is not declared in lowdefy.config.cron.environments.`);
32
+ }
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.`);
35
+ }
36
+ if (target.enabled === false) {
37
+ throw new ConfigError(`Cron environment "${environment}" is disabled.`);
38
+ }
39
+ const secret = secrets?.[target.secret];
40
+ 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.`);
42
+ }
43
+ // The same build is deployed to every environment, so the local artifact says whether the target
44
+ // declares the firing schedule: fail here with a config error instead of having the target 500.
45
+ const endpointConfig = await getEndpointConfig(context, {
46
+ endpointId
47
+ });
48
+ const schedules = getEnvironmentSchedules({
49
+ endpointConfig,
50
+ environment
51
+ });
52
+ findSchedule({
53
+ schedules,
54
+ cron,
55
+ endpointId,
56
+ environment
57
+ });
58
+ // Trim trailing slashes with a loop rather than a regex: CodeQL flags `/\/+$/` on config input.
59
+ let origin = target.url;
60
+ while(origin.endsWith('/')){
61
+ origin = origin.slice(0, -1);
62
+ }
63
+ const url = `${origin}/api/cron/${endpointId}`;
64
+ const timeoutMs = (config?.vercel?.maxDuration ?? 60) * 1000;
65
+ const headers = {
66
+ authorization: `Bearer ${secret}`
67
+ };
68
+ if (cron) headers['x-vercel-cron-schedule'] = cron;
69
+ headers['x-lowdefy-cron-environment'] = environment;
70
+ logger.info({
71
+ event: 'forward_scheduled_endpoint',
72
+ endpointId,
73
+ environment,
74
+ cron,
75
+ url
76
+ });
77
+ scheduleBackground(context, {
78
+ event: 'forward_scheduled_endpoint',
79
+ endpointId
80
+ }, async ()=>{
81
+ const response = await fetch(url, {
82
+ method: 'GET',
83
+ headers,
84
+ signal: AbortSignal.timeout(timeoutMs)
85
+ });
86
+ // Drain the body so the connection is released; the target logs its own outcome.
87
+ await response.arrayBuffer();
88
+ if (!response.ok) {
89
+ throw new Error(`Forwarded cron for environment "${environment}" at ${url} responded ${response.status}.`);
90
+ }
91
+ return {
92
+ status: response.status
93
+ };
94
+ });
95
+ return {
96
+ error: null,
97
+ response: serializer.serialize({
98
+ accepted: true,
99
+ environment,
100
+ endpointId
101
+ }),
102
+ status: 'accepted',
103
+ success: true
104
+ };
105
+ }
106
+ export default forwardScheduledEndpoint;
@@ -12,11 +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
- */ import { ConfigError } from '@lowdefy/errors';
16
- async function getEndpointConfig({ logger, readConfigFile }, { endpointId }) {
15
+ */ import { AuthenticationError, ConfigError } from '@lowdefy/errors';
16
+ import isUnauthenticatedHuman from './isUnauthenticatedHuman.js';
17
+ // A missing endpoint answers the same as a protected one for an anonymous
18
+ // caller on an auth'd app - the message is byte-identical to the one
19
+ // authorizeApiEndpoint throws, so present and absent are indistinguishable
20
+ // before authenticating. Every other caller keeps the opaque does-not-exist.
21
+ async function getEndpointConfig(context, { endpointId }) {
22
+ const { logger, readConfigFile } = context;
17
23
  const endpoint = await readConfigFile(`api/${endpointId}.json`);
18
24
  if (!endpoint) {
19
- const err = new ConfigError(`API Endpoint "${endpointId}" does not exist.`);
25
+ const err = await isUnauthenticatedHuman(context) ? new AuthenticationError(`Authentication required for API endpoint "${endpointId}".`) : new ConfigError(`API Endpoint "${endpointId}" does not exist.`);
20
26
  logger.debug({
21
27
  params: {
22
28
  endpointId
@@ -0,0 +1,29 @@
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
+ // 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.
19
+ function getEnvironmentSchedules({ endpointConfig, environment }) {
20
+ const schedules = endpointConfig.schedules;
21
+ if (type.isArray(schedules)) {
22
+ return schedules;
23
+ }
24
+ if (environment === undefined) {
25
+ return [];
26
+ }
27
+ return schedules?.[environment] ?? [];
28
+ }
29
+ export default getEnvironmentSchedules;
@@ -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 { type } from '@lowdefy/helpers';
17
+ import addStepResult from './addStepResult.js';
18
+ import prepareAgent from '../agent/prepareAgent.js';
19
+ async function handleAgentCall(context, routineContext, { step }) {
20
+ const { logger, evaluateOperators } = context;
21
+ logger.debug({
22
+ event: 'debug_start_agent_call',
23
+ step
24
+ });
25
+ // Evaluate operators in step.properties (resolves agentId, prompt)
26
+ const evaluatedProperties = evaluateOperators({
27
+ input: step.properties,
28
+ items: routineContext.items,
29
+ location: step.stepId,
30
+ payload: routineContext.payload,
31
+ state: routineContext.state,
32
+ steps: routineContext.steps
33
+ });
34
+ const { agentId, prompt } = evaluatedProperties;
35
+ if (!type.isString(agentId)) {
36
+ throw new ConfigError(`CallAgent step "${step.stepId}" properties.agentId must evaluate to a string. Received ${JSON.stringify(agentId)}.`, {
37
+ configKey: step['~k']
38
+ });
39
+ }
40
+ if (!type.isString(prompt)) {
41
+ throw new ConfigError(`CallAgent step "${step.stepId}" properties.prompt must evaluate to a string. Received ${JSON.stringify(prompt)}.`, {
42
+ configKey: step['~k']
43
+ });
44
+ }
45
+ // Headless agent context — no page, no conversation, no sharedState (which
46
+ // also excludes the client-only update-page-state tool). userId is null
47
+ // under scheduled (system) context.
48
+ const agentContext = {
49
+ conversationId: null,
50
+ pageId: null,
51
+ sharedState: undefined,
52
+ urlQuery: {},
53
+ userId: context.user?.sub ?? context.user?.id ?? null
54
+ };
55
+ const { agentConfig, connectionInstance, agentType, resolverContext } = await prepareAgent(context, {
56
+ agentId,
57
+ agentContext,
58
+ endpointDepth: routineContext.endpointDepth,
59
+ mode: 'generate'
60
+ });
61
+ const { result } = await agentType.resolver({
62
+ connection: connectionInstance,
63
+ properties: {
64
+ agent: agentConfig,
65
+ prompt
66
+ },
67
+ context: resolverContext
68
+ });
69
+ addStepResult(context, routineContext, {
70
+ result,
71
+ stepId: step.stepId
72
+ });
73
+ logger.debug({
74
+ event: 'debug_end_agent_call',
75
+ stepId: step.stepId,
76
+ targetAgentId: agentId,
77
+ finishReason: result?.finishReason
78
+ });
79
+ return {
80
+ status: 'continue'
81
+ };
82
+ }
83
+ export default handleAgentCall;
@@ -12,8 +12,11 @@
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';
17
20
  async function handleEndpointCall(context, routineContext, { step }) {
18
21
  const { logger, evaluateOperators } = context;
19
22
  logger.debug({
@@ -29,6 +32,51 @@ async function handleEndpointCall(context, routineContext, { step }) {
29
32
  state: routineContext.state,
30
33
  steps: routineContext.steps
31
34
  });
35
+ // detached: true — fire-and-forget the call back through the deployment's
36
+ // /api/detached route, so the target runs in its OWN function invocation
37
+ // with a fresh duration budget (chainable bounded work without a queue).
38
+ // At-most-once, no retry: targets must be idempotent. The dispatch promise
39
+ // rides the platform request context so the outgoing request always leaves
40
+ // before the invocation is reaped; the target's own outcome exists only in
41
+ // its logs and whatever its routine writes.
42
+ if (evaluatedProperties.detached === true) {
43
+ if (!process.env.CRON_SECRET) {
44
+ throw new ConfigError('CallApi with "detached: true" requires the CRON_SECRET environment variable — the /api/detached route fails closed without it.');
45
+ }
46
+ if (!context.origin) {
47
+ throw new ConfigError('Detached endpoint calls require the request origin on context.');
48
+ }
49
+ const targetEndpointId = evaluatedProperties.endpointId;
50
+ scheduleBackground(context, {
51
+ event: 'detached_dispatch',
52
+ endpointId: targetEndpointId
53
+ }, ()=>fetch(`${context.origin}/api/detached/${targetEndpointId}`, {
54
+ method: 'POST',
55
+ headers: {
56
+ 'content-type': 'application/json',
57
+ authorization: `Bearer ${process.env.CRON_SECRET}`
58
+ },
59
+ body: JSON.stringify({
60
+ payload: serializer.serialize(evaluatedProperties.payload ?? {})
61
+ })
62
+ }));
63
+ addStepResult(context, routineContext, {
64
+ result: {
65
+ detached: true,
66
+ endpointId: targetEndpointId
67
+ },
68
+ stepId: step.stepId
69
+ });
70
+ logger.debug({
71
+ event: 'debug_end_endpoint_call',
72
+ stepId: step.stepId,
73
+ targetEndpointId,
74
+ detached: true
75
+ });
76
+ return {
77
+ status: 'continue'
78
+ };
79
+ }
32
80
  const result = await invokeEndpoint(context, {
33
81
  endpointId: evaluatedProperties.endpointId,
34
82
  payload: evaluatedProperties.payload,