@lowdefy/server 6.0.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 (119) hide show
  1. package/client/App.jsx +41 -21
  2. package/client/Page.jsx +101 -24
  3. package/client/clearReloadedForTypes.js +29 -0
  4. package/client/clientBuildImports.test.mjs +60 -0
  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 +224 -39
  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 -7
  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 +16 -4
  30. package/lib/server/log/createHandleError.test.mjs +102 -0
  31. package/lib/server/log/createLogger.js +10 -0
  32. package/lib/server/log/createLogger.test.mjs +117 -0
  33. package/lib/server/log/logRequest.js +12 -4
  34. package/lib/server/log/maskCredentialKeys.js +37 -0
  35. package/lib/server/log/projectErrorForLog.js +61 -0
  36. package/lib/server/log/redactUrlQuery.js +75 -0
  37. package/lib/server/log/redactUrlQuery.test.mjs +73 -0
  38. package/lib/server/{auth/session.js → log/serializeErrorForLog.js} +5 -11
  39. package/lib/server/log/serializeErrorForLog.test.mjs +337 -0
  40. package/lib/server/scrubSecrets.js +21 -0
  41. package/lib/server/sentry/captureSentryError.js +29 -3
  42. package/lib/server/sentry/captureSentryError.test.mjs +128 -0
  43. package/lib/server/sentry/initSentry.js +46 -14
  44. package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
  45. package/lib/server/sentry/initSentry.test.mjs +365 -0
  46. package/lib/server/sentry/scrubEvent.js +40 -0
  47. package/lib/server/sentry/scrubEvent.test.mjs +88 -0
  48. package/lib/server/sentry/sentrySdk.js +23 -0
  49. package/lib/server/sentry/setSentryUser.js +3 -3
  50. package/lowdefy/build.mjs +1 -0
  51. package/lowdefy/build.test.mjs +68 -0
  52. package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
  53. package/package.json +47 -42
  54. package/package.original.json +47 -42
  55. package/src/app.js +74 -17
  56. package/src/app.test.mjs +118 -0
  57. package/src/html/collectChunkClosure.js +31 -0
  58. package/src/html/collectLazyImports.js +41 -0
  59. package/src/html/collectPageTypesAssets.js +57 -0
  60. package/src/html/collectPageTypesAssets.test.mjs +130 -0
  61. package/src/html/getAssets.js +20 -1
  62. package/src/html/getHomePath.js +29 -0
  63. package/src/html/getPageAssets.js +32 -0
  64. package/src/html/getPageAssets.test.mjs +47 -0
  65. package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
  66. package/src/html/renderPage.js +80 -19
  67. package/src/html/renderPage.test.mjs +189 -0
  68. package/src/html/template.js +50 -15
  69. package/src/html/template.test.mjs +126 -0
  70. package/src/index.js +28 -19
  71. package/src/initServer.js +61 -0
  72. package/src/initServer.test.mjs +116 -0
  73. package/src/lib/getRequestPath.js +29 -0
  74. package/src/middleware/apiContext.js +68 -13
  75. package/src/middleware/apiContext.test.mjs +178 -0
  76. package/src/middleware/createSameOriginGuard.js +36 -0
  77. package/src/middleware/createSameOriginGuard.test.mjs +102 -0
  78. package/src/middleware/errorHandler.js +56 -4
  79. package/src/middleware/errorHandler.test.mjs +440 -0
  80. package/src/middleware/noStore.js +27 -0
  81. package/src/middleware/refuseOtherBuild.js +49 -0
  82. package/src/middleware/refuseOtherBuild.test.mjs +63 -0
  83. package/src/middleware/requestTimeout.js +38 -0
  84. package/src/middleware/sentry.js +13 -5
  85. package/src/middleware/stampBuildId.js +30 -0
  86. package/src/middleware/stampBuildId.test.mjs +41 -0
  87. package/src/middleware/stampCaller.js +38 -0
  88. package/src/middleware/stampCaller.test.mjs +64 -0
  89. package/src/routes/agent.js +15 -3
  90. package/src/routes/apiPage.js +54 -9
  91. package/src/routes/apiPage.test.mjs +143 -0
  92. package/src/routes/auth.js +15 -9
  93. package/src/routes/auth.test.mjs +85 -0
  94. package/src/routes/clientError.js +11 -13
  95. package/src/routes/cron.js +3 -1
  96. package/src/routes/cronForward.js +4 -2
  97. package/src/routes/detached.js +22 -9
  98. package/src/routes/endpoints.js +27 -26
  99. package/src/routes/endpointsTextAnswer.test.mjs +77 -0
  100. package/src/routes/mcp.js +82 -5
  101. package/src/routes/mcp.test.mjs +215 -0
  102. package/src/routes/mcpMemberToken.test.mjs +125 -0
  103. package/src/routes/mcpProtectedResourceMetadata.js +34 -0
  104. package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
  105. package/src/routes/mountOauthDiscovery.js +44 -0
  106. package/src/routes/mountOauthDiscovery.test.mjs +79 -0
  107. package/src/routes/mountPageRoutes.js +29 -0
  108. package/src/routes/mountPageRoutes.test.mjs +56 -0
  109. package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
  110. package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
  111. package/src/routes/request.js +5 -3
  112. package/src/routes/requestBody.test.mjs +146 -0
  113. package/src/routes/usage.js +6 -2
  114. package/src/routes/user.js +27 -0
  115. package/src/routes/websocket.js +26 -3
  116. package/src/routes/websocket.test.mjs +106 -0
  117. package/src/routes/wellKnownFallback.js +30 -0
  118. package/vite.config.js +55 -2
  119. package/lib/server/apiWrapper.js +0 -87
@@ -14,29 +14,27 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { logClientError } from '@lowdefy/api';
17
+ import { logClientError, parseRequestBody } from '@lowdefy/api';
18
18
 
19
19
  import captureSentryError from '../../lib/server/sentry/captureSentryError.js';
20
+ import createSameOriginGuard from '../middleware/createSameOriginGuard.js';
21
+
22
+ const guardSameOrigin = createSameOriginGuard();
20
23
 
21
24
  async function clientErrorHandler(c) {
22
25
  if (c.req.method !== 'POST') {
23
- 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);
24
29
  }
25
30
  const context = c.get('lowdefyContext');
26
31
 
27
- const origin = c.req.header('origin');
28
- if (!origin) {
29
- return c.json({ error: 'Forbidden' }, 403);
30
- }
31
- try {
32
- if (new URL(origin).host !== c.req.header('host')) {
33
- return c.json({ error: 'Forbidden' }, 403);
34
- }
35
- } catch {
36
- return c.json({ error: 'Forbidden' }, 403);
32
+ const forbidden = guardSameOrigin(c);
33
+ if (forbidden) {
34
+ return forbidden;
37
35
  }
38
36
 
39
- const body = await c.req.json();
37
+ const body = parseRequestBody({ text: await c.req.text() });
40
38
  // Strip received from payload — prod doesn't need it for schema validation
41
39
  if (body?.['~e']) {
42
40
  delete body['~e'].received;
@@ -23,7 +23,9 @@ import getPathSegments from '../lib/getPathSegments.js';
23
23
  // cron expression in the `x-vercel-cron-schedule` header. Fails closed if CRON_SECRET is unset.
24
24
  async function cronHandler(c) {
25
25
  if (c.req.method !== 'GET') {
26
- throw new Error('Only GET 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);
27
29
  }
28
30
  const context = c.get('lowdefyContext');
29
31
 
@@ -20,13 +20,15 @@ import getPathSegments from '../lib/getPathSegments.js';
20
20
 
21
21
  // Triggered by Vercel Cron on the production deployment: an HTTP GET to
22
22
  // /api/cron-forward/<environment>/<endpointId>, registered by `lowdefy vercel-output` for the
23
- // schedules of every environment declared with a url in config.cron.environments. Vercel fires crons
23
+ // schedules of every environment declared with a cron.secret in config.environments. Vercel fires crons
24
24
  // only on production, so production pings that environment's own /api/cron/<endpointId> (with the
25
25
  // environment's CRON_SECRET) and answers immediately. Same transport auth as cron (CRON_SECRET,
26
26
  // fails closed).
27
27
  async function cronForwardHandler(c) {
28
28
  if (c.req.method !== 'GET') {
29
- throw new Error('Only GET requests are supported.');
29
+ // A wrong-method request is client-caused: answer 405 rather than raising a
30
+ // fault that would be logged at error level and answered with a 500.
31
+ return c.json({ error: 'Method not allowed.' }, 405);
30
32
  }
31
33
  const context = c.get('lowdefyContext');
32
34
 
@@ -14,23 +14,34 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { runDetachedEndpoint } from '@lowdefy/api';
17
+ import { acceptDetachedEndpoint, parseRequestBody } from '@lowdefy/api';
18
18
 
19
19
  import getPathSegments from '../lib/getPathSegments.js';
20
20
 
21
21
  // Target of a CallApi step with `detached: true`: the calling invocation
22
22
  // fire-and-forgets a POST here, so the endpoint runs in its OWN function
23
23
  // invocation with a fresh duration budget (chainable bounded work without a
24
- // queue). Same transport auth as cron (CRON_SECRET, fails closed) and the same
25
- // system execution context — InternalApi endpoints are callable, `_user` is
26
- // undefined. Delivery is at-most-once with no retry: targets must be
27
- // idempotent.
24
+ // queue). Transport auth is CRON_SECRET (fails closed), which proves the
25
+ // request originated from the deployment. The run carries the DISPATCHER'S
26
+ // identity (Decision 4): the request body carries a server-built principal
27
+ // snapshot that runDetachedEndpoint rehydrates, so nested calls authorize
28
+ // against the dispatcher, not a forced system context. Delivery is at-most-once
29
+ // with no retry: targets must be idempotent.
30
+ //
31
+ // The route answers 202 as soon as the call is authorized and parsed; the
32
+ // endpoint runs after the response under this invocation's waitUntil
33
+ // (acceptDetachedEndpoint). The dispatcher is therefore released at once
34
+ // instead of staying alive - and billed - until the target's routine ends.
28
35
  async function detachedHandler(c) {
29
36
  if (c.req.method !== 'POST') {
30
- throw new Error('Only POST requests are supported.');
37
+ // A wrong-method request is client-caused: answer 405 rather than raising a
38
+ // fault that would be logged at error level and answered with a 500.
39
+ return c.json({ error: 'Method not allowed.' }, 405);
31
40
  }
32
41
  const context = c.get('lowdefyContext');
33
42
 
43
+ // The secret gate stays first: an external POST without the secret is
44
+ // rejected before the body (and its principal assertion) is ever read.
34
45
  const secret = process.env.CRON_SECRET;
35
46
  if (!secret || c.req.header('authorization') !== `Bearer ${secret}`) {
36
47
  context.logger.warn({ event: 'detached_unauthorized' });
@@ -38,10 +49,12 @@ async function detachedHandler(c) {
38
49
  }
39
50
 
40
51
  const endpointId = getPathSegments(c, '/api/detached/').join('/');
41
- const { payload } = await c.req.json();
52
+ const { credentials, payload, principal } = parseRequestBody({ text: await c.req.text() });
42
53
  context.logger.info({ event: 'call_detached_endpoint', endpointId });
43
- const response = await runDetachedEndpoint(context, { endpointId, payload });
44
- return c.json(response);
54
+ return c.json(
55
+ acceptDetachedEndpoint(context, { credentials, endpointId, payload, principal }),
56
+ 202
57
+ );
45
58
  }
46
59
 
47
60
  export default detachedHandler;
@@ -14,55 +14,56 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { callEndpoint, getEndpointConfig, runWebhookEndpoint } from '@lowdefy/api';
17
+ import {
18
+ callEndpoint,
19
+ getEndpointConfig,
20
+ parseRequestBody,
21
+ runWebhookEndpoint,
22
+ } from '@lowdefy/api';
18
23
 
19
24
  import getPathSegments from '../lib/getPathSegments.js';
20
25
 
21
26
  async function endpointsHandler(c) {
22
27
  if (c.req.method !== 'POST') {
23
- throw new Error('Only POST requests are supported.');
28
+ // A wrong-method request is client-caused: answer 405 rather than raising a
29
+ // fault that would be logged at error level and answered with a 500.
30
+ return c.json({ error: 'Method not allowed.' }, 405);
24
31
  }
25
32
  const context = c.get('lowdefyContext');
26
33
  const endpointId = getPathSegments(c, '/api/endpoints/').join('/');
27
34
 
28
- // Endpoints opting in with `webhook: true` are third-party webhook receivers
29
- // (SNS, Event Grid, Stripe, ...): they take the request RAW — body in the
30
- // caller's own format (not the { payload } envelope), plus query + headers —
31
- // and their return value is sent back verbatim, because webhook handshakes
32
- // (e.g. Event Grid's validationResponse) require exact response shapes.
33
- // The config lookup is a cached file read; an unknown endpoint falls through
34
- // to the standard path so its error shape is unchanged. Everything about
35
- // non-webhook endpoints is untouched.
35
+ // Endpoints opting in with `webhook` (true or a { verify } object) are
36
+ // third-party webhook receivers (SNS, Event Grid, Stripe, ...): they take the
37
+ // request RAW — body in the caller's own format (not the { payload }
38
+ // envelope), plus query + headers — and their return value is sent back
39
+ // verbatim, because webhook handshakes (e.g. Event Grid's validationResponse)
40
+ // require exact response shapes. The config lookup is a cached file read; an
41
+ // unknown endpoint falls through to the standard path so its error shape is
42
+ // unchanged. Everything about non-webhook endpoints is untouched.
36
43
  let endpointConfig = null;
37
44
  try {
38
45
  endpointConfig = await getEndpointConfig(context, { endpointId });
39
46
  } catch {
40
47
  /* unknown endpoint — the standard path below reports it exactly as before */
41
48
  }
42
- if (endpointConfig?.webhook === true) {
43
- // SNS posts JSON as text/plain — parse regardless of content-type; an
44
- // unparseable body arrives as the raw string.
45
- const raw = await c.req.text();
46
- let body = raw;
47
- try {
48
- body = JSON.parse(raw);
49
- } catch {
50
- /* non-JSON body — raw string passthrough */
51
- }
49
+ if (endpointConfig?.webhook) {
52
50
  context.logger.info({ event: 'call_webhook_endpoint', endpointId });
53
- const result = await runWebhookEndpoint(context, {
51
+ const { status, body, contentType } = await runWebhookEndpoint(context, {
54
52
  endpointId,
55
- body,
53
+ rawBody: await c.req.text(),
56
54
  query: c.req.query(),
57
55
  headers: c.req.header(),
58
56
  });
59
- if (!result.success) {
60
- return c.json({ error: 'Webhook failed.' }, 500);
57
+ if (contentType === 'text/plain') {
58
+ // A text answer often echoes a value from the request (a handshake token), so the browser
59
+ // must not sniff it into HTML.
60
+ c.header('X-Content-Type-Options', 'nosniff');
61
+ return c.text(body, status);
61
62
  }
62
- return c.json(result.response ?? { ok: true });
63
+ return c.json(body, status);
63
64
  }
64
65
 
65
- const { blockId, payload, pageId } = await c.req.json();
66
+ const { blockId, payload, pageId } = parseRequestBody({ text: await c.req.text() });
66
67
  context.logger.info({ event: 'call_api_endpoint', blockId, endpointId, pageId });
67
68
  const response = await callEndpoint(context, { blockId, endpointId, pageId, payload });
68
69
  return c.json(response);
@@ -0,0 +1,77 @@
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 mockGetEndpointConfig = jest.fn();
21
+ const mockRunWebhookEndpoint = jest.fn();
22
+ jest.unstable_mockModule('@lowdefy/api', () => ({
23
+ callEndpoint: jest.fn(),
24
+ getEndpointConfig: mockGetEndpointConfig,
25
+ parseRequestBody: jest.fn(),
26
+ runWebhookEndpoint: mockRunWebhookEndpoint,
27
+ }));
28
+
29
+ const { default: endpointsHandler } = await import('./endpoints.js');
30
+
31
+ const logger = { debug: jest.fn(), error: jest.fn(), info: jest.fn(), warn: jest.fn() };
32
+
33
+ function createApp() {
34
+ const app = new Hono();
35
+ app.use('*', async (c, next) => {
36
+ c.set('lowdefyContext', { logger, handleError: jest.fn() });
37
+ await next();
38
+ });
39
+ app.all('/api/endpoints/*', endpointsHandler);
40
+ return app;
41
+ }
42
+
43
+ beforeEach(() => {
44
+ mockGetEndpointConfig.mockResolvedValue({ webhook: true });
45
+ });
46
+
47
+ test('a webhook answer with contentType text/plain is sent as the plain-text body', async () => {
48
+ mockRunWebhookEndpoint.mockResolvedValue({
49
+ status: 200,
50
+ body: 'Validation: Testing client application',
51
+ contentType: 'text/plain',
52
+ });
53
+ const res = await createApp().request(
54
+ '/api/endpoints/graph_notifications?validationToken=Validation%3A+Testing+client+application',
55
+ { method: 'POST' }
56
+ );
57
+ expect(res.status).toBe(200);
58
+ expect(res.headers.get('content-type')).toMatch(/^text\/plain\b/);
59
+ expect(res.headers.get('x-content-type-options')).toBe('nosniff');
60
+ expect(await res.text()).toBe('Validation: Testing client application');
61
+ expect(mockRunWebhookEndpoint).toHaveBeenCalledWith(
62
+ expect.anything(),
63
+ expect.objectContaining({
64
+ endpointId: 'graph_notifications',
65
+ query: { validationToken: 'Validation: Testing client application' },
66
+ })
67
+ );
68
+ });
69
+
70
+ test('a webhook answer without contentType is still sent as JSON', async () => {
71
+ mockRunWebhookEndpoint.mockResolvedValue({ status: 200, body: 'token' });
72
+ const res = await createApp().request('/api/endpoints/hook', { method: 'POST', body: '{}' });
73
+ expect(res.status).toBe(200);
74
+ expect(res.headers.get('content-type')).toMatch(/^application\/json\b/);
75
+ expect(res.headers.get('x-content-type-options')).toBeNull();
76
+ expect(await res.json()).toBe('token');
77
+ });
package/src/routes/mcp.js CHANGED
@@ -15,17 +15,94 @@
15
15
  */
16
16
 
17
17
  import { StreamableHTTPTransport } from '@hono/mcp';
18
- import { createMcpServer } from '@lowdefy/api';
18
+ import { createMcpServer, getMcpResourceMetadataUri, getMcpResourceUri } from '@lowdefy/api';
19
+ import { type } from '@lowdefy/helpers';
20
+
21
+ import authJson from '../../lib/build/auth.js';
22
+ import normalizeMcpProtocolVersionHeader from './normalizeMcpProtocolVersionHeader.js';
23
+
24
+ // The RFC 6750 challenge for the MCP resource, pointing at its RFC 9728
25
+ // metadata. The invalid_token extension exists for the two failures a client
26
+ // can act on: BetterAuth mints opaque tokens when a client omits the RFC 8707
27
+ // resource parameter, and those can never verify here - the description names
28
+ // the parameter, never app state. A revoked grant (the member switched
29
+ // organization or disconnected the assistant) is told to reconnect, which
30
+ // re-runs the authorization and its organization choice. A refused member
31
+ // token gets its reason and no resource_metadata: a script cannot sign in, so
32
+ // pointing it at the authorization server would only mislead it.
33
+ function getChallengeHeader({ context, description, memberToken, parseableJwt, revoked }) {
34
+ if (memberToken === true) {
35
+ return `Bearer error="invalid_token", error_description="${description}"`;
36
+ }
37
+ const metadataUri = getMcpResourceMetadataUri({ config: context.config });
38
+ if (parseableJwt === false) {
39
+ return `Bearer error="invalid_token", error_description="The access token is not a JWT. Connect with a client that sends the RFC 8707 resource parameter.", resource_metadata="${metadataUri}"`;
40
+ }
41
+ if (revoked === true) {
42
+ return `Bearer error="invalid_token", error_description="This connection was disconnected. Reconnect to choose the organization to work in.", resource_metadata="${metadataUri}"`;
43
+ }
44
+ return `Bearer resource_metadata="${metadataUri}"`;
45
+ }
46
+
47
+ // Browser cross-site defence: an Origin that is neither the pinned canonical
48
+ // origin nor one the app registered on BetterAuth's trustedOrigins is
49
+ // refused. Non-browser MCP clients send no Origin and pass.
50
+ function isOriginAllowed({ c, context }) {
51
+ const origin = c.req.header('origin');
52
+ if (type.isNone(origin)) {
53
+ return true;
54
+ }
55
+ const canonicalOrigin = new URL(getMcpResourceUri({ config: context.config })).origin;
56
+ if (origin === canonicalOrigin) {
57
+ return true;
58
+ }
59
+ // Exact strings only - a trustedOrigins function or wildcard entry is not
60
+ // consulted here.
61
+ const trustedOrigins = context.auth?.options?.trustedOrigins;
62
+ return type.isArray(trustedOrigins) && trustedOrigins.includes(origin);
63
+ }
19
64
 
20
65
  // Stateless per-request MCP server over the app's configured endpoints. The
21
- // apiContext middleware has already resolved the caller's session, so tool
22
- // listing and calls are authorized per request.
66
+ // apiContext middleware has already resolved the caller from the bearer token
67
+ // (context.mcpAuth), so tool listing and calls are authorized per request.
68
+ //
69
+ // The 401 challenge is decided here, before the transport runs -
70
+ // transport.handleRequest writes tool results over HTTP 200, so past this
71
+ // boundary a role or scope shortfall stays opaque inside the tool surface,
72
+ // never a 403 or insufficient_scope.
23
73
  async function mcpHandler(c) {
74
+ normalizeMcpProtocolVersionHeader({ request: c.req.raw });
24
75
  const context = c.get('lowdefyContext');
25
- const server = await createMcpServer({ context });
26
- if (!server) {
76
+ const mcpConfig = await context.readConfigFile('mcp.json');
77
+ if (type.isNone(mcpConfig) || mcpConfig.configured !== true) {
27
78
  return c.json({ error: 'MCP is not configured.' }, 404);
28
79
  }
80
+ // Without an authorization server the build guaranteed an all-public tool
81
+ // surface, so the route serves openly - no challenge envelope at all.
82
+ if (!type.isNone(authJson.oauthProvider)) {
83
+ if (!isOriginAllowed({ c, context })) {
84
+ return c.json({ error: 'Origin not allowed.' }, 403);
85
+ }
86
+ const { tokenStatus, description, memberToken, parseableJwt, revoked } = context.mcpAuth ?? {};
87
+ if (tokenStatus === 'invalid') {
88
+ return c.json({ error: 'Unauthorized.' }, 401, {
89
+ 'WWW-Authenticate': getChallengeHeader({
90
+ context,
91
+ description,
92
+ memberToken,
93
+ parseableJwt,
94
+ revoked,
95
+ }),
96
+ });
97
+ }
98
+ // Nothing to serve anonymously - challenge instead of an empty tool list.
99
+ if (tokenStatus === 'none' && mcpConfig.hasPublicTool === false) {
100
+ return c.json({ error: 'Unauthorized.' }, 401, {
101
+ 'WWW-Authenticate': getChallengeHeader({ context, parseableJwt: true }),
102
+ });
103
+ }
104
+ }
105
+ const server = await createMcpServer({ context });
29
106
  const transport = new StreamableHTTPTransport();
30
107
  await server.connect(transport);
31
108
  return transport.handleRequest(c);
@@ -0,0 +1,215 @@
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
+ // The real URI helpers drive the challenge strings, so the header assertions
21
+ // below exercise the actual BETTER_AUTH_URL derivation - only createMcpServer
22
+ // is stubbed out.
23
+ const uriHelpers = await import('@lowdefy/api/routes/mcp/getMcpUri.js');
24
+
25
+ const mockCreateMcpServer = jest.fn();
26
+ jest.unstable_mockModule('@lowdefy/api', () => ({
27
+ ...uriHelpers,
28
+ createMcpServer: mockCreateMcpServer,
29
+ }));
30
+
31
+ jest.unstable_mockModule('@hono/mcp', () => ({
32
+ StreamableHTTPTransport: class {
33
+ handleRequest() {
34
+ return new Response(JSON.stringify({ served: true }), {
35
+ status: 200,
36
+ headers: { 'content-type': 'application/json' },
37
+ });
38
+ }
39
+ },
40
+ }));
41
+
42
+ const mockAuthJson = { configured: true, oauthProvider: { consentPage: '/oauth/consent' } };
43
+ jest.unstable_mockModule('../../lib/build/auth.js', () => ({
44
+ default: mockAuthJson,
45
+ }));
46
+
47
+ const { default: mcpHandler } = await import('./mcp.js');
48
+
49
+ const originalBetterAuthUrl = process.env.BETTER_AUTH_URL;
50
+
51
+ const mockReadConfigFile = jest.fn();
52
+
53
+ let contextOverrides;
54
+
55
+ function createApp() {
56
+ const app = new Hono();
57
+ app.use('*', async (c, next) => {
58
+ c.set('lowdefyContext', {
59
+ config: { basePath: '' },
60
+ logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
61
+ readConfigFile: mockReadConfigFile,
62
+ ...contextOverrides,
63
+ });
64
+ await next();
65
+ });
66
+ app.all('/api/mcp', mcpHandler);
67
+ return app;
68
+ }
69
+
70
+ beforeEach(() => {
71
+ process.env.BETTER_AUTH_URL = 'https://app.test.com';
72
+ mockAuthJson.oauthProvider = { consentPage: '/oauth/consent' };
73
+ contextOverrides = {};
74
+ mockReadConfigFile.mockReset();
75
+ mockCreateMcpServer.mockReset();
76
+ mockCreateMcpServer.mockResolvedValue({ connect: jest.fn() });
77
+ mockReadConfigFile.mockResolvedValue({ configured: true, hasPublicTool: true, endpoints: [] });
78
+ });
79
+
80
+ afterAll(() => {
81
+ if (originalBetterAuthUrl === undefined) {
82
+ delete process.env.BETTER_AUTH_URL;
83
+ } else {
84
+ process.env.BETTER_AUTH_URL = originalBetterAuthUrl;
85
+ }
86
+ });
87
+
88
+ test('mcpHandler returns 404 when mcp is not configured', async () => {
89
+ mockReadConfigFile.mockResolvedValue({ configured: false });
90
+ const res = await createApp().request('/api/mcp', { method: 'POST' });
91
+ expect(res.status).toEqual(404);
92
+ expect(await res.json()).toEqual({ error: 'MCP is not configured.' });
93
+ expect(mockCreateMcpServer).not.toHaveBeenCalled();
94
+ });
95
+
96
+ test('mcpHandler challenges an invalid token with the resource metadata pointer', async () => {
97
+ contextOverrides.mcpAuth = { tokenStatus: 'invalid', parseableJwt: true };
98
+ const res = await createApp().request('/api/mcp', { method: 'POST' });
99
+ expect(res.status).toEqual(401);
100
+ expect(res.headers.get('WWW-Authenticate')).toEqual(
101
+ 'Bearer resource_metadata="https://app.test.com/.well-known/oauth-protected-resource/api/mcp"'
102
+ );
103
+ expect(mockCreateMcpServer).not.toHaveBeenCalled();
104
+ });
105
+
106
+ test('mcpHandler extends the challenge with invalid_token when the bearer is not a JWT', async () => {
107
+ contextOverrides.mcpAuth = { tokenStatus: 'invalid', parseableJwt: false };
108
+ const res = await createApp().request('/api/mcp', { method: 'POST' });
109
+ expect(res.status).toEqual(401);
110
+ expect(res.headers.get('WWW-Authenticate')).toEqual(
111
+ 'Bearer error="invalid_token", error_description="The access token is not a JWT. Connect with a client that sends the RFC 8707 resource parameter.", resource_metadata="https://app.test.com/.well-known/oauth-protected-resource/api/mcp"'
112
+ );
113
+ });
114
+
115
+ test('mcpHandler tells a revoked grant to reconnect and choose an organization', async () => {
116
+ contextOverrides.mcpAuth = {
117
+ clientId: 'client_1',
118
+ organizationId: 'org_1',
119
+ tokenStatus: 'invalid',
120
+ parseableJwt: true,
121
+ revoked: true,
122
+ };
123
+ const res = await createApp().request('/api/mcp', { method: 'POST' });
124
+ expect(res.status).toEqual(401);
125
+ expect(res.headers.get('WWW-Authenticate')).toEqual(
126
+ 'Bearer error="invalid_token", error_description="This connection was disconnected. Reconnect to choose the organization to work in.", resource_metadata="https://app.test.com/.well-known/oauth-protected-resource/api/mcp"'
127
+ );
128
+ expect(mockCreateMcpServer).not.toHaveBeenCalled();
129
+ });
130
+
131
+ test('mcpHandler gives a token without membership the same challenge as an invalid token', async () => {
132
+ contextOverrides.mcpAuth = {
133
+ clientId: 'client_1',
134
+ organizationId: 'org_1',
135
+ tokenStatus: 'invalid',
136
+ parseableJwt: true,
137
+ noMembership: true,
138
+ };
139
+ const res = await createApp().request('/api/mcp', { method: 'POST' });
140
+ expect(res.status).toEqual(401);
141
+ expect(res.headers.get('WWW-Authenticate')).toEqual(
142
+ 'Bearer resource_metadata="https://app.test.com/.well-known/oauth-protected-resource/api/mcp"'
143
+ );
144
+ expect(mockCreateMcpServer).not.toHaveBeenCalled();
145
+ });
146
+
147
+ test('mcpHandler challenges an anonymous request when the surface has no public tool', async () => {
148
+ mockReadConfigFile.mockResolvedValue({ configured: true, hasPublicTool: false, endpoints: [] });
149
+ contextOverrides.mcpAuth = { tokenStatus: 'none', parseableJwt: true };
150
+ const res = await createApp().request('/api/mcp', { method: 'POST' });
151
+ expect(res.status).toEqual(401);
152
+ expect(res.headers.get('WWW-Authenticate')).toEqual(
153
+ 'Bearer resource_metadata="https://app.test.com/.well-known/oauth-protected-resource/api/mcp"'
154
+ );
155
+ });
156
+
157
+ test('mcpHandler serves an anonymous request when the surface has a public tool', async () => {
158
+ contextOverrides.mcpAuth = { tokenStatus: 'none', parseableJwt: true };
159
+ const res = await createApp().request('/api/mcp', { method: 'POST' });
160
+ expect(res.status).toEqual(200);
161
+ expect(await res.json()).toEqual({ served: true });
162
+ });
163
+
164
+ test('mcpHandler serves a valid-token request and never challenges past the boundary', async () => {
165
+ mockReadConfigFile.mockResolvedValue({ configured: true, hasPublicTool: false, endpoints: [] });
166
+ contextOverrides.mcpAuth = {
167
+ tokenStatus: 'valid',
168
+ parseableJwt: true,
169
+ grantedScopes: ['mcp:read'],
170
+ };
171
+ const res = await createApp().request('/api/mcp', { method: 'POST' });
172
+ expect(res.status).toEqual(200);
173
+ expect(await res.json()).toEqual({ served: true });
174
+ });
175
+
176
+ test('mcpHandler refuses a foreign Origin with a 403', async () => {
177
+ contextOverrides.mcpAuth = { tokenStatus: 'none', parseableJwt: true };
178
+ const res = await createApp().request('/api/mcp', {
179
+ method: 'POST',
180
+ headers: { Origin: 'https://evil.test' },
181
+ });
182
+ expect(res.status).toEqual(403);
183
+ expect(await res.json()).toEqual({ error: 'Origin not allowed.' });
184
+ expect(mockCreateMcpServer).not.toHaveBeenCalled();
185
+ });
186
+
187
+ test('mcpHandler accepts the pinned canonical origin', async () => {
188
+ contextOverrides.mcpAuth = { tokenStatus: 'none', parseableJwt: true };
189
+ const res = await createApp().request('/api/mcp', {
190
+ method: 'POST',
191
+ headers: { Origin: 'https://app.test.com' },
192
+ });
193
+ expect(res.status).toEqual(200);
194
+ });
195
+
196
+ test('mcpHandler accepts an origin registered on BetterAuth trustedOrigins', async () => {
197
+ contextOverrides.mcpAuth = { tokenStatus: 'none', parseableJwt: true };
198
+ contextOverrides.auth = { options: { trustedOrigins: ['https://studio.test.com'] } };
199
+ const res = await createApp().request('/api/mcp', {
200
+ method: 'POST',
201
+ headers: { Origin: 'https://studio.test.com' },
202
+ });
203
+ expect(res.status).toEqual(200);
204
+ });
205
+
206
+ test('mcpHandler serves openly with no envelope when oauthProvider is not configured', async () => {
207
+ delete mockAuthJson.oauthProvider;
208
+ contextOverrides.mcpAuth = { tokenStatus: 'invalid', parseableJwt: false };
209
+ const res = await createApp().request('/api/mcp', {
210
+ method: 'POST',
211
+ headers: { Origin: 'https://evil.test' },
212
+ });
213
+ expect(res.status).toEqual(200);
214
+ expect(await res.json()).toEqual({ served: true });
215
+ });