@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.
- package/client/App.jsx +41 -21
- package/client/Page.jsx +90 -26
- package/client/clearReloadedForTypes.js +29 -0
- package/client/clientBuildImports.test.mjs +6 -7
- package/client/getShownPage.js +28 -0
- package/client/getShownPage.test.mjs +52 -0
- package/client/loadAllIcons.js +45 -0
- package/client/loadPageTypes.js +44 -0
- package/client/main.jsx +22 -1
- package/client/reloadedForTypesKey.js +21 -0
- package/client/shouldReloadForTypes.js +38 -0
- package/client/shouldReloadForTypes.test.mjs +63 -0
- package/client/types.js +28 -0
- package/lib/build/security.js +26 -0
- package/lib/client/auth/Auth.jsx +11 -6
- package/lib/client/auth/AuthConfigured.jsx +222 -36
- package/lib/client/auth/AuthNotConfigured.js +44 -4
- package/lib/client/auth/createFetchCheckingBuild.js +45 -0
- package/lib/client/auth/createFetchCheckingBuild.test.mjs +100 -0
- package/lib/client/bundleBuildId.js +23 -0
- package/lib/client/createLogUsage.js +46 -25
- package/lib/client/createLogUsage.test.mjs +90 -0
- package/lib/client/getCspNonce.js +25 -0
- package/lib/server/auth/createSystemContext.js +70 -0
- package/lib/server/auth/{getAuthConfig.js → getAuth.js} +15 -9
- package/lib/server/auth/getStrategies.js +40 -0
- package/lib/server/getClientAddress.js +56 -0
- package/lib/server/getClientAddress.test.mjs +108 -0
- package/lib/server/log/createHandleError.js +7 -0
- package/lib/server/log/createHandleError.test.mjs +17 -0
- package/lib/server/log/createLogger.test.mjs +15 -0
- package/lib/server/log/logRequest.js +5 -3
- package/lib/server/log/projectErrorForLog.js +6 -0
- package/lib/server/log/serializeErrorForLog.test.mjs +13 -1
- package/lib/server/sentry/captureSentryError.js +17 -3
- package/lib/server/sentry/captureSentryError.test.mjs +37 -13
- package/lib/server/sentry/initSentry.js +39 -18
- package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
- package/lib/server/sentry/initSentry.test.mjs +132 -4
- package/lib/server/sentry/scrubEvent.js +12 -1
- package/lib/server/sentry/sentrySdk.js +23 -0
- package/lib/server/sentry/setSentryUser.js +3 -3
- package/lowdefy/build.mjs +1 -0
- package/lowdefy/build.test.mjs +68 -0
- package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
- package/package.json +42 -36
- package/package.original.json +42 -36
- package/src/app.js +74 -17
- package/src/app.test.mjs +118 -0
- package/src/html/collectChunkClosure.js +31 -0
- package/src/html/collectLazyImports.js +41 -0
- package/src/html/collectPageTypesAssets.js +57 -0
- package/src/html/collectPageTypesAssets.test.mjs +130 -0
- package/src/html/getAssets.js +20 -1
- package/src/html/getHomePath.js +29 -0
- package/src/html/getPageAssets.js +32 -0
- package/src/html/getPageAssets.test.mjs +47 -0
- package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
- package/src/html/renderPage.js +80 -19
- package/src/html/renderPage.test.mjs +189 -0
- package/src/html/template.js +50 -15
- package/src/html/template.test.mjs +126 -0
- package/src/index.js +28 -29
- package/src/initServer.js +61 -0
- package/src/initServer.test.mjs +116 -0
- package/src/lib/getRequestPath.js +29 -0
- package/src/middleware/apiContext.js +65 -13
- package/src/middleware/apiContext.test.mjs +66 -6
- package/src/middleware/createSameOriginGuard.js +36 -0
- package/src/middleware/createSameOriginGuard.test.mjs +102 -0
- package/src/middleware/errorHandler.js +56 -4
- package/src/middleware/errorHandler.test.mjs +440 -0
- package/{lib/server/auth/session.js → src/middleware/noStore.js} +9 -13
- package/src/middleware/refuseOtherBuild.js +49 -0
- package/src/middleware/refuseOtherBuild.test.mjs +63 -0
- package/src/middleware/requestTimeout.js +38 -0
- package/src/middleware/sentry.js +13 -5
- package/src/middleware/stampBuildId.js +30 -0
- package/src/middleware/stampBuildId.test.mjs +41 -0
- package/src/middleware/stampCaller.js +38 -0
- package/src/middleware/stampCaller.test.mjs +64 -0
- package/src/routes/agent.js +15 -3
- package/src/routes/apiPage.js +51 -9
- package/src/routes/apiPage.test.mjs +99 -15
- package/src/routes/auth.js +15 -9
- package/src/routes/auth.test.mjs +85 -0
- package/src/routes/clientError.js +11 -13
- package/src/routes/cron.js +3 -1
- package/src/routes/cronForward.js +3 -1
- package/src/routes/detached.js +22 -9
- package/src/routes/endpoints.js +27 -26
- package/src/routes/endpointsTextAnswer.test.mjs +77 -0
- package/src/routes/mcp.js +82 -5
- package/src/routes/mcp.test.mjs +215 -0
- package/src/routes/mcpMemberToken.test.mjs +125 -0
- package/src/routes/mcpProtectedResourceMetadata.js +34 -0
- package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
- package/src/routes/mountOauthDiscovery.js +44 -0
- package/src/routes/mountOauthDiscovery.test.mjs +79 -0
- package/src/routes/mountPageRoutes.js +29 -0
- package/src/routes/mountPageRoutes.test.mjs +56 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
- package/src/routes/request.js +5 -3
- package/src/routes/requestBody.test.mjs +146 -0
- package/src/routes/usage.js +6 -2
- package/src/routes/user.js +27 -0
- package/src/routes/websocket.js +26 -3
- package/src/routes/websocket.test.mjs +106 -0
- package/src/routes/wellKnownFallback.js +30 -0
- package/vite.config.js +55 -2
- package/client/shouldReloadForBuild.js +0 -48
- package/client/shouldReloadForBuild.test.mjs +0 -75
package/src/routes/detached.js
CHANGED
|
@@ -14,23 +14,34 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
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).
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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
|
-
|
|
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.
|
|
52
|
+
const { credentials, payload, principal } = parseRequestBody({ text: await c.req.text() });
|
|
42
53
|
context.logger.info({ event: 'call_detached_endpoint', endpointId });
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
return c.json(
|
|
55
|
+
acceptDetachedEndpoint(context, { credentials, endpointId, payload, principal }),
|
|
56
|
+
202
|
|
57
|
+
);
|
|
45
58
|
}
|
|
46
59
|
|
|
47
60
|
export default detachedHandler;
|
package/src/routes/endpoints.js
CHANGED
|
@@ -14,55 +14,56 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
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
|
-
|
|
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
|
|
29
|
-
// (SNS, Event Grid, Stripe, ...): they take the
|
|
30
|
-
// caller's own format (not the { payload }
|
|
31
|
-
// and their return value is sent back
|
|
32
|
-
// (e.g. Event Grid's validationResponse)
|
|
33
|
-
// The config lookup is a cached file read; an
|
|
34
|
-
// to the standard path so its error shape is
|
|
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
|
|
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
|
|
51
|
+
const { status, body, contentType } = await runWebhookEndpoint(context, {
|
|
54
52
|
endpointId,
|
|
55
|
-
|
|
53
|
+
rawBody: await c.req.text(),
|
|
56
54
|
query: c.req.query(),
|
|
57
55
|
headers: c.req.header(),
|
|
58
56
|
});
|
|
59
|
-
if (
|
|
60
|
-
|
|
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(
|
|
63
|
+
return c.json(body, status);
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
const { blockId, payload, pageId } = await c.req.
|
|
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
|
|
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
|
|
26
|
-
if (
|
|
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
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
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 answers a refused member token with its reason and no resource metadata', async () => {
|
|
89
|
+
contextOverrides.mcpAuth = {
|
|
90
|
+
tokenStatus: 'invalid',
|
|
91
|
+
memberToken: true,
|
|
92
|
+
description: 'This token has expired.',
|
|
93
|
+
};
|
|
94
|
+
const res = await createApp().request('/api/mcp', { method: 'POST' });
|
|
95
|
+
expect(res.status).toEqual(401);
|
|
96
|
+
expect(res.headers.get('WWW-Authenticate')).toEqual(
|
|
97
|
+
'Bearer error="invalid_token", error_description="This token has expired."'
|
|
98
|
+
);
|
|
99
|
+
expect(mockCreateMcpServer).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('mcpHandler answers a member token whose member is gone with that reason', async () => {
|
|
103
|
+
contextOverrides.mcpAuth = {
|
|
104
|
+
tokenStatus: 'invalid',
|
|
105
|
+
memberToken: true,
|
|
106
|
+
description: "This token's member can no longer use it.",
|
|
107
|
+
};
|
|
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="This token\'s member can no longer use it."'
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('mcpHandler serves a valid member token caller', async () => {
|
|
116
|
+
contextOverrides.mcpAuth = {
|
|
117
|
+
tokenStatus: 'valid',
|
|
118
|
+
tokenId: 'token_1',
|
|
119
|
+
organizationId: 'org_1',
|
|
120
|
+
grantedScopes: ['mcp:read', 'mcp:write'],
|
|
121
|
+
};
|
|
122
|
+
const res = await createApp().request('/api/mcp', { method: 'POST' });
|
|
123
|
+
expect(res.status).toEqual(200);
|
|
124
|
+
expect(mockCreateMcpServer).toHaveBeenCalledTimes(1);
|
|
125
|
+
});
|