@lowdefy/server 6.1.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/client/App.jsx +41 -21
  2. package/client/Page.jsx +90 -26
  3. package/client/clearReloadedForTypes.js +29 -0
  4. package/client/clientBuildImports.test.mjs +6 -7
  5. package/client/getShownPage.js +28 -0
  6. package/client/getShownPage.test.mjs +52 -0
  7. package/client/loadAllIcons.js +45 -0
  8. package/client/loadPageTypes.js +44 -0
  9. package/client/main.jsx +22 -1
  10. package/client/reloadedForTypesKey.js +21 -0
  11. package/client/shouldReloadForTypes.js +38 -0
  12. package/client/shouldReloadForTypes.test.mjs +63 -0
  13. package/client/types.js +28 -0
  14. package/lib/build/security.js +26 -0
  15. package/lib/client/auth/Auth.jsx +11 -6
  16. package/lib/client/auth/AuthConfigured.jsx +222 -36
  17. package/lib/client/auth/AuthNotConfigured.js +44 -4
  18. package/lib/client/auth/createFetchCheckingBuild.js +45 -0
  19. package/lib/client/auth/createFetchCheckingBuild.test.mjs +100 -0
  20. package/lib/client/bundleBuildId.js +23 -0
  21. package/lib/client/createLogUsage.js +46 -25
  22. package/lib/client/createLogUsage.test.mjs +90 -0
  23. package/lib/client/getCspNonce.js +25 -0
  24. package/lib/server/auth/createSystemContext.js +70 -0
  25. package/lib/server/auth/{getAuthConfig.js → getAuth.js} +15 -9
  26. package/lib/server/auth/getStrategies.js +40 -0
  27. package/lib/server/getClientAddress.js +56 -0
  28. package/lib/server/getClientAddress.test.mjs +108 -0
  29. package/lib/server/log/createHandleError.js +7 -0
  30. package/lib/server/log/createHandleError.test.mjs +17 -0
  31. package/lib/server/log/createLogger.test.mjs +15 -0
  32. package/lib/server/log/logRequest.js +5 -3
  33. package/lib/server/log/projectErrorForLog.js +6 -0
  34. package/lib/server/log/serializeErrorForLog.test.mjs +13 -1
  35. package/lib/server/sentry/captureSentryError.js +17 -3
  36. package/lib/server/sentry/captureSentryError.test.mjs +37 -13
  37. package/lib/server/sentry/initSentry.js +39 -18
  38. package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
  39. package/lib/server/sentry/initSentry.test.mjs +132 -4
  40. package/lib/server/sentry/scrubEvent.js +12 -1
  41. package/lib/server/sentry/sentrySdk.js +23 -0
  42. package/lib/server/sentry/setSentryUser.js +3 -3
  43. package/lowdefy/build.mjs +1 -0
  44. package/lowdefy/build.test.mjs +68 -0
  45. package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
  46. package/package.json +42 -36
  47. package/package.original.json +42 -36
  48. package/src/app.js +74 -17
  49. package/src/app.test.mjs +118 -0
  50. package/src/html/collectChunkClosure.js +31 -0
  51. package/src/html/collectLazyImports.js +41 -0
  52. package/src/html/collectPageTypesAssets.js +57 -0
  53. package/src/html/collectPageTypesAssets.test.mjs +130 -0
  54. package/src/html/getAssets.js +20 -1
  55. package/src/html/getHomePath.js +29 -0
  56. package/src/html/getPageAssets.js +32 -0
  57. package/src/html/getPageAssets.test.mjs +47 -0
  58. package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
  59. package/src/html/renderPage.js +80 -19
  60. package/src/html/renderPage.test.mjs +189 -0
  61. package/src/html/template.js +50 -15
  62. package/src/html/template.test.mjs +126 -0
  63. package/src/index.js +28 -29
  64. package/src/initServer.js +61 -0
  65. package/src/initServer.test.mjs +116 -0
  66. package/src/lib/getRequestPath.js +29 -0
  67. package/src/middleware/apiContext.js +65 -13
  68. package/src/middleware/apiContext.test.mjs +66 -6
  69. package/src/middleware/createSameOriginGuard.js +36 -0
  70. package/src/middleware/createSameOriginGuard.test.mjs +102 -0
  71. package/src/middleware/errorHandler.js +56 -4
  72. package/src/middleware/errorHandler.test.mjs +440 -0
  73. package/{lib/server/auth/session.js → src/middleware/noStore.js} +9 -13
  74. package/src/middleware/refuseOtherBuild.js +49 -0
  75. package/src/middleware/refuseOtherBuild.test.mjs +63 -0
  76. package/src/middleware/requestTimeout.js +38 -0
  77. package/src/middleware/sentry.js +13 -5
  78. package/src/middleware/stampBuildId.js +30 -0
  79. package/src/middleware/stampBuildId.test.mjs +41 -0
  80. package/src/middleware/stampCaller.js +38 -0
  81. package/src/middleware/stampCaller.test.mjs +64 -0
  82. package/src/routes/agent.js +15 -3
  83. package/src/routes/apiPage.js +51 -9
  84. package/src/routes/apiPage.test.mjs +99 -15
  85. package/src/routes/auth.js +15 -9
  86. package/src/routes/auth.test.mjs +85 -0
  87. package/src/routes/clientError.js +11 -13
  88. package/src/routes/cron.js +3 -1
  89. package/src/routes/cronForward.js +3 -1
  90. package/src/routes/detached.js +22 -9
  91. package/src/routes/endpoints.js +27 -26
  92. package/src/routes/endpointsTextAnswer.test.mjs +77 -0
  93. package/src/routes/mcp.js +82 -5
  94. package/src/routes/mcp.test.mjs +215 -0
  95. package/src/routes/mcpMemberToken.test.mjs +125 -0
  96. package/src/routes/mcpProtectedResourceMetadata.js +34 -0
  97. package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
  98. package/src/routes/mountOauthDiscovery.js +44 -0
  99. package/src/routes/mountOauthDiscovery.test.mjs +79 -0
  100. package/src/routes/mountPageRoutes.js +29 -0
  101. package/src/routes/mountPageRoutes.test.mjs +56 -0
  102. package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
  103. package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
  104. package/src/routes/request.js +5 -3
  105. package/src/routes/requestBody.test.mjs +146 -0
  106. package/src/routes/usage.js +6 -2
  107. package/src/routes/user.js +27 -0
  108. package/src/routes/websocket.js +26 -3
  109. package/src/routes/websocket.test.mjs +106 -0
  110. package/src/routes/wellKnownFallback.js +30 -0
  111. package/vite.config.js +55 -2
  112. package/client/shouldReloadForBuild.js +0 -48
  113. package/client/shouldReloadForBuild.test.mjs +0 -75
@@ -0,0 +1,68 @@
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 { jest } from '@jest/globals';
18
+
19
+ const mockBuild = jest.fn();
20
+
21
+ jest.unstable_mockModule('@lowdefy/build', () => ({ default: mockBuild }));
22
+ jest.unstable_mockModule('@lowdefy/logger/node', () => ({
23
+ createNodeLogger: jest.fn(() => 'logger'),
24
+ }));
25
+ jest.unstable_mockModule('./createCustomPluginTypesMap.mjs', () => ({
26
+ default: jest.fn().mockResolvedValue({}),
27
+ }));
28
+ jest.unstable_mockModule('./createCustomPluginMessagesMap.mjs', () => ({
29
+ default: jest.fn().mockResolvedValue({}),
30
+ }));
31
+
32
+ const savedSkip = process.env.LOWDEFY_BUILD_SKIP_PLUGIN_CHECKS;
33
+ let loadCount = 0;
34
+
35
+ // build.mjs runs the build when imported, so each case loads a fresh module
36
+ // instance and waits for the build call.
37
+ async function runScript() {
38
+ loadCount += 1;
39
+ await import(`./build.mjs?case=${loadCount}`);
40
+ await new Promise((resolve) => setImmediate(resolve));
41
+ }
42
+
43
+ beforeEach(() => {
44
+ delete process.env.LOWDEFY_BUILD_SKIP_PLUGIN_CHECKS;
45
+ mockBuild.mockReset();
46
+ mockBuild.mockResolvedValue();
47
+ });
48
+
49
+ afterAll(() => {
50
+ if (savedSkip === undefined) {
51
+ delete process.env.LOWDEFY_BUILD_SKIP_PLUGIN_CHECKS;
52
+ } else {
53
+ process.env.LOWDEFY_BUILD_SKIP_PLUGIN_CHECKS = savedSkip;
54
+ }
55
+ });
56
+
57
+ test('build passes skipPluginChecks true when LOWDEFY_BUILD_SKIP_PLUGIN_CHECKS is true', async () => {
58
+ process.env.LOWDEFY_BUILD_SKIP_PLUGIN_CHECKS = 'true';
59
+ await runScript();
60
+ expect(mockBuild).toHaveBeenCalledTimes(1);
61
+ expect(mockBuild.mock.calls[0][0].skipPluginChecks).toBe(true);
62
+ });
63
+
64
+ test('build passes skipPluginChecks false when LOWDEFY_BUILD_SKIP_PLUGIN_CHECKS is unset', async () => {
65
+ await runScript();
66
+ expect(mockBuild).toHaveBeenCalledTimes(1);
67
+ expect(mockBuild.mock.calls[0][0].skipPluginChecks).toBe(false);
68
+ });
@@ -42,14 +42,15 @@ async function createCustomPluginTypesMap({ directories }) {
42
42
  agents: {},
43
43
  auth: {
44
44
  adapters: {},
45
- callbacks: {},
46
- events: {},
47
45
  providers: {},
46
+ strategies: {},
48
47
  },
49
48
  blockMetas: {},
49
+ connectionMetas: {},
50
50
  blocks: {},
51
51
  connections: {},
52
52
  icons: {},
53
+ iconSets: {},
53
54
  operators: {
54
55
  client: {},
55
56
  server: {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/server",
3
- "version": "6.1.0",
3
+ "version": "7.0.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "",
6
6
  "homepage": "https://lowdefy.com",
@@ -39,59 +39,65 @@
39
39
  ],
40
40
  "dependencies": {
41
41
  "@ant-design/cssinjs": "2.1.2",
42
- "@ant-design/x": "2.7.0",
43
- "@auth/core": "0.41.3",
44
- "@hono/auth-js": "1.1.1",
45
- "@hono/mcp": "0.3.0",
42
+ "@ant-design/x": "2.9.0",
43
+ "@better-auth/oauth-provider": "1.7.7",
44
+ "@better-auth/passkey": "1.7.7",
45
+ "@hono/mcp": "0.3.2",
46
46
  "@hono/node-server": "2.0.10",
47
- "@lowdefy/actions-core": "6.1.0",
48
- "@lowdefy/api": "6.1.0",
49
- "@lowdefy/block-utils": "6.1.0",
50
- "@lowdefy/blocks-aggrid": "6.1.0",
51
- "@lowdefy/blocks-antd": "6.1.0",
52
- "@lowdefy/blocks-antd-x": "6.1.0",
53
- "@lowdefy/blocks-basic": "6.1.0",
54
- "@lowdefy/blocks-echarts": "6.1.0",
55
- "@lowdefy/blocks-files": "6.1.0",
56
- "@lowdefy/blocks-loaders": "6.1.0",
57
- "@lowdefy/blocks-markdown": "6.1.0",
58
- "@lowdefy/blocks-tiptap": "6.1.0",
59
- "@lowdefy/client": "6.1.0",
60
- "@lowdefy/errors": "6.1.0",
61
- "@lowdefy/helpers": "6.1.0",
62
- "@lowdefy/layout": "6.1.0",
63
- "@lowdefy/logger": "6.1.0",
64
- "@lowdefy/node-utils": "6.1.0",
65
- "@lowdefy/operators-js": "6.1.0",
66
- "@lowdefy/plugin-next-auth": "6.1.0",
67
- "@lowdefy/websockets-core": "6.1.0",
47
+ "@lowdefy/actions-core": "7.0.0",
48
+ "@lowdefy/api": "7.0.0",
49
+ "@lowdefy/block-utils": "7.0.0",
50
+ "@lowdefy/blocks-aggrid": "7.0.0",
51
+ "@lowdefy/blocks-antd": "7.0.0",
52
+ "@lowdefy/blocks-antd-x": "7.0.0",
53
+ "@lowdefy/blocks-basic": "7.0.0",
54
+ "@lowdefy/blocks-captcha": "7.0.0",
55
+ "@lowdefy/blocks-echarts": "7.0.0",
56
+ "@lowdefy/blocks-files": "7.0.0",
57
+ "@lowdefy/blocks-loaders": "7.0.0",
58
+ "@lowdefy/blocks-markdown": "7.0.0",
59
+ "@lowdefy/blocks-table": "7.0.0",
60
+ "@lowdefy/blocks-tiptap": "7.0.0",
61
+ "@lowdefy/client": "7.0.0",
62
+ "@lowdefy/connection-anthropic": "7.0.0",
63
+ "@lowdefy/errors": "7.0.0",
64
+ "@lowdefy/helpers": "7.0.0",
65
+ "@lowdefy/layout": "7.0.0",
66
+ "@lowdefy/logger": "7.0.0",
67
+ "@lowdefy/node-utils": "7.0.0",
68
+ "@lowdefy/operators-js": "7.0.0",
69
+ "@lowdefy/operators-nunjucks": "7.0.0",
70
+ "@lowdefy/plugin-better-auth": "7.0.0",
71
+ "@lowdefy/websockets-core": "7.0.0",
68
72
  "@sentry/browser": "10.57.0",
69
73
  "@sentry/node": "10.57.0",
74
+ "@sentry/node-core": "10.57.0",
70
75
  "@sentry/vite-plugin": "5.3.0",
71
- "@tailwindcss/postcss": "4.3.1",
76
+ "@tailwindcss/postcss": "4.3.3",
72
77
  "@vitejs/plugin-react": "6.0.2",
73
- "antd": "6.3.1",
78
+ "antd": "6.6.5",
79
+ "better-auth": "1.7.7",
74
80
  "dayjs": "1.11.20",
75
- "hono": "4.13.5",
76
- "pino": "10.3.1",
81
+ "hono": "4.13.7",
82
+ "lightningcss": "1.32.0",
77
83
  "react": "18.2.0",
78
84
  "react-dom": "18.2.0",
79
- "react-icons": "5.6.0",
80
- "tailwindcss": "4.3.1",
85
+ "tailwindcss": "4.3.3",
81
86
  "uuid": "14.0.0",
82
87
  "vite": "8.0.16",
83
88
  "ws": "8.21.0"
84
89
  },
85
90
  "devDependencies": {
86
- "@lowdefy/build": "6.1.0",
87
- "@tailwindcss/postcss": "4.3.1",
91
+ "@jest/globals": "28.1.3",
92
+ "@lowdefy/build": "7.0.0",
93
+ "@tailwindcss/postcss": "4.3.3",
88
94
  "jest": "28.1.3",
89
- "tailwindcss": "4.3.1",
95
+ "tailwindcss": "4.3.3",
90
96
  "yaml": "2.9.0",
91
97
  "yargs": "18.0.0"
92
98
  },
93
99
  "engines": {
94
- "node": ">=22"
100
+ "node": ">=24"
95
101
  },
96
102
  "publishConfig": {
97
103
  "access": "public"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/server",
3
- "version": "6.1.0",
3
+ "version": "7.0.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "",
6
6
  "homepage": "https://lowdefy.com",
@@ -47,59 +47,65 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@ant-design/cssinjs": "2.1.2",
50
- "@ant-design/x": "2.7.0",
51
- "@auth/core": "0.41.3",
52
- "@hono/auth-js": "1.1.1",
53
- "@hono/mcp": "0.3.0",
50
+ "@ant-design/x": "2.9.0",
51
+ "@better-auth/oauth-provider": "1.7.7",
52
+ "@better-auth/passkey": "1.7.7",
53
+ "@hono/mcp": "0.3.2",
54
54
  "@hono/node-server": "2.0.10",
55
- "@lowdefy/actions-core": "6.1.0",
56
- "@lowdefy/api": "6.1.0",
57
- "@lowdefy/block-utils": "6.1.0",
58
- "@lowdefy/blocks-aggrid": "6.1.0",
59
- "@lowdefy/blocks-antd": "6.1.0",
60
- "@lowdefy/blocks-antd-x": "6.1.0",
61
- "@lowdefy/blocks-basic": "6.1.0",
62
- "@lowdefy/blocks-echarts": "6.1.0",
63
- "@lowdefy/blocks-files": "6.1.0",
64
- "@lowdefy/blocks-loaders": "6.1.0",
65
- "@lowdefy/blocks-markdown": "6.1.0",
66
- "@lowdefy/blocks-tiptap": "6.1.0",
67
- "@lowdefy/client": "6.1.0",
68
- "@lowdefy/errors": "6.1.0",
69
- "@lowdefy/helpers": "6.1.0",
70
- "@lowdefy/layout": "6.1.0",
71
- "@lowdefy/logger": "6.1.0",
72
- "@lowdefy/node-utils": "6.1.0",
73
- "@lowdefy/operators-js": "6.1.0",
74
- "@lowdefy/plugin-next-auth": "6.1.0",
75
- "@lowdefy/websockets-core": "6.1.0",
55
+ "@lowdefy/actions-core": "7.0.0",
56
+ "@lowdefy/api": "7.0.0",
57
+ "@lowdefy/block-utils": "7.0.0",
58
+ "@lowdefy/blocks-aggrid": "7.0.0",
59
+ "@lowdefy/blocks-antd": "7.0.0",
60
+ "@lowdefy/blocks-antd-x": "7.0.0",
61
+ "@lowdefy/blocks-basic": "7.0.0",
62
+ "@lowdefy/blocks-captcha": "7.0.0",
63
+ "@lowdefy/blocks-echarts": "7.0.0",
64
+ "@lowdefy/blocks-files": "7.0.0",
65
+ "@lowdefy/blocks-loaders": "7.0.0",
66
+ "@lowdefy/blocks-markdown": "7.0.0",
67
+ "@lowdefy/blocks-table": "7.0.0",
68
+ "@lowdefy/blocks-tiptap": "7.0.0",
69
+ "@lowdefy/client": "7.0.0",
70
+ "@lowdefy/connection-anthropic": "7.0.0",
71
+ "@lowdefy/errors": "7.0.0",
72
+ "@lowdefy/helpers": "7.0.0",
73
+ "@lowdefy/layout": "7.0.0",
74
+ "@lowdefy/logger": "7.0.0",
75
+ "@lowdefy/node-utils": "7.0.0",
76
+ "@lowdefy/operators-js": "7.0.0",
77
+ "@lowdefy/operators-nunjucks": "7.0.0",
78
+ "@lowdefy/plugin-better-auth": "7.0.0",
79
+ "@lowdefy/websockets-core": "7.0.0",
76
80
  "@sentry/browser": "10.57.0",
77
81
  "@sentry/node": "10.57.0",
82
+ "@sentry/node-core": "10.57.0",
78
83
  "@sentry/vite-plugin": "5.3.0",
79
- "@tailwindcss/postcss": "4.3.1",
84
+ "@tailwindcss/postcss": "4.3.3",
80
85
  "@vitejs/plugin-react": "6.0.2",
81
- "antd": "6.3.1",
86
+ "antd": "6.6.5",
87
+ "better-auth": "1.7.7",
82
88
  "dayjs": "1.11.20",
83
- "hono": "4.13.5",
84
- "pino": "10.3.1",
89
+ "hono": "4.13.7",
90
+ "lightningcss": "1.32.0",
85
91
  "react": "18.2.0",
86
92
  "react-dom": "18.2.0",
87
- "react-icons": "5.6.0",
88
- "tailwindcss": "4.3.1",
93
+ "tailwindcss": "4.3.3",
89
94
  "uuid": "14.0.0",
90
95
  "vite": "8.0.16",
91
96
  "ws": "8.21.0"
92
97
  },
93
98
  "devDependencies": {
94
- "@lowdefy/build": "6.1.0",
95
- "@tailwindcss/postcss": "4.3.1",
99
+ "@jest/globals": "28.1.3",
100
+ "@lowdefy/build": "7.0.0",
101
+ "@tailwindcss/postcss": "4.3.3",
96
102
  "jest": "28.1.3",
97
- "tailwindcss": "4.3.1",
103
+ "tailwindcss": "4.3.3",
98
104
  "yaml": "2.9.0",
99
105
  "yargs": "18.0.0"
100
106
  },
101
107
  "engines": {
102
- "node": ">=22"
108
+ "node": ">=24"
103
109
  },
104
110
  "publishConfig": {
105
111
  "access": "public"
package/src/app.js CHANGED
@@ -17,13 +17,13 @@
17
17
  import { Hono } from 'hono';
18
18
  import { bodyLimit } from 'hono/body-limit';
19
19
  import { compress } from 'hono/compress';
20
- import { timeout } from 'hono/timeout';
21
- import { initAuthConfig } from '@hono/auth-js';
22
20
  import { serveStatic } from '@hono/node-server/serve-static';
21
+ import { runInCredentialScope } from '@lowdefy/node-utils';
23
22
 
24
23
  import agentHandler from './routes/agent.js';
25
24
  import apiContext from './middleware/apiContext.js';
26
25
  import apiPageHandler from './routes/apiPage.js';
26
+ import appMeta from '../lib/build/appMeta.js';
27
27
  import authJson from '../lib/build/auth.js';
28
28
  import authMiddleware from './routes/auth.js';
29
29
  import clientErrorHandler from './routes/clientError.js';
@@ -33,13 +33,23 @@ import cronForwardHandler from './routes/cronForward.js';
33
33
  import cronHandler from './routes/cron.js';
34
34
  import detachedHandler from './routes/detached.js';
35
35
  import endpointsHandler from './routes/endpoints.js';
36
- import getAuthConfig from '../lib/server/auth/getAuthConfig.js';
36
+ import getAuth from '../lib/server/auth/getAuth.js';
37
+ import getStrategies from '../lib/server/auth/getStrategies.js';
37
38
  import lowdefyConfig from '../lib/build/config.js';
38
39
  import mcpHandler from './routes/mcp.js';
40
+ import mountOauthDiscovery from './routes/mountOauthDiscovery.js';
41
+ import mountPageRoutes from './routes/mountPageRoutes.js';
42
+ import noStore from './middleware/noStore.js';
43
+ import wellKnownFallbackHandler from './routes/wellKnownFallback.js';
39
44
  import renderPage from './html/renderPage.js';
40
45
  import requestHandler from './routes/request.js';
46
+ import refuseOtherBuild from './middleware/refuseOtherBuild.js';
47
+ import requestTimeout from './middleware/requestTimeout.js';
41
48
  import sentryMiddleware from './middleware/sentry.js';
49
+ import stampBuildId from './middleware/stampBuildId.js';
50
+ import stampCaller from './middleware/stampCaller.js';
42
51
  import usageHandler from './routes/usage.js';
52
+ import userHandler from './routes/user.js';
43
53
  import websocketHandler from './routes/websocket.js';
44
54
 
45
55
  const basePath = lowdefyConfig.basePath ?? '';
@@ -53,7 +63,10 @@ const requestTimeoutMs = lowdefyConfig.requestTimeout ?? 30000;
53
63
  // `serveStaticAssets` is true for the Node server (it serves `dist/client` itself). On a
54
64
  // platform that serves the built client + public files from a CDN (e.g. Vercel), pass false so
55
65
  // the request handler skips the Node-only static middleware and only owns the dynamic routes.
56
- function createApp({ serveStaticAssets = true } = {}) {
66
+ // `clientAddressHeader` names the header a platform's edge sets to the client address on every
67
+ // request (Vercel's x-real-ip). Without it the Node server resolves the address from the
68
+ // connection and config.trustedProxies.
69
+ function createApp({ serveStaticAssets = true, clientAddressHeader } = {}) {
57
70
  const app = basePath ? new Hono().basePath(basePath) : new Hono();
58
71
  const logger = createLogger({ server: 'lowdefy' });
59
72
 
@@ -61,12 +74,17 @@ function createApp({ serveStaticAssets = true } = {}) {
61
74
  // before all middleware so probes skip auth, session, request logging, and Sentry.
62
75
  app.get('/api/lowdefy-health', (c) => c.json({ status: 'ok' }));
63
76
 
77
+ // Each request runs in its own credential scope: what it marks with _credential is scrubbed
78
+ // from every log line written while it is handled.
79
+ app.use('*', (c, next) => runInCredentialScope(next));
80
+
64
81
  app.use('*', sentryMiddleware());
65
82
 
66
83
  // Bound request duration so a hung upstream returns an error instead of running to the platform's
67
84
  // function limit (cost protection on serverless). Agent streaming is long-lived by design, so it
68
85
  // is exempt. Configured via `config.requestTimeout` in lowdefy.yaml (0 disables).
69
86
  if (requestTimeoutMs > 0) {
87
+ const timeoutMiddleware = requestTimeout({ timeoutMs: requestTimeoutMs });
70
88
  app.use('*', async (c, next) => {
71
89
  if (
72
90
  c.req.path.includes('/api/agent/') ||
@@ -75,7 +93,7 @@ function createApp({ serveStaticAssets = true } = {}) {
75
93
  ) {
76
94
  return next();
77
95
  }
78
- return timeout(requestTimeoutMs)(c, next);
96
+ return timeoutMiddleware(c, next);
79
97
  });
80
98
  }
81
99
 
@@ -93,18 +111,46 @@ function createApp({ serveStaticAssets = true } = {}) {
93
111
  });
94
112
 
95
113
  if (authJson.configured === true) {
96
- app.use(
97
- '*',
98
- initAuthConfig(() => getAuthConfig({ logger }))
99
- );
114
+ // Construct the BetterAuth instance and strategy verifiers at startup so
115
+ // config errors fail boot instead of the first request, and so the
116
+ // process-memoized singletons capture the base logger rather than the
117
+ // first request's rid-scoped child.
118
+ getAuth({ logger });
119
+ getStrategies({ logger });
100
120
  }
101
121
 
102
- app.use('/api/*', apiContext());
103
- app.use('/api/auth/*', authMiddleware());
104
- app.all('/api/request/*', requestHandler);
122
+ mountOauthDiscovery({
123
+ app,
124
+ auth: authJson.configured === true ? getAuth({ logger }) : null,
125
+ });
126
+ app.all('/.well-known/*', wellKnownFallbackHandler);
127
+
128
+ app.use('/api/*', apiContext({ clientAddressHeader }));
129
+ // Responses that carry the caller's data are never stored by a browser or proxy cache.
130
+ // Static assets keep their own caching below; the page routes use the same middleware.
131
+ const noStoreMiddleware = noStore();
132
+ ['/api/page/*', '/api/user', '/api/request/*', '/api/endpoints/*'].forEach((route) =>
133
+ app.use(route, noStoreMiddleware)
134
+ );
135
+ // A tab left open across a deploy (or restored from the browser cache) runs
136
+ // the old bundle against this build: see refuseOtherBuild and stampBuildId.
137
+ app.use('/api/auth/*', stampBuildId({ buildId: appMeta.buildId }));
138
+ app.use('/api/user', stampBuildId({ buildId: appMeta.buildId }));
139
+ app.use('/api/auth/*', authMiddleware({ logger }));
140
+ // The caller each page, request and endpoint response was answered as: the
141
+ // client reloads when it differs from the caller it renders (stampCaller).
142
+ app.use('/api/request/*', stampCaller());
143
+ app.use('/api/endpoints/*', stampCaller());
144
+ app.use('/api/page/*', stampCaller());
145
+ app.all('/api/request/*', refuseOtherBuild({ buildId: appMeta.buildId }), requestHandler);
105
146
  // Endpoint payloads may carry base64 file content (emitFileContent + CallAPI);
106
147
  // cap bodies at 10 MiB to match the agent route.
107
- app.all('/api/endpoints/*', bodyLimit({ maxSize: 10 * 1024 * 1024 }), endpointsHandler);
148
+ app.all(
149
+ '/api/endpoints/*',
150
+ refuseOtherBuild({ buildId: appMeta.buildId }),
151
+ bodyLimit({ maxSize: 10 * 1024 * 1024 }),
152
+ endpointsHandler
153
+ );
108
154
  app.get('/api/cron/*', cronHandler);
109
155
  app.get('/api/cron-forward/*', cronForwardHandler);
110
156
  app.post('/api/detached/*', detachedHandler);
@@ -114,10 +160,21 @@ function createApp({ serveStaticAssets = true } = {}) {
114
160
  app.all('/api/mcp', bodyLimit({ maxSize: 10 * 1024 * 1024 }), mcpHandler);
115
161
  app.get('/api/websocket', websocketHandler);
116
162
  app.get('/api/page/*', apiPageHandler);
163
+ app.get('/api/user', userHandler);
117
164
 
118
165
  // Vite build output (includes public/ via Vite's publicDir copy). Falls
119
166
  // through to the page routes when no file matches.
120
167
  if (serveStaticAssets) {
168
+ // Vite content-hashes everything under assets/, so a file never changes at
169
+ // its URL. Pages load many small chunks; without this each is re-fetched
170
+ // whenever the browser's heuristic freshness runs out (serveStatic sets
171
+ // Last-Modified but never answers 304).
172
+ app.use('/assets/*', async (c, next) => {
173
+ await next();
174
+ if (c.res.status === 200) {
175
+ c.header('Cache-Control', 'public, max-age=31536000, immutable');
176
+ }
177
+ });
121
178
  app.use(
122
179
  '/*',
123
180
  serveStatic({
@@ -127,10 +184,10 @@ function createApp({ serveStaticAssets = true } = {}) {
127
184
  );
128
185
  }
129
186
 
130
- app.use('/*', apiContext());
131
- app.get('/', (c) => renderPage(c, { pageId: '' }));
132
- app.get('/404', (c) => renderPage(c, { pageId: '404', status: 404 }));
133
- app.get('/:rest{.+}', (c) => renderPage(c, { pageId: c.req.param('rest') }));
187
+ app.use('/*', apiContext({ clientAddressHeader }));
188
+ // Registered after the static middleware, which answers a file without calling next().
189
+ app.use('/*', noStoreMiddleware);
190
+ mountPageRoutes({ app, basePath, renderPage });
134
191
 
135
192
  app.onError(createErrorHandler({ basePath, logger }));
136
193
 
@@ -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 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+
21
+ import { jest } from '@jest/globals';
22
+
23
+ // The real app wiring with every handler stubbed, to check which routes carry which
24
+ // Cache-Control header.
25
+ function handler(c) {
26
+ return c.json({ ok: true });
27
+ }
28
+ function throwingHandler() {
29
+ throw new Error('Request failed.');
30
+ }
31
+ function passThrough() {
32
+ return (c, next) => next();
33
+ }
34
+
35
+ jest.unstable_mockModule('./routes/agent.js', () => ({ default: handler }));
36
+ jest.unstable_mockModule('./routes/apiPage.js', () => ({ default: handler }));
37
+ jest.unstable_mockModule('./routes/auth.js', () => ({ default: () => handler }));
38
+ jest.unstable_mockModule('./routes/clientError.js', () => ({ default: handler }));
39
+ jest.unstable_mockModule('./routes/cron.js', () => ({ default: handler }));
40
+ jest.unstable_mockModule('./routes/cronForward.js', () => ({ default: handler }));
41
+ jest.unstable_mockModule('./routes/detached.js', () => ({ default: handler }));
42
+ jest.unstable_mockModule('./routes/endpoints.js', () => ({ default: throwingHandler }));
43
+ jest.unstable_mockModule('./routes/mcp.js', () => ({ default: handler }));
44
+ jest.unstable_mockModule('./routes/mountOauthDiscovery.js', () => ({ default: () => {} }));
45
+ jest.unstable_mockModule('./routes/request.js', () => ({ default: handler }));
46
+ jest.unstable_mockModule('./routes/usage.js', () => ({ default: handler }));
47
+ jest.unstable_mockModule('./routes/user.js', () => ({ default: handler }));
48
+ jest.unstable_mockModule('./routes/websocket.js', () => ({ default: handler }));
49
+ jest.unstable_mockModule('./routes/wellKnownFallback.js', () => ({ default: handler }));
50
+ jest.unstable_mockModule('./html/renderPage.js', () => ({
51
+ default: (c) => c.html('<html></html>'),
52
+ }));
53
+ jest.unstable_mockModule('./middleware/apiContext.js', () => ({ default: passThrough }));
54
+ jest.unstable_mockModule('./middleware/sentry.js', () => ({ default: passThrough }));
55
+ jest.unstable_mockModule('./middleware/errorHandler.js', () => ({
56
+ default: () => (error, c) => c.json({ error: error.message }, 500),
57
+ }));
58
+ jest.unstable_mockModule('../lib/build/appMeta.js', () => ({ default: { buildId: 'build-1' } }));
59
+ jest.unstable_mockModule('../lib/build/auth.js', () => ({ default: { configured: false } }));
60
+ jest.unstable_mockModule('../lib/build/config.js', () => ({ default: {} }));
61
+ jest.unstable_mockModule('../lib/server/auth/getAuth.js', () => ({ default: jest.fn() }));
62
+ jest.unstable_mockModule('../lib/server/auth/getStrategies.js', () => ({ default: jest.fn() }));
63
+ jest.unstable_mockModule('../lib/server/log/createLogger.js', () => ({
64
+ default: () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }),
65
+ }));
66
+
67
+ const { default: createApp } = await import('./app.js');
68
+
69
+ const originalCwd = process.cwd();
70
+ let serverDirectory;
71
+
72
+ beforeAll(() => {
73
+ serverDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'lowdefy-server-app-'));
74
+ fs.mkdirSync(path.join(serverDirectory, 'dist/client/assets'), { recursive: true });
75
+ fs.writeFileSync(path.join(serverDirectory, 'dist/client/assets/main-abc.js'), 'export {};');
76
+ fs.writeFileSync(path.join(serverDirectory, 'dist/client/icon.svg'), '<svg></svg>');
77
+ process.chdir(serverDirectory);
78
+ });
79
+
80
+ afterAll(() => {
81
+ process.chdir(originalCwd);
82
+ fs.rmSync(serverDirectory, { recursive: true, force: true });
83
+ });
84
+
85
+ test.each([
86
+ ['GET', '/home'],
87
+ ['GET', '/'],
88
+ ['GET', '/404'],
89
+ ['GET', '/api/page/home'],
90
+ ['GET', '/api/user'],
91
+ ['POST', '/api/request/home/get_data'],
92
+ ['POST', '/api/endpoints/my_endpoint'],
93
+ ])('%s %s responds with Cache-Control private, no-store', async (method, url) => {
94
+ const res = await createApp().request(url, { method });
95
+ expect(res.headers.get('cache-control')).toEqual('private, no-store');
96
+ });
97
+
98
+ test('an endpoint that throws still responds with Cache-Control private, no-store', async () => {
99
+ const res = await createApp().request('/api/endpoints/my_endpoint', { method: 'POST' });
100
+ expect(res.status).toEqual(500);
101
+ expect(res.headers.get('cache-control')).toEqual('private, no-store');
102
+ });
103
+
104
+ test('hashed assets keep their long-lived immutable caching', async () => {
105
+ const res = await createApp().request('/assets/main-abc.js');
106
+ expect(res.status).toEqual(200);
107
+ expect(res.headers.get('cache-control')).toEqual('public, max-age=31536000, immutable');
108
+ });
109
+
110
+ test.each([
111
+ ['GET', '/icon.svg'],
112
+ ['GET', '/api/cron/nightly'],
113
+ ['POST', '/api/client-error'],
114
+ ['GET', '/api/lowdefy-health'],
115
+ ])('%s %s gets no no-store header', async (method, url) => {
116
+ const res = await createApp().request(url, { method });
117
+ expect(res.headers.get('cache-control')).not.toEqual('private, no-store');
118
+ });
@@ -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
+ */
16
+
17
+ // Every JS file and stylesheet a dynamic entry needs, following its static
18
+ // imports - what the browser would otherwise discover one round trip at a time.
19
+ function collectChunkClosure({ manifest, key, js, css }) {
20
+ const chunk = manifest[key];
21
+ if (js.has(chunk.file)) {
22
+ return;
23
+ }
24
+ js.add(chunk.file);
25
+ (chunk.css ?? []).forEach((file) => css.add(file));
26
+ (chunk.imports ?? []).forEach((importKey) =>
27
+ collectChunkClosure({ manifest, key: importKey, js, css })
28
+ );
29
+ }
30
+
31
+ export default collectChunkClosure;