@lowdefy/api 5.5.1 → 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 (56) hide show
  1. package/dist/context/createAuthorize.js +4 -1
  2. package/dist/context/resolveStrategyCaller.js +56 -0
  3. package/dist/index.js +16 -3
  4. package/dist/response/buildEndpointResult.js +33 -0
  5. package/dist/response/normalizeErrorSources.js +64 -0
  6. package/dist/response/omitErrorProps.js +51 -0
  7. package/dist/response/redactErrorResponse.js +30 -0
  8. package/dist/response/redactResponse.js +31 -0
  9. package/dist/routes/agent/authorizeAgent.js +39 -0
  10. package/dist/routes/agent/callAgent.js +7 -180
  11. package/dist/routes/agent/prepareAgent.js +173 -0
  12. package/dist/routes/auth/createLogger.js +2 -5
  13. package/dist/routes/auth/createPrefixedCookies.js +52 -0
  14. package/dist/routes/auth/{getNextAuthConfig.js → getAuthConfig.js} +27 -16
  15. package/dist/routes/auth/resolveCookies.js +37 -0
  16. package/dist/routes/auth/strategies/createAuthStrategies.js +77 -0
  17. package/dist/routes/auth/strategies/getAuthStrategies.js +31 -0
  18. package/dist/routes/endpoints/addStepResult.js +12 -2
  19. package/dist/routes/endpoints/authorizeApiEndpoint.js +8 -2
  20. package/dist/routes/endpoints/callEndpoint.js +32 -13
  21. package/dist/routes/endpoints/control/controlReject.js +3 -1
  22. package/dist/routes/endpoints/control/controlSetState.js +13 -2
  23. package/dist/routes/endpoints/control/controlThrow.js +4 -1
  24. package/dist/routes/endpoints/findSchedule.js +35 -0
  25. package/dist/routes/endpoints/forwardScheduledEndpoint.js +106 -0
  26. package/dist/routes/endpoints/getEndpointConfig.js +9 -3
  27. package/dist/routes/endpoints/getEnvironmentSchedules.js +29 -0
  28. package/dist/routes/endpoints/handleAgentCall.js +83 -0
  29. package/dist/routes/endpoints/handleEndpointCall.js +49 -1
  30. package/dist/routes/endpoints/handleRenderNotification.js +189 -0
  31. package/dist/routes/endpoints/handleValidateSchema.js +3 -1
  32. package/dist/routes/endpoints/isUnauthenticatedHuman.js +31 -0
  33. package/dist/routes/endpoints/resolveCronEnvironment.js +37 -0
  34. package/dist/routes/endpoints/runDetachedEndpoint.js +66 -0
  35. package/dist/routes/endpoints/runRoutine.js +15 -1
  36. package/dist/routes/endpoints/runScheduledEndpoint.js +104 -0
  37. package/dist/routes/endpoints/runWebhookEndpoint.js +83 -0
  38. package/dist/routes/endpoints/scheduleBackground.js +48 -0
  39. package/dist/routes/mcp/createMcpServer.js +160 -0
  40. package/dist/routes/notifications/derivePreview.js +30 -0
  41. package/dist/routes/notifications/getNotificationConfig.js +32 -0
  42. package/dist/routes/notifications/resolveNotificationLinks.js +72 -0
  43. package/dist/routes/notifications/resolveThemeLogo.js +35 -0
  44. package/dist/routes/page/dynamic/resolveDynamicContent.js +172 -0
  45. package/dist/routes/page/dynamic/unescapeOperators.js +36 -0
  46. package/dist/routes/page/dynamic/validateFragment.js +104 -0
  47. package/dist/routes/page/getPageConfig.js +16 -6
  48. package/dist/routes/request/callRequest.js +2 -1
  49. package/dist/routes/websocket/authorizeWebsocket.js +27 -0
  50. package/dist/routes/websocket/createChannelRegistry.js +269 -0
  51. package/dist/routes/websocket/createWebSocketConnection.js +131 -0
  52. package/dist/routes/websocket/getWebsocketConfig.js +30 -0
  53. package/dist/routes/websocket/getWebsocketResolver.js +33 -0
  54. package/dist/routes/websocket/prepareChannel.js +72 -0
  55. package/dist/test/testContext.js +10 -3
  56. package/package.json +12 -10
@@ -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,
@@ -0,0 +1,189 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { validate } from '@lowdefy/ajv';
16
+ import { ConfigError } from '@lowdefy/errors';
17
+ import { type } from '@lowdefy/helpers';
18
+ import addStepResult from './addStepResult.js';
19
+ import derivePreview from '../notifications/derivePreview.js';
20
+ import getNotificationConfig from '../notifications/getNotificationConfig.js';
21
+ import resolveNotificationLinks from '../notifications/resolveNotificationLinks.js';
22
+ import resolveThemeLogo from '../notifications/resolveThemeLogo.js';
23
+ function itemHasPageLinks(item, dataKeys) {
24
+ const links = Object.values(item.links ?? {});
25
+ const arrayLinks = dataKeys.flatMap((key)=>(type.isArray(item[key]) ? item[key] : []).map((entry)=>entry?.link));
26
+ return [
27
+ ...links,
28
+ ...arrayLinks
29
+ ].some((link)=>type.isObject(link) && !type.isNone(link.pageId));
30
+ }
31
+ async function handleRenderNotification(context, routineContext, { step }) {
32
+ const { logger, evaluateOperators } = context;
33
+ logger.debug({
34
+ event: 'debug_start_render_notification',
35
+ step
36
+ });
37
+ const evaluatedProperties = evaluateOperators({
38
+ input: step.properties,
39
+ items: routineContext.items,
40
+ location: step.stepId,
41
+ payload: routineContext.payload,
42
+ state: routineContext.state,
43
+ steps: routineContext.steps
44
+ });
45
+ const { notificationId, data, landingPage, recordId } = evaluatedProperties;
46
+ let { serverUrl } = evaluatedProperties;
47
+ if (!type.isString(notificationId)) {
48
+ throw new ConfigError(`RenderNotification step "${step.stepId}" properties.notificationId must evaluate to a string. Received ${JSON.stringify(notificationId)}.`, {
49
+ configKey: step['~k']
50
+ });
51
+ }
52
+ if (type.isArray(data)) {
53
+ throw new ConfigError(`RenderNotification step "${step.stepId}" properties.data must evaluate to an object. Received an array — iterate with a ":for" control and render one item per step.`, {
54
+ configKey: step['~k']
55
+ });
56
+ }
57
+ if (!type.isObject(data)) {
58
+ throw new ConfigError(`RenderNotification step "${step.stepId}" properties.data must evaluate to an object. Received ${JSON.stringify(data)}.`, {
59
+ configKey: step['~k']
60
+ });
61
+ }
62
+ if (!type.isNone(serverUrl)) {
63
+ if (!type.isString(serverUrl) || serverUrl === '') {
64
+ throw new ConfigError(`RenderNotification step "${step.stepId}" properties.serverUrl must evaluate to a non-empty string. Received ${JSON.stringify(serverUrl)}.`, {
65
+ configKey: step['~k']
66
+ });
67
+ }
68
+ serverUrl = serverUrl.replace(/\/$/, '');
69
+ }
70
+ if (!type.isNone(landingPage) && !type.isString(landingPage)) {
71
+ throw new ConfigError(`RenderNotification step "${step.stepId}" properties.landingPage must evaluate to a string. Received ${JSON.stringify(landingPage)}.`, {
72
+ configKey: step['~k']
73
+ });
74
+ }
75
+ if (!type.isNone(recordId) && !type.isString(recordId)) {
76
+ throw new ConfigError(`RenderNotification step "${step.stepId}" properties.recordId must evaluate to a string. Received ${JSON.stringify(recordId)}.`, {
77
+ configKey: step['~k']
78
+ });
79
+ }
80
+ const notificationConfig = await getNotificationConfig(context, {
81
+ notificationId,
82
+ configKey: step['~k']
83
+ });
84
+ const configKey = notificationConfig['~k'];
85
+ const Template = context.notifications[notificationConfig.type];
86
+ if (!Template) {
87
+ throw new ConfigError(`Notification template type "${notificationConfig.type}" can not be found.`, {
88
+ configKey
89
+ });
90
+ }
91
+ const { renderEmail, interpolateProperties } = context;
92
+ if (!type.isFunction(renderEmail) || !type.isFunction(interpolateProperties)) {
93
+ // plugins/notifications.js exports real implementations whenever the app
94
+ // has notifications built — reaching this means the build artifact and
95
+ // config are out of sync.
96
+ throw new ConfigError('Email rendering is not available. Rebuild the app — @lowdefy/email-templates is installed when "notifications:" is configured.', {
97
+ configKey
98
+ });
99
+ }
100
+ const app = await context.readConfigFile('app.json') ?? {};
101
+ const basePath = context.config?.basePath ?? '';
102
+ // Resolve after the merge so a per-notification theme.logo override also
103
+ // resolves; precedence stays notification theme over app.email.
104
+ const theme = resolveThemeLogo({
105
+ theme: {
106
+ ...app.email ?? {},
107
+ ...notificationConfig.theme ?? {}
108
+ },
109
+ serverUrl,
110
+ basePath
111
+ });
112
+ if (itemHasPageLinks(data, Template.dataKeys ?? [])) {
113
+ if (type.isNone(serverUrl)) {
114
+ throw new ConfigError(`Notification "${notificationId}" has links but no server URL is available. Set the serverUrl step property.`, {
115
+ configKey: step['~k']
116
+ });
117
+ }
118
+ // Landing URLs embed the record id — without it the landing page cannot
119
+ // resolve the record and every link in the email would be dead.
120
+ if (!type.isNone(landingPage) && type.isNone(recordId)) {
121
+ throw new ConfigError(`RenderNotification step "${step.stepId}" properties.landingPage requires properties.recordId to compose landing URLs.`, {
122
+ configKey: step['~k']
123
+ });
124
+ }
125
+ }
126
+ const resolvedItem = resolveNotificationLinks({
127
+ item: data,
128
+ dataKeys: Template.dataKeys ?? [],
129
+ serverUrl: serverUrl ?? '',
130
+ basePath,
131
+ landingPage,
132
+ recordId
133
+ });
134
+ let interpolated;
135
+ try {
136
+ interpolated = interpolateProperties({
137
+ properties: notificationConfig.properties,
138
+ data: resolvedItem,
139
+ markdownProperties: Template.markdownProperties ?? []
140
+ });
141
+ } catch (error) {
142
+ throw new ConfigError(`Notification "${notificationId}" template interpolation failed: ${error.message}`, {
143
+ configKey,
144
+ cause: error
145
+ });
146
+ }
147
+ if (Template.schema) {
148
+ const { valid, errors } = validate({
149
+ schema: Template.schema,
150
+ data: interpolated,
151
+ returnErrors: true
152
+ });
153
+ if (!valid) {
154
+ throw new ConfigError(`Notification "${notificationId}" properties do not match template "${notificationConfig.type}" schema: ${errors?.[0]?.message}.`, {
155
+ configKey
156
+ });
157
+ }
158
+ }
159
+ const { html, text } = await renderEmail({
160
+ Template,
161
+ properties: interpolated,
162
+ data: resolvedItem,
163
+ theme,
164
+ links: resolvedItem.links ?? {}
165
+ });
166
+ const result = {
167
+ subject: interpolated.subject,
168
+ title: interpolated.title ?? interpolated.subject,
169
+ preview: derivePreview({
170
+ properties: interpolated
171
+ }),
172
+ html,
173
+ text,
174
+ data: resolvedItem
175
+ };
176
+ addStepResult(context, routineContext, {
177
+ result,
178
+ stepId: step.stepId
179
+ });
180
+ logger.debug({
181
+ event: 'debug_end_render_notification',
182
+ stepId: step.stepId,
183
+ notificationId
184
+ });
185
+ return {
186
+ status: 'continue'
187
+ };
188
+ }
189
+ export default handleRenderNotification;
@@ -51,10 +51,12 @@ async function handleValidateSchema(context, routineContext, { step }) {
51
51
  const error = new Error(buildErrorMessage(result.errors, step.stepId), {
52
52
  cause: result.errors
53
53
  });
54
+ // Log under `err` — see controlThrow: only the `err` key runs the pino error
55
+ // serializer, so `error` would drop the message from the log line.
54
56
  logger.error({
55
57
  event: 'error_validate_schema',
56
58
  stepId: step.stepId,
57
- error
59
+ err: error
58
60
  });
59
61
  return {
60
62
  status: 'error',
@@ -0,0 +1,31 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { type } from '@lowdefy/helpers';
16
+ // True for a session-less caller on an app that has auth configured, outside a
17
+ // system run. This is the one caller who must get the same answer for a missing
18
+ // id as for a protected one - "authenticate" - or a logged-out client can
19
+ // enumerate endpoint ids by response difference (500/does-not-exist for a miss
20
+ // versus 401 for a protected id). "No user" is not enough on its own: it is also
21
+ // "no auth in this app" and "the engine talking to itself" (scheduled, webhook
22
+ // and detached runs), and neither should be told to authenticate against
23
+ // nothing. auth.json is a build artifact the build always writes.
24
+ async function isUnauthenticatedHuman({ readConfigFile, system, user }) {
25
+ if (!type.isNone(user) || system === true) {
26
+ return false;
27
+ }
28
+ const authConfig = await readConfigFile('auth.json');
29
+ return authConfig?.configured === true;
30
+ }
31
+ export default isUnauthenticatedHuman;
@@ -0,0 +1,37 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { type } from '@lowdefy/helpers';
16
+ import { ConfigError } from '@lowdefy/errors';
17
+ // The environment a cron run resolves its schedules for. A forwarded request names it in the
18
+ // x-lowdefy-cron-environment header; a request without one is the deployment running its own crons,
19
+ // which is the environment declared without a url (the one Vercel fires crons on). Without
20
+ // config.cron there are no environments and the endpoint's plain schedules apply.
21
+ function resolveCronEnvironment({ config, environment }) {
22
+ const environments = config?.cron?.environments;
23
+ if (type.isNone(environments)) {
24
+ if (!type.isNone(environment)) {
25
+ throw new ConfigError(`Cron environment "${environment}" is not configured: lowdefy.config.cron.environments is not defined.`);
26
+ }
27
+ return undefined;
28
+ }
29
+ if (!type.isNone(environment)) {
30
+ if (!type.isObject(environments[environment])) {
31
+ throw new ConfigError(`Cron environment "${environment}" is not declared in lowdefy.config.cron.environments.`);
32
+ }
33
+ return environment;
34
+ }
35
+ return Object.keys(environments).find((name)=>type.isObject(environments[name]) && type.isUndefined(environments[name].url));
36
+ }
37
+ export default resolveCronEnvironment;