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