@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,34 @@
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 { getAsIssuer, getMcpResourceUri, MCP_OAUTH_SCOPES } from '@lowdefy/api';
18
+
19
+ import lowdefyConfig from '../../lib/build/config.js';
20
+
21
+ // RFC 9728 protected-resource metadata for the MCP endpoint. Constant per
22
+ // deployment: the URIs derive from the pinned BETTER_AUTH_URL, never a Host
23
+ // header. The organization a token acts in is not part of the resource - it is
24
+ // chosen at authorization and carried as a token claim.
25
+ function mcpProtectedResourceMetadataHandler(c) {
26
+ return c.json({
27
+ resource: getMcpResourceUri({ config: lowdefyConfig }),
28
+ authorization_servers: [getAsIssuer({ config: lowdefyConfig })],
29
+ scopes_supported: MCP_OAUTH_SCOPES,
30
+ bearer_methods_supported: ['header'],
31
+ });
32
+ }
33
+
34
+ export default mcpProtectedResourceMetadataHandler;
@@ -0,0 +1,65 @@
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
+ jest.unstable_mockModule('../../lib/build/config.js', () => ({
21
+ default: { basePath: '' },
22
+ }));
23
+
24
+ const { default: mcpProtectedResourceMetadataHandler } = await import(
25
+ './mcpProtectedResourceMetadata.js'
26
+ );
27
+
28
+ const originalBetterAuthUrl = process.env.BETTER_AUTH_URL;
29
+
30
+ function createApp() {
31
+ const app = new Hono();
32
+ app.get('/.well-known/oauth-protected-resource/api/mcp', mcpProtectedResourceMetadataHandler);
33
+ return app;
34
+ }
35
+
36
+ beforeEach(() => {
37
+ process.env.BETTER_AUTH_URL = 'https://app.test.com';
38
+ });
39
+
40
+ afterAll(() => {
41
+ if (originalBetterAuthUrl === undefined) {
42
+ delete process.env.BETTER_AUTH_URL;
43
+ } else {
44
+ process.env.BETTER_AUTH_URL = originalBetterAuthUrl;
45
+ }
46
+ });
47
+
48
+ test('metadata handler serves the single MCP resource document', async () => {
49
+ const res = await createApp().request('/.well-known/oauth-protected-resource/api/mcp');
50
+ expect(res.status).toEqual(200);
51
+ expect(await res.json()).toEqual({
52
+ resource: 'https://app.test.com/api/mcp',
53
+ authorization_servers: ['https://app.test.com/api/auth'],
54
+ scopes_supported: ['mcp:read', 'mcp:write', 'offline_access'],
55
+ bearer_methods_supported: ['header'],
56
+ });
57
+ });
58
+
59
+ test('metadata handler derives the resource from BETTER_AUTH_URL, never the request host', async () => {
60
+ const res = await createApp().request(
61
+ 'http://evil.example.com/.well-known/oauth-protected-resource/api/mcp'
62
+ );
63
+ expect(res.status).toEqual(200);
64
+ expect((await res.json()).resource).toEqual('https://app.test.com/api/mcp');
65
+ });
@@ -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 { createAsMetadataHandler } from '@lowdefy/api';
18
+ import { type } from '@lowdefy/helpers';
19
+
20
+ import authJson from '../../lib/build/auth.js';
21
+ import mcpProtectedResourceMetadataHandler from './mcpProtectedResourceMetadata.js';
22
+
23
+ // OAuth discovery for the MCP resource. Both documents are public,
24
+ // unauthenticated and constant per deployment, so they mount outside the api
25
+ // context. Nothing mounts when the app is not an authorization server - the
26
+ // build then guaranteed an all-public tool surface with nothing to discover.
27
+ function mountOauthDiscovery({ app, auth }) {
28
+ if (type.isNone(authJson.oauthProvider)) {
29
+ return;
30
+ }
31
+ app.get('/.well-known/oauth-protected-resource/api/mcp', mcpProtectedResourceMetadataHandler);
32
+ // The auth instance is absent only in dev mock mode, which runs no auth
33
+ // engine - the static resource document above still serves there.
34
+ if (type.isNone(auth)) {
35
+ return;
36
+ }
37
+ // The oauth-provider serves its AS metadata relative to the auth mount, so
38
+ // the RFC 8414 path-inserted location a client derives from the issuer must
39
+ // be mounted here, re-exposing the same live document.
40
+ const asMetadataHandler = createAsMetadataHandler({ auth });
41
+ app.get('/.well-known/oauth-authorization-server/api/auth', (c) => asMetadataHandler(c.req.raw));
42
+ }
43
+
44
+ export default mountOauthDiscovery;
@@ -0,0 +1,79 @@
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 mockCreateAsMetadataHandler = jest.fn(
21
+ () => async () =>
22
+ new Response(JSON.stringify({ issuer: 'https://app.test.com/api/auth' }), {
23
+ status: 200,
24
+ headers: { 'content-type': 'application/json' },
25
+ })
26
+ );
27
+ jest.unstable_mockModule('@lowdefy/api', () => ({
28
+ createAsMetadataHandler: mockCreateAsMetadataHandler,
29
+ }));
30
+
31
+ jest.unstable_mockModule('./mcpProtectedResourceMetadata.js', () => ({
32
+ default: (c) => c.json({ resource: 'mcp' }),
33
+ }));
34
+
35
+ const mockAuthJson = { configured: true };
36
+ jest.unstable_mockModule('../../lib/build/auth.js', () => ({
37
+ default: mockAuthJson,
38
+ }));
39
+
40
+ const { default: mountOauthDiscovery } = await import('./mountOauthDiscovery.js');
41
+
42
+ beforeEach(() => {
43
+ delete mockAuthJson.oauthProvider;
44
+ });
45
+
46
+ test('mountOauthDiscovery mounts nothing when oauthProvider is not configured', async () => {
47
+ const app = new Hono();
48
+ mountOauthDiscovery({ app, auth: { api: {} } });
49
+ const resourceRes = await app.request('/.well-known/oauth-protected-resource/api/mcp');
50
+ expect(resourceRes.status).toEqual(404);
51
+ const asRes = await app.request('/.well-known/oauth-authorization-server/api/auth');
52
+ expect(asRes.status).toEqual(404);
53
+ expect(mockCreateAsMetadataHandler).not.toHaveBeenCalled();
54
+ });
55
+
56
+ test('mountOauthDiscovery mounts both discovery documents when oauthProvider is configured', async () => {
57
+ mockAuthJson.oauthProvider = { consentPage: '/oauth/consent' };
58
+ const app = new Hono();
59
+ const auth = { api: {} };
60
+ mountOauthDiscovery({ app, auth });
61
+ const resourceRes = await app.request('/.well-known/oauth-protected-resource/api/mcp');
62
+ expect(resourceRes.status).toEqual(200);
63
+ expect(await resourceRes.json()).toEqual({ resource: 'mcp' });
64
+ const asRes = await app.request('/.well-known/oauth-authorization-server/api/auth');
65
+ expect(asRes.status).toEqual(200);
66
+ expect(await asRes.json()).toEqual({ issuer: 'https://app.test.com/api/auth' });
67
+ expect(mockCreateAsMetadataHandler).toHaveBeenCalledWith({ auth });
68
+ });
69
+
70
+ test('mountOauthDiscovery serves only the static resource document without an auth instance', async () => {
71
+ mockAuthJson.oauthProvider = { consentPage: '/oauth/consent' };
72
+ const app = new Hono();
73
+ mountOauthDiscovery({ app, auth: null });
74
+ const resourceRes = await app.request('/.well-known/oauth-protected-resource/api/mcp');
75
+ expect(resourceRes.status).toEqual(200);
76
+ const asRes = await app.request('/.well-known/oauth-authorization-server/api/auth');
77
+ expect(asRes.status).toEqual(404);
78
+ expect(mockCreateAsMetadataHandler).not.toHaveBeenCalled();
79
+ });
@@ -0,0 +1,29 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import getRequestPath from '../lib/getRequestPath.js';
18
+
19
+ // `/` (home) and `/404` are fixed routes ahead of the page matcher; every other
20
+ // path renders the page it matches. `.*`, not `.+`: under a basePath,
21
+ // `<basePath>/` arrives with an empty rest, and is the app root like
22
+ // `<basePath>` itself.
23
+ function mountPageRoutes({ app, basePath, renderPage }) {
24
+ app.get('/', (c) => renderPage(c, { path: '', matchedPath: '' }));
25
+ app.get('/404', (c) => renderPage(c, { path: '404', matchedPath: '404', status: 404 }));
26
+ app.get('/:rest{.*}', (c) => renderPage(c, getRequestPath({ c, basePath, prefix: '/' })));
27
+ }
28
+
29
+ export default mountPageRoutes;
@@ -0,0 +1,56 @@
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
+
19
+ import mountPageRoutes from './mountPageRoutes.js';
20
+
21
+ function request({ basePath, path }) {
22
+ const app = basePath === '' ? new Hono() : new Hono().basePath(basePath);
23
+ mountPageRoutes({
24
+ app,
25
+ basePath,
26
+ renderPage: (c, { path: pagePath, matchedPath, status = 200 }) =>
27
+ c.json({ path: pagePath, matchedPath }, status),
28
+ });
29
+ return app.request(path);
30
+ }
31
+
32
+ test.each([
33
+ ['', '/', '', ''],
34
+ ['/app', '/app', '', ''],
35
+ ['/app', '/app/', '', ''],
36
+ ['/app', '/app/invoices', 'invoices', 'invoices'],
37
+ ['', '/support/tickets/1234/', 'support/tickets/1234/', 'support/tickets/1234'],
38
+ ['', '/support/tickets/a%2Fb%20c', 'support/tickets/a%2Fb%20c', 'support/tickets/a%2Fb%20c'],
39
+ ['/app', '/app/tickets//1', 'tickets//1', 'tickets//1'],
40
+ ])(
41
+ 'with basePath "%s", %s renders path "%s" matched as "%s"',
42
+ async (basePath, url, path, matchedPath) => {
43
+ const res = await request({ basePath, path: url });
44
+ expect(res.status).toEqual(200);
45
+ expect(await res.json()).toEqual({ path, matchedPath });
46
+ }
47
+ );
48
+
49
+ test.each([[''], ['/app']])(
50
+ 'with basePath "%s", /404 is a fixed route with status 404',
51
+ async (basePath) => {
52
+ const res = await request({ basePath, path: `${basePath}/404` });
53
+ expect(res.status).toEqual(404);
54
+ expect(await res.json()).toEqual({ path: '404', matchedPath: '404' });
55
+ }
56
+ );
@@ -0,0 +1,46 @@
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
+ // Claude Code merges the protocol version it negotiated (mcp-protocol-version)
18
+ // with a static MCP-Protocol-Version it sets on every request, and the Fetch
19
+ // Headers constructor folds the two spellings of one header into a single
20
+ // comma-joined value: "2025-11-25, 2025-11-25". @hono/mcp compares that raw
21
+ // string against its supported list, so a client that agrees with the server
22
+ // twice over is answered 404 "Unsupported protocol version" - which Claude Code
23
+ // shows as "MCP endpoint not found". Collapsing a repeated value to the one
24
+ // version it names is lossless; a header naming two DIFFERENT versions is left
25
+ // for the transport to refuse. Runs before the transport reads the header: the
26
+ // transport reads c.req.header(), which reads the raw request headers live.
27
+ function normalizeMcpProtocolVersionHeader({ request }) {
28
+ const value = request.headers.get('mcp-protocol-version');
29
+ if (value === null || !value.includes(',')) {
30
+ return;
31
+ }
32
+ const versions = [
33
+ ...new Set(
34
+ value
35
+ .split(',')
36
+ .map((version) => version.trim())
37
+ .filter(Boolean)
38
+ ),
39
+ ];
40
+ if (versions.length !== 1) {
41
+ return;
42
+ }
43
+ request.headers.set('mcp-protocol-version', versions[0]);
44
+ }
45
+
46
+ export default normalizeMcpProtocolVersionHeader;
@@ -0,0 +1,62 @@
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
+
19
+ import normalizeMcpProtocolVersionHeader from './normalizeMcpProtocolVersionHeader.js';
20
+
21
+ function createApp() {
22
+ const app = new Hono();
23
+ app.post('/api/mcp', (c) => {
24
+ normalizeMcpProtocolVersionHeader({ request: c.req.raw });
25
+ return c.json({ protocolVersion: c.req.header('mcp-protocol-version') ?? null });
26
+ });
27
+ return app;
28
+ }
29
+
30
+ async function send(headerValue) {
31
+ const headers = new Headers();
32
+ if (headerValue !== undefined) {
33
+ headers.set('mcp-protocol-version', headerValue);
34
+ }
35
+ const res = await createApp().request('/api/mcp', { method: 'POST', headers });
36
+ return (await res.json()).protocolVersion;
37
+ }
38
+
39
+ test('collapses a repeated protocol version to the single version it names', async () => {
40
+ expect(await send('2025-11-25, 2025-11-25')).toEqual('2025-11-25');
41
+ });
42
+
43
+ test('collapses the value the Fetch Headers constructor builds from both header spellings', async () => {
44
+ const headers = new Headers({
45
+ 'mcp-protocol-version': '2025-11-25',
46
+ 'MCP-Protocol-Version': '2025-11-25',
47
+ });
48
+ const res = await createApp().request('/api/mcp', { method: 'POST', headers });
49
+ expect((await res.json()).protocolVersion).toEqual('2025-11-25');
50
+ });
51
+
52
+ test('leaves a single protocol version untouched', async () => {
53
+ expect(await send('2025-06-18')).toEqual('2025-06-18');
54
+ });
55
+
56
+ test('leaves a header naming two different versions for the transport to refuse', async () => {
57
+ expect(await send('2025-11-25, 2025-06-18')).toEqual('2025-11-25, 2025-06-18');
58
+ });
59
+
60
+ test('leaves an absent header absent', async () => {
61
+ expect(await send(undefined)).toEqual(null);
62
+ });
@@ -14,13 +14,15 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { callRequest } from '@lowdefy/api';
17
+ import { callRequest, parseRequestBody } from '@lowdefy/api';
18
18
 
19
19
  import getPathSegments from '../lib/getPathSegments.js';
20
20
 
21
21
  async function requestHandler(c) {
22
22
  if (c.req.method !== 'POST') {
23
- throw new Error('Only POST requests are supported.');
23
+ // A wrong-method request is client-caused: answer 405 rather than raising a
24
+ // fault that would be logged at error level and answered with a 500.
25
+ return c.json({ error: 'Method not allowed.' }, 405);
24
26
  }
25
27
  const context = c.get('lowdefyContext');
26
28
  const segments = getPathSegments(c, '/api/request/');
@@ -29,7 +31,7 @@ async function requestHandler(c) {
29
31
  }
30
32
  const requestId = segments[segments.length - 1];
31
33
  const pageId = segments.slice(0, -1).join('/');
32
- const { actionId, blockId, payload } = await c.req.json();
34
+ const { actionId, blockId, payload } = parseRequestBody({ text: await c.req.text() });
33
35
  context.logger.info({ event: 'call_request', pageId, requestId, blockId, actionId });
34
36
  const response = await callRequest(context, { blockId, pageId, payload, requestId });
35
37
  return c.json(response);
@@ -0,0 +1,146 @@
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
+ import { isSameOriginRequest, parseRequestBody } from '@lowdefy/api';
21
+
22
+ const mockCallEndpoint = jest.fn();
23
+ const mockGetEndpointConfig = jest.fn().mockRejectedValue(new Error('not found'));
24
+ const mockRunWebhookEndpoint = jest.fn();
25
+ jest.unstable_mockModule('@lowdefy/api', () => ({
26
+ acceptDetachedEndpoint: jest.fn(),
27
+ callAgent: jest.fn(),
28
+ callEndpoint: mockCallEndpoint,
29
+ callRequest: jest.fn(),
30
+ getEndpointConfig: mockGetEndpointConfig,
31
+ logClientError: jest.fn(),
32
+ isSameOriginRequest,
33
+ parseRequestBody,
34
+ redactErrorResponse: jest.fn(),
35
+ runWebhookEndpoint: mockRunWebhookEndpoint,
36
+ }));
37
+
38
+ const { default: createErrorHandler } = await import('../middleware/errorHandler.js');
39
+ const routes = {
40
+ '/api/agent/*': (await import('./agent.js')).default,
41
+ '/api/client-error': (await import('./clientError.js')).default,
42
+ '/api/detached/*': (await import('./detached.js')).default,
43
+ '/api/endpoints/*': (await import('./endpoints.js')).default,
44
+ '/api/request/*': (await import('./request.js')).default,
45
+ '/api/usage': (await import('./usage.js')).default,
46
+ };
47
+
48
+ const logger = { debug: jest.fn(), error: jest.fn(), info: jest.fn(), warn: jest.fn() };
49
+
50
+ function createApp() {
51
+ const app = new Hono();
52
+ app.use('*', async (c, next) => {
53
+ c.set('lowdefyContext', { logger, handleError: jest.fn() });
54
+ await next();
55
+ });
56
+ Object.entries(routes).forEach(([path, handler]) => app.all(path, handler));
57
+ app.onError(createErrorHandler({ logger }));
58
+ return app;
59
+ }
60
+
61
+ const originalCronSecret = process.env.CRON_SECRET;
62
+ beforeAll(() => {
63
+ process.env.CRON_SECRET = 'secret';
64
+ });
65
+ afterAll(() => {
66
+ if (originalCronSecret === undefined) {
67
+ delete process.env.CRON_SECRET;
68
+ } else {
69
+ process.env.CRON_SECRET = originalCronSecret;
70
+ }
71
+ });
72
+
73
+ test.each([
74
+ ['/api/endpoints/save', 'nope', {}, 'Request body is not valid JSON.'],
75
+ ['/api/endpoints/save', 'null', {}, 'Request body must be a JSON object.'],
76
+ ['/api/request/orders/load', 'nope', {}, 'Request body is not valid JSON.'],
77
+ ['/api/agent/orders/assistant', 'nope', {}, 'Request body is not valid JSON.'],
78
+ [
79
+ '/api/detached/save',
80
+ 'nope',
81
+ { authorization: 'Bearer secret' },
82
+ 'Request body is not valid JSON.',
83
+ ],
84
+ ['/api/usage', 'nope', {}, 'Request body is not valid JSON.'],
85
+ [
86
+ '/api/client-error',
87
+ 'nope',
88
+ { host: 'localhost', origin: 'http://localhost' },
89
+ 'Request body is not valid JSON.',
90
+ ],
91
+ ])('POST %s with body %s answers 400 and logs a warning', async (path, body, headers, message) => {
92
+ const res = await createApp().request(path, { method: 'POST', body, headers });
93
+ expect(res.status).toBe(400);
94
+ expect(await res.json()).toEqual({ name: 'UserError', message });
95
+ expect(logger.warn).toHaveBeenCalledTimes(1);
96
+ expect(logger.error).not.toHaveBeenCalled();
97
+ });
98
+
99
+ test('POST /api/endpoints with a JSON body passes its fields to callEndpoint', async () => {
100
+ mockCallEndpoint.mockResolvedValue({ success: true });
101
+ const res = await createApp().request('/api/endpoints/save', {
102
+ method: 'POST',
103
+ body: JSON.stringify({ blockId: 'b', pageId: 'p', payload: { a: 1 } }),
104
+ });
105
+ expect(res.status).toBe(200);
106
+ expect(mockCallEndpoint).toHaveBeenCalledWith(expect.anything(), {
107
+ blockId: 'b',
108
+ endpointId: 'save',
109
+ pageId: 'p',
110
+ payload: { a: 1 },
111
+ });
112
+ });
113
+
114
+ test('POST /api/endpoints to a webhook endpoint passes the body text exactly as sent as rawBody', async () => {
115
+ mockGetEndpointConfig.mockResolvedValueOnce({ webhook: { verify: {} } });
116
+ mockRunWebhookEndpoint.mockResolvedValue({ status: 200, body: { ok: true } });
117
+ const body = '{ "zeta": 1, "alpha": "caf\\u00e9", "beta": "café" }';
118
+ const res = await createApp().request('/api/endpoints/signed_hook?t=1', {
119
+ method: 'POST',
120
+ body,
121
+ headers: { 'x-hub-signature-256': 'sha256=abc' },
122
+ });
123
+ expect(res.status).toBe(200);
124
+ expect(mockRunWebhookEndpoint).toHaveBeenCalledWith(expect.anything(), {
125
+ endpointId: 'signed_hook',
126
+ rawBody: body,
127
+ query: { t: '1' },
128
+ headers: expect.objectContaining({ 'x-hub-signature-256': 'sha256=abc' }),
129
+ });
130
+ });
131
+
132
+ test.each([
133
+ [200, { received: true }],
134
+ [401, { error: { code: 'unauthorized', message: 'Webhook verification failed.' } }],
135
+ [404, { error: 'not_found', id: 'ticket-1' }],
136
+ [500, { error: { code: 'internal_error', message: 'Webhook failed.' } }],
137
+ ])(
138
+ 'POST /api/endpoints to a webhook endpoint answers status %s with its body',
139
+ async (status, body) => {
140
+ mockGetEndpointConfig.mockResolvedValueOnce({ webhook: true });
141
+ mockRunWebhookEndpoint.mockResolvedValue({ status, body });
142
+ const res = await createApp().request('/api/endpoints/hook', { method: 'POST', body: '{}' });
143
+ expect(res.status).toBe(status);
144
+ expect(await res.json()).toEqual(body);
145
+ }
146
+ );
@@ -17,14 +17,18 @@
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
 
20
+ import { parseRequestBody } from '@lowdefy/api';
21
+
20
22
  const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
21
23
 
22
24
  async function usageHandler(c) {
23
25
  if (c.req.method !== 'POST') {
24
- throw new Error('Only POST requests are supported.');
26
+ // A wrong-method request is client-caused: answer 405 rather than raising a
27
+ // fault that would be logged at error level and answered with a 500.
28
+ return c.json({ error: 'Method not allowed.' }, 405);
25
29
  }
26
30
  const context = c.get('lowdefyContext');
27
- const { user, machine } = await c.req.json();
31
+ const { user, machine } = parseRequestBody({ text: await c.req.text() });
28
32
  const host = c.req.header('host');
29
33
  context.logger.info({ event: 'log_usage', user, machine });
30
34
 
@@ -0,0 +1,27 @@
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
+ // The resolved caller for the client - the same resolveAuthentication output
18
+ // the first page load embeds. The base session carries neither roles (read
19
+ // live from the active member row) nor the merged attributes bag, so the
20
+ // client re-syncs from here (UpdateSession) after a session change such as
21
+ // SetActiveOrganization.
22
+ async function userHandler(c) {
23
+ const context = c.get('lowdefyContext');
24
+ return c.json({ user: context.user ?? null });
25
+ }
26
+
27
+ export default userHandler;