@lowdefy/server 5.6.0 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/{pages/_app.js → client/App.jsx} +26 -34
- package/client/Page.jsx +122 -0
- package/client/clientBuildImports.test.mjs +61 -0
- package/client/main.jsx +39 -0
- package/client/shouldReloadForBuild.js +48 -0
- package/client/shouldReloadForBuild.test.mjs +75 -0
- package/lib/build/app.js +8 -1
- package/lib/build/appMeta.js +8 -1
- package/lib/build/auth.js +8 -1
- package/lib/build/config.js +8 -1
- package/lib/build/i18n.js +8 -1
- package/lib/build/logger.js +8 -1
- package/lib/build/theme.js +8 -1
- package/lib/client/auth/{Auth.js → Auth.jsx} +10 -2
- package/lib/client/auth/{AuthConfigured.js → AuthConfigured.jsx} +21 -10
- package/lib/client/sentry/captureSentryError.js +1 -1
- package/lib/client/sentry/initSentryClient.js +1 -1
- package/lib/client/sentry/setSentryUser.js +1 -1
- package/lib/server/auth/{getAuthOptions.js → getAuthConfig.js} +7 -4
- package/{pages/api/auth/[...nextauth].js → lib/server/auth/session.js} +9 -15
- package/lib/server/auth/strategies.js +47 -0
- package/lib/server/log/createHandleError.js +10 -5
- package/lib/server/log/createHandleError.test.mjs +85 -0
- package/lib/server/log/createLogger.js +28 -1
- package/lib/server/log/createLogger.test.mjs +102 -0
- package/lib/server/log/logRequest.js +47 -45
- package/lib/server/log/maskCredentialKeys.js +37 -0
- package/lib/server/log/projectErrorForLog.js +55 -0
- package/lib/server/log/redactUrlQuery.js +75 -0
- package/lib/server/log/redactUrlQuery.test.mjs +73 -0
- package/lib/server/{auth/getServerSession.js → log/serializeErrorForLog.js} +5 -8
- package/lib/server/log/serializeErrorForLog.test.mjs +325 -0
- package/lib/server/scrubSecrets.js +21 -0
- package/lib/server/sentry/captureSentryError.js +13 -1
- package/lib/server/sentry/captureSentryError.test.mjs +104 -0
- package/lib/server/sentry/initSentry.js +17 -4
- package/lib/server/sentry/initSentry.test.mjs +237 -0
- package/lib/server/sentry/scrubEvent.js +29 -0
- package/lib/server/sentry/scrubEvent.test.mjs +88 -0
- package/lib/server/sentry/setSentryUser.js +1 -1
- package/lowdefy/createCustomPluginTypesMap.mjs +1 -0
- package/package.json +54 -42
- package/package.original.json +56 -44
- package/src/app.js +140 -0
- package/src/html/getAssets.js +45 -0
- package/src/html/renderPage.js +80 -0
- package/src/html/template.js +95 -0
- package/src/index.js +95 -0
- package/src/lib/getPathSegments.js +25 -0
- package/src/lib/safeScriptJson.js +38 -0
- package/src/middleware/apiContext.js +138 -0
- package/src/middleware/apiContext.test.mjs +118 -0
- package/src/middleware/errorHandler.js +54 -0
- package/src/middleware/sentry.js +33 -0
- package/{pages/api/agent/[...path].js → src/routes/agent.js} +20 -42
- package/src/routes/apiPage.js +40 -0
- package/src/routes/apiPage.test.mjs +59 -0
- package/src/routes/auth.js +38 -0
- package/{pages/api/client-error.js → src/routes/clientError.js} +14 -16
- package/src/routes/cron.js +44 -0
- package/src/routes/cronForward.js +47 -0
- package/src/routes/detached.js +47 -0
- package/src/routes/endpoints.js +71 -0
- package/src/routes/mcp.js +34 -0
- package/{pages/api/request/[...path].js → src/routes/request.js} +10 -10
- package/src/routes/usage.js +46 -0
- package/src/routes/websocket.js +46 -0
- package/vite.config.js +61 -0
- package/.eslintrc.yaml +0 -1
- package/lib/client/Page.js +0 -56
- package/lib/server/apiWrapper.js +0 -87
- package/lib/server/serverSidePropsWrapper.js +0 -66
- package/next.config.js +0 -34
- package/pages/404.js +0 -49
- package/pages/[[...pageId]].js +0 -85
- package/pages/_document.js +0 -108
- package/pages/api/endpoints/[...endpointId].js +0 -32
- package/pages/api/usage.js +0 -67
- /package/{postcss.config.js → postcss.config.cjs} +0 -0
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
*/
|
|
16
|
+
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { createApiContext } from '@lowdefy/api';
|
|
19
|
+
import { getSecretsFromEnv } from '@lowdefy/node-utils';
|
|
20
|
+
import { v4 as uuid } from 'uuid';
|
|
21
|
+
|
|
22
|
+
import agents from '../../build/plugins/agents.js';
|
|
23
|
+
import appMeta from '../../lib/build/appMeta.js';
|
|
24
|
+
import config from '../../lib/build/config.js';
|
|
25
|
+
import connections from '../../build/plugins/connections.js';
|
|
26
|
+
import createHandleError from '../../lib/server/log/createHandleError.js';
|
|
27
|
+
import createLogger from '../../lib/server/log/createLogger.js';
|
|
28
|
+
import fileCache from '../../lib/server/fileCache.js';
|
|
29
|
+
import getSession from '../../lib/server/auth/session.js';
|
|
30
|
+
import getStrategyCaller from '../../lib/server/auth/strategies.js';
|
|
31
|
+
import i18nConfig from '../../lib/build/i18n.js';
|
|
32
|
+
import jsMap from '../../build/plugins/operators/serverJsMap.js';
|
|
33
|
+
import logRequest from '../../lib/server/log/logRequest.js';
|
|
34
|
+
import loggerConfig from '../../lib/build/logger.js';
|
|
35
|
+
import scrubSecrets from '../../lib/server/scrubSecrets.js';
|
|
36
|
+
import notifications, {
|
|
37
|
+
interpolateProperties,
|
|
38
|
+
renderEmail,
|
|
39
|
+
} from '../../build/plugins/notifications.js';
|
|
40
|
+
import operators from '../../build/plugins/operators/server.js';
|
|
41
|
+
import setSentryUser from '../../lib/server/sentry/setSentryUser.js';
|
|
42
|
+
import websockets from '../../build/plugins/websockets.js';
|
|
43
|
+
|
|
44
|
+
const secrets = getSecretsFromEnv();
|
|
45
|
+
|
|
46
|
+
// Charset/length guard so a hostile x-request-id can't inject into logs.
|
|
47
|
+
const REQUEST_ID_REGEX = /^[\w.:-]{1,128}$/;
|
|
48
|
+
|
|
49
|
+
// Honor the request id assigned by an upstream proxy or load balancer so one
|
|
50
|
+
// id correlates client, proxy, and server logs; generate one otherwise.
|
|
51
|
+
function getRequestId(c) {
|
|
52
|
+
const incoming = c.req.header('x-request-id');
|
|
53
|
+
if (incoming && REQUEST_ID_REGEX.test(incoming)) {
|
|
54
|
+
return incoming;
|
|
55
|
+
}
|
|
56
|
+
return uuid();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Replaces lib/server/apiWrapper.js. Builds the request context consumed by
|
|
60
|
+
// @lowdefy/api functions. Errors thrown by handlers are routed by Hono to the
|
|
61
|
+
// app-level error handler (src/middleware/errorHandler.js), which reads this
|
|
62
|
+
// context back from the Hono context.
|
|
63
|
+
function apiContext() {
|
|
64
|
+
return async function apiContextMiddleware(c, next) {
|
|
65
|
+
// The page-route mount ('/*') also matches /api/* paths that already
|
|
66
|
+
// built a context — never build twice for one request.
|
|
67
|
+
if (c.get('lowdefyContext')) {
|
|
68
|
+
return next();
|
|
69
|
+
}
|
|
70
|
+
const rid = getRequestId(c);
|
|
71
|
+
const context = {
|
|
72
|
+
rid,
|
|
73
|
+
agents,
|
|
74
|
+
appMeta,
|
|
75
|
+
buildDirectory: path.join(process.cwd(), 'build'),
|
|
76
|
+
config,
|
|
77
|
+
connections,
|
|
78
|
+
fileCache,
|
|
79
|
+
headers: c.req.header(),
|
|
80
|
+
// The deployment's own origin — detached endpoint calls loop back
|
|
81
|
+
// through it so the target runs in its own function invocation.
|
|
82
|
+
origin: new URL(c.req.url).origin,
|
|
83
|
+
i18n: i18nConfig,
|
|
84
|
+
interpolateProperties,
|
|
85
|
+
jsMap,
|
|
86
|
+
logger: createLogger({ rid }),
|
|
87
|
+
mode: 'prod',
|
|
88
|
+
notifications,
|
|
89
|
+
operators,
|
|
90
|
+
renderEmail,
|
|
91
|
+
req: {
|
|
92
|
+
url: c.req.path,
|
|
93
|
+
method: c.req.method,
|
|
94
|
+
hostname: c.req.header('host'),
|
|
95
|
+
},
|
|
96
|
+
scrubSecrets,
|
|
97
|
+
secrets,
|
|
98
|
+
// On Vercel (fluid compute) the platform request context keeps the
|
|
99
|
+
// invocation alive until waitUntil promises settle; on long-lived hosts
|
|
100
|
+
// the lookup resolves to nothing and background promises just run.
|
|
101
|
+
waitUntil: (promise) =>
|
|
102
|
+
globalThis[Symbol.for('@vercel/request-context')]?.get?.()?.waitUntil?.(promise),
|
|
103
|
+
websockets,
|
|
104
|
+
};
|
|
105
|
+
context.handleError = createHandleError({ context });
|
|
106
|
+
if (!c.req.path.includes('/api/auth')) {
|
|
107
|
+
context.session = await getSession(c);
|
|
108
|
+
if (!context.session?.user) {
|
|
109
|
+
const caller = await getStrategyCaller(c, context.logger);
|
|
110
|
+
if (caller) {
|
|
111
|
+
context.session = { user: caller };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Set Sentry user context for authenticated requests
|
|
115
|
+
setSentryUser({
|
|
116
|
+
user: context.session?.user,
|
|
117
|
+
sentryConfig: loggerConfig.sentry,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
createApiContext(context);
|
|
121
|
+
c.set('lowdefyContext', context);
|
|
122
|
+
// Echo the request id so clients and proxies can quote it when reporting
|
|
123
|
+
// a failure, and it can be matched to the rid on the server log lines.
|
|
124
|
+
c.header('x-request-id', context.rid);
|
|
125
|
+
const startTime = performance.now();
|
|
126
|
+
// Handler errors never reject next() — Hono's compose routes them to the
|
|
127
|
+
// app-level error handler at the throwing dispatch level, so by the time
|
|
128
|
+
// next() resolves c.res holds the final response, error or not.
|
|
129
|
+
await next();
|
|
130
|
+
logRequest({
|
|
131
|
+
context,
|
|
132
|
+
status: c.res.status,
|
|
133
|
+
durationMs: Math.round(performance.now() - startTime),
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export default apiContext;
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import { fileURLToPath } from 'url';
|
|
20
|
+
|
|
21
|
+
import { Hono } from 'hono';
|
|
22
|
+
import { jest } from '@jest/globals';
|
|
23
|
+
|
|
24
|
+
const secret = 'planted-apicontext-secret';
|
|
25
|
+
process.env.LOWDEFY_SECRET_TEST = secret;
|
|
26
|
+
|
|
27
|
+
jest.unstable_mockModule('@lowdefy/api', () => ({ createApiContext: jest.fn() }));
|
|
28
|
+
jest.unstable_mockModule('../../lib/build/appMeta.js', () => ({ default: {} }));
|
|
29
|
+
jest.unstable_mockModule('../../lib/build/config.js', () => ({ default: {} }));
|
|
30
|
+
jest.unstable_mockModule('../../lib/build/i18n.js', () => ({ default: {} }));
|
|
31
|
+
jest.unstable_mockModule('../../lib/build/logger.js', () => ({ default: { sentry: {} } }));
|
|
32
|
+
jest.unstable_mockModule('../../lib/server/log/createHandleError.js', () => ({
|
|
33
|
+
default: jest.fn(() => jest.fn()),
|
|
34
|
+
}));
|
|
35
|
+
jest.unstable_mockModule('../../lib/server/log/createLogger.js', () => ({
|
|
36
|
+
default: jest.fn(() => ({ debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() })),
|
|
37
|
+
}));
|
|
38
|
+
jest.unstable_mockModule('../../lib/server/fileCache.js', () => ({ default: {} }));
|
|
39
|
+
jest.unstable_mockModule('../../lib/server/auth/session.js', () => ({
|
|
40
|
+
default: jest.fn(async () => undefined),
|
|
41
|
+
}));
|
|
42
|
+
jest.unstable_mockModule('../../lib/server/auth/strategies.js', () => ({
|
|
43
|
+
default: jest.fn(async () => null),
|
|
44
|
+
}));
|
|
45
|
+
jest.unstable_mockModule('../../lib/server/log/logRequest.js', () => ({ default: jest.fn() }));
|
|
46
|
+
jest.unstable_mockModule('../../lib/server/sentry/setSentryUser.js', () => ({
|
|
47
|
+
default: jest.fn(),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
// apiContext statically imports the app build's plugin artifacts, which only
|
|
51
|
+
// exist in a built app (build/** is gitignored). Materialize the minimal set
|
|
52
|
+
// it needs so the import resolves; clean up only what this test created.
|
|
53
|
+
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
54
|
+
const pluginsDir = path.resolve(dirname, '../../build/plugins');
|
|
55
|
+
const fixtures = [
|
|
56
|
+
{ file: 'agents.js', content: 'export default {};\n' },
|
|
57
|
+
{ file: 'connections.js', content: 'export default {};\n' },
|
|
58
|
+
{
|
|
59
|
+
file: 'notifications.js',
|
|
60
|
+
content:
|
|
61
|
+
'export default {};\nexport const interpolateProperties = () => {};\nexport const renderEmail = () => {};\n',
|
|
62
|
+
},
|
|
63
|
+
{ file: path.join('operators', 'server.js'), content: 'export default {};\n' },
|
|
64
|
+
{ file: path.join('operators', 'serverJsMap.js'), content: 'export default {};\n' },
|
|
65
|
+
{ file: 'websockets.js', content: 'export default {};\n' },
|
|
66
|
+
].map(({ file, content }) => ({ absolutePath: path.join(pluginsDir, file), content }));
|
|
67
|
+
|
|
68
|
+
const createdFixtures = [];
|
|
69
|
+
fixtures.forEach(({ absolutePath, content }) => {
|
|
70
|
+
if (!fs.existsSync(absolutePath)) {
|
|
71
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
72
|
+
fs.writeFileSync(absolutePath, content);
|
|
73
|
+
createdFixtures.push(absolutePath);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// A checkout that has built an app already holds real artifacts here, which import plugins this
|
|
78
|
+
// package does not depend on, so the stubs are also mocked over whatever is on disk.
|
|
79
|
+
fixtures.forEach(({ absolutePath }) => {
|
|
80
|
+
jest.unstable_mockModule(absolutePath, () => ({
|
|
81
|
+
default: {},
|
|
82
|
+
interpolateProperties: () => {},
|
|
83
|
+
renderEmail: () => {},
|
|
84
|
+
}));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterAll(() => {
|
|
88
|
+
createdFixtures.forEach((absolutePath) => fs.rmSync(absolutePath, { force: true }));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const { default: apiContext } = await import('./apiContext.js');
|
|
92
|
+
|
|
93
|
+
function createApp() {
|
|
94
|
+
const app = new Hono();
|
|
95
|
+
app.use('*', apiContext());
|
|
96
|
+
app.get('/api/ping', (c) => {
|
|
97
|
+
const context = c.get('lowdefyContext');
|
|
98
|
+
return c.json({
|
|
99
|
+
mode: context.mode,
|
|
100
|
+
scrubbed: context.scrubSecrets(`token ${secret} end`),
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
return app;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test('apiContext sets mode to prod', async () => {
|
|
107
|
+
const res = await createApp().request('/api/ping');
|
|
108
|
+
expect(res.status).toEqual(200);
|
|
109
|
+
const body = await res.json();
|
|
110
|
+
expect(body.mode).toEqual('prod');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('apiContext scrubSecrets redacts a planted secret', async () => {
|
|
114
|
+
const res = await createApp().request('/api/ping');
|
|
115
|
+
expect(res.status).toEqual(200);
|
|
116
|
+
const body = await res.json();
|
|
117
|
+
expect(body.scrubbed).toEqual('token [REDACTED] end');
|
|
118
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as Sentry from '@sentry/node';
|
|
18
|
+
import { redactErrorResponse } from '@lowdefy/api';
|
|
19
|
+
|
|
20
|
+
// Hono routes every handler error to the app-level error handler — upstream
|
|
21
|
+
// middleware try/catch never sees them (compose catches per dispatch level).
|
|
22
|
+
// API routes get the serialized error JSON the old apiWrapper returned;
|
|
23
|
+
// page routes get a plain 500.
|
|
24
|
+
function createErrorHandler({ basePath = '', logger }) {
|
|
25
|
+
return async function errorHandler(error, c) {
|
|
26
|
+
const path = basePath ? c.req.path.replace(basePath, '') : c.req.path;
|
|
27
|
+
// Unauthenticated requests to protected endpoints are expected traffic -
|
|
28
|
+
// one warning line and a 401, skipping the structured error log and the
|
|
29
|
+
// Sentry capture so probing cannot flood either. Keys strictly on the
|
|
30
|
+
// error name; the 500 path below is untouched.
|
|
31
|
+
if (error.name === 'AuthenticationError') {
|
|
32
|
+
logger.warn(`Unauthenticated request: ${c.req.method} ${c.req.path}`);
|
|
33
|
+
if (path.startsWith('/api/')) {
|
|
34
|
+
return c.json({ name: error.name, message: error.message }, 401);
|
|
35
|
+
}
|
|
36
|
+
return c.text('Unauthorized', 401);
|
|
37
|
+
}
|
|
38
|
+
const context = c.get('lowdefyContext');
|
|
39
|
+
if (context) {
|
|
40
|
+
await context.handleError(error);
|
|
41
|
+
} else {
|
|
42
|
+
logger.error(error);
|
|
43
|
+
}
|
|
44
|
+
if (process.env.SENTRY_DSN) {
|
|
45
|
+
Sentry.captureException(error);
|
|
46
|
+
}
|
|
47
|
+
if (path.startsWith('/api/')) {
|
|
48
|
+
return c.json(redactErrorResponse(context, error), 500);
|
|
49
|
+
}
|
|
50
|
+
return c.text('Internal Server Error', 500);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default createErrorHandler;
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as Sentry from '@sentry/node';
|
|
18
|
+
|
|
19
|
+
// Replaces @sentry/nextjs route auto-instrumentation: every request becomes
|
|
20
|
+
// an http.server transaction. Handler errors never propagate through
|
|
21
|
+
// middleware in Hono — captureException happens in the app error handler.
|
|
22
|
+
function sentryMiddleware() {
|
|
23
|
+
return async function sentry(c, next) {
|
|
24
|
+
if (!process.env.SENTRY_DSN) {
|
|
25
|
+
return next();
|
|
26
|
+
}
|
|
27
|
+
return Sentry.startSpan({ name: `${c.req.method} ${c.req.path}`, op: 'http.server' }, () =>
|
|
28
|
+
next()
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default sentryMiddleware;
|
|
@@ -17,34 +17,31 @@
|
|
|
17
17
|
import { callAgent } from '@lowdefy/api';
|
|
18
18
|
import { translate, type } from '@lowdefy/helpers';
|
|
19
19
|
|
|
20
|
-
import
|
|
20
|
+
import getPathSegments from '../lib/getPathSegments.js';
|
|
21
21
|
|
|
22
|
-
async function
|
|
22
|
+
async function agentHandler(c) {
|
|
23
|
+
const context = c.get('lowdefyContext');
|
|
23
24
|
const t = (key, values) => translate({ key, values, i18n: context.i18n });
|
|
24
|
-
if (req.method !== 'POST') {
|
|
25
|
+
if (c.req.method !== 'POST') {
|
|
25
26
|
throw new Error(t('agent.runtime.methodNotAllowed'));
|
|
26
27
|
}
|
|
27
|
-
const segments =
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
return;
|
|
28
|
+
const segments = getPathSegments(c, '/api/agent/');
|
|
29
|
+
if (segments.length < 2) {
|
|
30
|
+
return c.json({ error: t('agent.runtime.invalidPath') }, 400);
|
|
31
31
|
}
|
|
32
32
|
const agentId = segments[segments.length - 1];
|
|
33
33
|
const pageId = segments.slice(0, -1).join('/');
|
|
34
34
|
context.logger.info({ event: 'call_agent', agentId, pageId });
|
|
35
|
-
const { conversationId } = req.query;
|
|
36
|
-
const { messages, urlQuery, sharedState } = req.
|
|
35
|
+
const { conversationId } = c.req.query();
|
|
36
|
+
const { messages, urlQuery, sharedState } = await c.req.json();
|
|
37
37
|
if (!Array.isArray(messages)) {
|
|
38
|
-
|
|
39
|
-
return;
|
|
38
|
+
return c.json({ error: t('agent.runtime.messagesMustBeArray') }, 400);
|
|
40
39
|
}
|
|
41
40
|
if (urlQuery != null && (typeof urlQuery !== 'object' || Array.isArray(urlQuery))) {
|
|
42
|
-
|
|
43
|
-
return;
|
|
41
|
+
return c.json({ error: t('agent.runtime.urlQueryMustBeObject') }, 400);
|
|
44
42
|
}
|
|
45
43
|
if (sharedState != null && !type.isObject(sharedState)) {
|
|
46
|
-
|
|
47
|
-
return;
|
|
44
|
+
return c.json({ error: t('agent.runtime.sharedStateMustBeObject') }, 400);
|
|
48
45
|
}
|
|
49
46
|
const { response: webResponse } = await callAgent(context, {
|
|
50
47
|
agentId,
|
|
@@ -55,32 +52,13 @@ async function handler({ context, req, res }) {
|
|
|
55
52
|
urlQuery: urlQuery ?? undefined,
|
|
56
53
|
});
|
|
57
54
|
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const reader = webResponse.body.getReader();
|
|
66
|
-
const decoder = new TextDecoder();
|
|
67
|
-
let done = false;
|
|
68
|
-
while (!done) {
|
|
69
|
-
const { value, done: readerDone } = await reader.read();
|
|
70
|
-
done = readerDone;
|
|
71
|
-
if (value) {
|
|
72
|
-
res.write(decoder.decode(value, { stream: true }));
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
res.end();
|
|
55
|
+
// callAgent returns a Web Response — stream its body straight through.
|
|
56
|
+
return c.body(webResponse.body, 200, {
|
|
57
|
+
'Content-Type': 'text/event-stream',
|
|
58
|
+
'Cache-Control': 'no-cache',
|
|
59
|
+
Connection: 'keep-alive',
|
|
60
|
+
'Content-Encoding': 'none',
|
|
61
|
+
});
|
|
76
62
|
}
|
|
77
63
|
|
|
78
|
-
export
|
|
79
|
-
api: {
|
|
80
|
-
bodyParser: {
|
|
81
|
-
sizeLimit: '10mb',
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
export default apiWrapper(handler);
|
|
64
|
+
export default agentHandler;
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getPageConfig } from '@lowdefy/api';
|
|
18
|
+
|
|
19
|
+
import appMeta from '../../lib/build/appMeta.js';
|
|
20
|
+
import getPathSegments from '../lib/getPathSegments.js';
|
|
21
|
+
|
|
22
|
+
// Page config as JSON for client-side SPA navigation. The first page load is
|
|
23
|
+
// served embedded in the HTML; subsequent navigations fetch from here.
|
|
24
|
+
async function apiPageHandler(c) {
|
|
25
|
+
const context = c.get('lowdefyContext');
|
|
26
|
+
const pageId = getPathSegments(c, '/api/page/').join('/');
|
|
27
|
+
// The client forwards its current query string on the fetch so Dynamic block
|
|
28
|
+
// resolution sees the same urlQuery as an initial HTML load.
|
|
29
|
+
const pageConfig = await getPageConfig(context, { pageId, urlQuery: c.req.query() });
|
|
30
|
+
if (!pageConfig) {
|
|
31
|
+
context.logger.info({ event: 'api_page_not_found', pageId });
|
|
32
|
+
return c.json({ pageConfig: null }, 404);
|
|
33
|
+
}
|
|
34
|
+
context.logger.info({ event: 'api_page_view', pageId });
|
|
35
|
+
// buildId lets a tab that loaded its bundle from an earlier deploy notice
|
|
36
|
+
// that this config comes from a newer build and reload (client/Page.jsx).
|
|
37
|
+
return c.json({ buildId: appMeta.buildId, pageConfig });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default apiPageHandler;
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Hono } from 'hono';
|
|
18
|
+
import { jest } from '@jest/globals';
|
|
19
|
+
|
|
20
|
+
const mockGetPageConfig = jest.fn();
|
|
21
|
+
jest.unstable_mockModule('@lowdefy/api', () => ({
|
|
22
|
+
getPageConfig: mockGetPageConfig,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
jest.unstable_mockModule('../../lib/build/appMeta.js', () => ({
|
|
26
|
+
default: { buildId: 'build-abc' },
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const { default: apiPageHandler } = await import('./apiPage.js');
|
|
30
|
+
|
|
31
|
+
function createApp() {
|
|
32
|
+
const app = new Hono();
|
|
33
|
+
app.use('*', async (c, next) => {
|
|
34
|
+
c.set('lowdefyContext', {
|
|
35
|
+
logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn() },
|
|
36
|
+
});
|
|
37
|
+
await next();
|
|
38
|
+
});
|
|
39
|
+
app.get('/api/page/*', apiPageHandler);
|
|
40
|
+
return app;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
mockGetPageConfig.mockReset();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('apiPageHandler returns the pageConfig stamped with the build id', async () => {
|
|
48
|
+
mockGetPageConfig.mockResolvedValue({ id: 'invoices' });
|
|
49
|
+
const res = await createApp().request('/api/page/invoices');
|
|
50
|
+
expect(res.status).toEqual(200);
|
|
51
|
+
expect(await res.json()).toEqual({ buildId: 'build-abc', pageConfig: { id: 'invoices' } });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('apiPageHandler returns 404 when the page is not found', async () => {
|
|
55
|
+
mockGetPageConfig.mockResolvedValue(null);
|
|
56
|
+
const res = await createApp().request('/api/page/missing');
|
|
57
|
+
expect(res.status).toEqual(404);
|
|
58
|
+
expect(await res.json()).toEqual({ pageConfig: null });
|
|
59
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { authHandler } from '@hono/auth-js';
|
|
18
|
+
|
|
19
|
+
import authJson from '../../lib/build/auth.js';
|
|
20
|
+
|
|
21
|
+
// Replaces pages/api/auth/[...nextauth].js. Hono routes HEAD requests through
|
|
22
|
+
// GET handlers, so the corporate-email pre-check branch must live inside the
|
|
23
|
+
// middleware, before delegating to the Auth.js handler. See:
|
|
24
|
+
// https://next-auth.js.org/tutorials/avoid-corporate-link-checking-email-provider
|
|
25
|
+
function authMiddleware() {
|
|
26
|
+
const handler = authJson.configured === true ? authHandler() : null;
|
|
27
|
+
return async function auth(c, next) {
|
|
28
|
+
if (authJson.configured !== true) {
|
|
29
|
+
return c.json({ message: 'Auth not configured' }, 404);
|
|
30
|
+
}
|
|
31
|
+
if (c.req.method === 'HEAD') {
|
|
32
|
+
return c.body(null, 200);
|
|
33
|
+
}
|
|
34
|
+
return handler(c, next);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default authMiddleware;
|
|
@@ -16,34 +16,32 @@
|
|
|
16
16
|
|
|
17
17
|
import { logClientError } from '@lowdefy/api';
|
|
18
18
|
|
|
19
|
-
import apiWrapper from '../../lib/server/apiWrapper.js';
|
|
20
19
|
import captureSentryError from '../../lib/server/sentry/captureSentryError.js';
|
|
21
20
|
|
|
22
|
-
async function
|
|
23
|
-
if (req.method !== 'POST') {
|
|
21
|
+
async function clientErrorHandler(c) {
|
|
22
|
+
if (c.req.method !== 'POST') {
|
|
24
23
|
throw new Error('Only POST requests are supported.');
|
|
25
24
|
}
|
|
25
|
+
const context = c.get('lowdefyContext');
|
|
26
26
|
|
|
27
|
-
const origin = req.
|
|
27
|
+
const origin = c.req.header('origin');
|
|
28
28
|
if (!origin) {
|
|
29
|
-
|
|
30
|
-
return;
|
|
29
|
+
return c.json({ error: 'Forbidden' }, 403);
|
|
31
30
|
}
|
|
32
31
|
try {
|
|
33
|
-
if (new URL(origin).host !== req.
|
|
34
|
-
|
|
35
|
-
return;
|
|
32
|
+
if (new URL(origin).host !== c.req.header('host')) {
|
|
33
|
+
return c.json({ error: 'Forbidden' }, 403);
|
|
36
34
|
}
|
|
37
35
|
} catch {
|
|
38
|
-
|
|
39
|
-
return;
|
|
36
|
+
return c.json({ error: 'Forbidden' }, 403);
|
|
40
37
|
}
|
|
41
38
|
|
|
39
|
+
const body = await c.req.json();
|
|
42
40
|
// Strip received from payload — prod doesn't need it for schema validation
|
|
43
|
-
if (
|
|
44
|
-
delete
|
|
41
|
+
if (body?.['~e']) {
|
|
42
|
+
delete body['~e'].received;
|
|
45
43
|
}
|
|
46
|
-
const { error, ...response } = await logClientError(context,
|
|
44
|
+
const { error, ...response } = await logClientError(context, body);
|
|
47
45
|
|
|
48
46
|
// Capture client error to Sentry (no-op if Sentry not configured)
|
|
49
47
|
captureSentryError({
|
|
@@ -52,7 +50,7 @@ async function handler({ context, req, res }) {
|
|
|
52
50
|
configLocation: response.source ? { source: response.source, config: response.config } : null,
|
|
53
51
|
});
|
|
54
52
|
|
|
55
|
-
|
|
53
|
+
return c.json({ success: true });
|
|
56
54
|
}
|
|
57
55
|
|
|
58
|
-
export default
|
|
56
|
+
export default clientErrorHandler;
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { runScheduledEndpoint } from '@lowdefy/api';
|
|
18
|
+
|
|
19
|
+
import getPathSegments from '../lib/getPathSegments.js';
|
|
20
|
+
|
|
21
|
+
// Triggered by Vercel Cron: an HTTP GET to /api/cron/<endpointId>. Vercel auto-sends the value of
|
|
22
|
+
// the project env var named exactly CRON_SECRET as `Authorization: Bearer <value>`, and the firing
|
|
23
|
+
// cron expression in the `x-vercel-cron-schedule` header. Fails closed if CRON_SECRET is unset.
|
|
24
|
+
async function cronHandler(c) {
|
|
25
|
+
if (c.req.method !== 'GET') {
|
|
26
|
+
throw new Error('Only GET requests are supported.');
|
|
27
|
+
}
|
|
28
|
+
const context = c.get('lowdefyContext');
|
|
29
|
+
|
|
30
|
+
const secret = process.env.CRON_SECRET;
|
|
31
|
+
if (!secret || c.req.header('authorization') !== `Bearer ${secret}`) {
|
|
32
|
+
context.logger.warn({ event: 'cron_unauthorized' });
|
|
33
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const endpointId = getPathSegments(c, '/api/cron/').join('/');
|
|
37
|
+
const cron = c.req.header('x-vercel-cron-schedule');
|
|
38
|
+
const environment = c.req.header('x-lowdefy-cron-environment');
|
|
39
|
+
context.logger.info({ event: 'call_cron_endpoint', endpointId, cron, environment });
|
|
40
|
+
const response = await runScheduledEndpoint(context, { endpointId, cron, environment });
|
|
41
|
+
return c.json(response);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default cronHandler;
|