@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.
Files changed (79) hide show
  1. package/{pages/_app.js → client/App.jsx} +26 -34
  2. package/client/Page.jsx +122 -0
  3. package/client/clientBuildImports.test.mjs +61 -0
  4. package/client/main.jsx +39 -0
  5. package/client/shouldReloadForBuild.js +48 -0
  6. package/client/shouldReloadForBuild.test.mjs +75 -0
  7. package/lib/build/app.js +8 -1
  8. package/lib/build/appMeta.js +8 -1
  9. package/lib/build/auth.js +8 -1
  10. package/lib/build/config.js +8 -1
  11. package/lib/build/i18n.js +8 -1
  12. package/lib/build/logger.js +8 -1
  13. package/lib/build/theme.js +8 -1
  14. package/lib/client/auth/{Auth.js → Auth.jsx} +10 -2
  15. package/lib/client/auth/{AuthConfigured.js → AuthConfigured.jsx} +21 -10
  16. package/lib/client/sentry/captureSentryError.js +1 -1
  17. package/lib/client/sentry/initSentryClient.js +1 -1
  18. package/lib/client/sentry/setSentryUser.js +1 -1
  19. package/lib/server/auth/{getAuthOptions.js → getAuthConfig.js} +7 -4
  20. package/{pages/api/auth/[...nextauth].js → lib/server/auth/session.js} +9 -15
  21. package/lib/server/auth/strategies.js +47 -0
  22. package/lib/server/log/createHandleError.js +10 -5
  23. package/lib/server/log/createHandleError.test.mjs +85 -0
  24. package/lib/server/log/createLogger.js +28 -1
  25. package/lib/server/log/createLogger.test.mjs +102 -0
  26. package/lib/server/log/logRequest.js +47 -45
  27. package/lib/server/log/maskCredentialKeys.js +37 -0
  28. package/lib/server/log/projectErrorForLog.js +55 -0
  29. package/lib/server/log/redactUrlQuery.js +75 -0
  30. package/lib/server/log/redactUrlQuery.test.mjs +73 -0
  31. package/lib/server/{auth/getServerSession.js → log/serializeErrorForLog.js} +5 -8
  32. package/lib/server/log/serializeErrorForLog.test.mjs +325 -0
  33. package/lib/server/scrubSecrets.js +21 -0
  34. package/lib/server/sentry/captureSentryError.js +13 -1
  35. package/lib/server/sentry/captureSentryError.test.mjs +104 -0
  36. package/lib/server/sentry/initSentry.js +17 -4
  37. package/lib/server/sentry/initSentry.test.mjs +237 -0
  38. package/lib/server/sentry/scrubEvent.js +29 -0
  39. package/lib/server/sentry/scrubEvent.test.mjs +88 -0
  40. package/lib/server/sentry/setSentryUser.js +1 -1
  41. package/lowdefy/createCustomPluginTypesMap.mjs +1 -0
  42. package/package.json +54 -42
  43. package/package.original.json +56 -44
  44. package/src/app.js +140 -0
  45. package/src/html/getAssets.js +45 -0
  46. package/src/html/renderPage.js +80 -0
  47. package/src/html/template.js +95 -0
  48. package/src/index.js +95 -0
  49. package/src/lib/getPathSegments.js +25 -0
  50. package/src/lib/safeScriptJson.js +38 -0
  51. package/src/middleware/apiContext.js +138 -0
  52. package/src/middleware/apiContext.test.mjs +118 -0
  53. package/src/middleware/errorHandler.js +54 -0
  54. package/src/middleware/sentry.js +33 -0
  55. package/{pages/api/agent/[...path].js → src/routes/agent.js} +20 -42
  56. package/src/routes/apiPage.js +40 -0
  57. package/src/routes/apiPage.test.mjs +59 -0
  58. package/src/routes/auth.js +38 -0
  59. package/{pages/api/client-error.js → src/routes/clientError.js} +14 -16
  60. package/src/routes/cron.js +44 -0
  61. package/src/routes/cronForward.js +47 -0
  62. package/src/routes/detached.js +47 -0
  63. package/src/routes/endpoints.js +71 -0
  64. package/src/routes/mcp.js +34 -0
  65. package/{pages/api/request/[...path].js → src/routes/request.js} +10 -10
  66. package/src/routes/usage.js +46 -0
  67. package/src/routes/websocket.js +46 -0
  68. package/vite.config.js +61 -0
  69. package/.eslintrc.yaml +0 -1
  70. package/lib/client/Page.js +0 -56
  71. package/lib/server/apiWrapper.js +0 -87
  72. package/lib/server/serverSidePropsWrapper.js +0 -66
  73. package/next.config.js +0 -34
  74. package/pages/404.js +0 -49
  75. package/pages/[[...pageId]].js +0 -85
  76. package/pages/_document.js +0 -108
  77. package/pages/api/endpoints/[...endpointId].js +0 -32
  78. package/pages/api/usage.js +0 -67
  79. /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 apiWrapper from '../../../lib/server/apiWrapper.js';
20
+ import getPathSegments from '../lib/getPathSegments.js';
21
21
 
22
- async function handler({ context, req, res }) {
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 = req.query.path;
28
- if (!Array.isArray(segments) || segments.length < 2) {
29
- res.status(400).json({ error: t('agent.runtime.invalidPath') });
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.body;
35
+ const { conversationId } = c.req.query();
36
+ const { messages, urlQuery, sharedState } = await c.req.json();
37
37
  if (!Array.isArray(messages)) {
38
- res.status(400).json({ error: t('agent.runtime.messagesMustBeArray') });
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
- res.status(400).json({ error: t('agent.runtime.urlQueryMustBeObject') });
43
- return;
41
+ return c.json({ error: t('agent.runtime.urlQueryMustBeObject') }, 400);
44
42
  }
45
43
  if (sharedState != null && !type.isObject(sharedState)) {
46
- res.status(400).json({ error: t('agent.runtime.sharedStateMustBeObject') });
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
- // Stream the Web Response body to the Next.js response
59
- res.setHeader('Content-Type', 'text/event-stream');
60
- res.setHeader('Cache-Control', 'no-cache');
61
- res.setHeader('Connection', 'keep-alive');
62
- res.setHeader('Content-Encoding', 'none');
63
- res.setHeader('Transfer-Encoding', 'chunked');
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 const config = {
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 handler({ context, req, res }) {
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.headers.origin;
27
+ const origin = c.req.header('origin');
28
28
  if (!origin) {
29
- res.status(403).json({ error: 'Forbidden' });
30
- return;
29
+ return c.json({ error: 'Forbidden' }, 403);
31
30
  }
32
31
  try {
33
- if (new URL(origin).host !== req.headers.host) {
34
- res.status(403).json({ error: 'Forbidden' });
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
- res.status(403).json({ error: 'Forbidden' });
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 (req.body?.['~e']) {
44
- delete req.body['~e'].received;
41
+ if (body?.['~e']) {
42
+ delete body['~e'].received;
45
43
  }
46
- const { error, ...response } = await logClientError(context, req.body);
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
- res.status(200).json({ success: true });
53
+ return c.json({ success: true });
56
54
  }
57
55
 
58
- export default apiWrapper(handler);
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;