@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.
- package/dist/context/createAuthorize.js +4 -1
- package/dist/context/resolveStrategyCaller.js +56 -0
- package/dist/index.js +16 -3
- package/dist/response/buildEndpointResult.js +33 -0
- package/dist/response/normalizeErrorSources.js +64 -0
- package/dist/response/omitErrorProps.js +51 -0
- package/dist/response/redactErrorResponse.js +30 -0
- package/dist/response/redactResponse.js +31 -0
- package/dist/routes/agent/authorizeAgent.js +39 -0
- package/dist/routes/agent/callAgent.js +7 -180
- package/dist/routes/agent/prepareAgent.js +173 -0
- package/dist/routes/auth/createLogger.js +2 -5
- package/dist/routes/auth/createPrefixedCookies.js +52 -0
- package/dist/routes/auth/{getNextAuthConfig.js → getAuthConfig.js} +27 -16
- package/dist/routes/auth/resolveCookies.js +37 -0
- package/dist/routes/auth/strategies/createAuthStrategies.js +77 -0
- package/dist/routes/auth/strategies/getAuthStrategies.js +31 -0
- package/dist/routes/endpoints/addStepResult.js +12 -2
- package/dist/routes/endpoints/authorizeApiEndpoint.js +8 -2
- package/dist/routes/endpoints/callEndpoint.js +32 -13
- package/dist/routes/endpoints/control/controlReject.js +3 -1
- package/dist/routes/endpoints/control/controlSetState.js +13 -2
- package/dist/routes/endpoints/control/controlThrow.js +4 -1
- package/dist/routes/endpoints/findSchedule.js +35 -0
- package/dist/routes/endpoints/forwardScheduledEndpoint.js +106 -0
- package/dist/routes/endpoints/getEndpointConfig.js +9 -3
- package/dist/routes/endpoints/getEnvironmentSchedules.js +29 -0
- package/dist/routes/endpoints/handleAgentCall.js +83 -0
- package/dist/routes/endpoints/handleEndpointCall.js +49 -1
- package/dist/routes/endpoints/handleRenderNotification.js +189 -0
- package/dist/routes/endpoints/handleValidateSchema.js +3 -1
- package/dist/routes/endpoints/isUnauthenticatedHuman.js +31 -0
- package/dist/routes/endpoints/resolveCronEnvironment.js +37 -0
- package/dist/routes/endpoints/runDetachedEndpoint.js +66 -0
- package/dist/routes/endpoints/runRoutine.js +15 -1
- package/dist/routes/endpoints/runScheduledEndpoint.js +104 -0
- package/dist/routes/endpoints/runWebhookEndpoint.js +83 -0
- package/dist/routes/endpoints/scheduleBackground.js +48 -0
- package/dist/routes/mcp/createMcpServer.js +160 -0
- package/dist/routes/notifications/derivePreview.js +30 -0
- package/dist/routes/notifications/getNotificationConfig.js +32 -0
- package/dist/routes/notifications/resolveNotificationLinks.js +72 -0
- package/dist/routes/notifications/resolveThemeLogo.js +35 -0
- package/dist/routes/page/dynamic/resolveDynamicContent.js +172 -0
- package/dist/routes/page/dynamic/unescapeOperators.js +36 -0
- package/dist/routes/page/dynamic/validateFragment.js +104 -0
- package/dist/routes/page/getPageConfig.js +16 -6
- package/dist/routes/request/callRequest.js +2 -1
- package/dist/routes/websocket/authorizeWebsocket.js +27 -0
- package/dist/routes/websocket/createChannelRegistry.js +269 -0
- package/dist/routes/websocket/createWebSocketConnection.js +131 -0
- package/dist/routes/websocket/getWebsocketConfig.js +30 -0
- package/dist/routes/websocket/getWebsocketResolver.js +33 -0
- package/dist/routes/websocket/prepareChannel.js +72 -0
- package/dist/test/testContext.js +10 -3
- 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
|
-
|
|
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
|
|
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;
|