@lowdefy/server 6.1.0 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +2 -0
- package/lowdefy/build.test.mjs +88 -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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { jest } from '@jest/globals';
|
|
18
|
+
|
|
19
|
+
const calls = [];
|
|
20
|
+
const info = jest.fn();
|
|
21
|
+
const guards = { env: { BETTER_AUTH_URL: '^https://app\\.example\\.com$' } };
|
|
22
|
+
|
|
23
|
+
const checkEnvironmentGuards = jest.fn(() => {
|
|
24
|
+
calls.push('checkEnvironmentGuards');
|
|
25
|
+
});
|
|
26
|
+
const captureException = jest.fn();
|
|
27
|
+
const flush = jest.fn(async () => true);
|
|
28
|
+
const sentry = { captureException, flush };
|
|
29
|
+
const initSentryServer = jest.fn(async () => {
|
|
30
|
+
calls.push('initSentryServer');
|
|
31
|
+
return null;
|
|
32
|
+
});
|
|
33
|
+
const createApp = jest.fn();
|
|
34
|
+
|
|
35
|
+
jest.unstable_mockModule('@lowdefy/node-utils/checkEnvironmentGuards.js', () => ({
|
|
36
|
+
default: checkEnvironmentGuards,
|
|
37
|
+
}));
|
|
38
|
+
jest.unstable_mockModule('../lib/server/sentry/initSentry.js', () => ({
|
|
39
|
+
default: initSentryServer,
|
|
40
|
+
}));
|
|
41
|
+
jest.unstable_mockModule('./app.js', () => {
|
|
42
|
+
calls.push('import app');
|
|
43
|
+
return { default: createApp };
|
|
44
|
+
});
|
|
45
|
+
jest.unstable_mockModule('../lib/server/log/createLogger.js', () => ({
|
|
46
|
+
default: () => ({ info }),
|
|
47
|
+
}));
|
|
48
|
+
jest.unstable_mockModule('../lib/build/config.js', () => ({
|
|
49
|
+
default: { environment: 'prod', environments: { prod: { guards } } },
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
const { default: initServer } = await import('./initServer.js');
|
|
53
|
+
|
|
54
|
+
// The app module is imported once and then cached, so find the run that imported it rather than
|
|
55
|
+
// assuming this test runs first.
|
|
56
|
+
test('initServer initialises Sentry and checks the guards before importing the app', async () => {
|
|
57
|
+
const result = await initServer();
|
|
58
|
+
const importIndex = calls.indexOf('import app');
|
|
59
|
+
expect(calls.slice(importIndex - 2, importIndex + 1)).toEqual([
|
|
60
|
+
'initSentryServer',
|
|
61
|
+
'checkEnvironmentGuards',
|
|
62
|
+
'import app',
|
|
63
|
+
]);
|
|
64
|
+
expect(result.createApp).toBe(createApp);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('initServer checks the current environment guards', async () => {
|
|
68
|
+
await initServer();
|
|
69
|
+
expect(checkEnvironmentGuards.mock.calls).toEqual([[{ name: 'prod', guards }]]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('initServer throws when the current environment guards fail', async () => {
|
|
73
|
+
checkEnvironmentGuards.mockImplementationOnce(() => {
|
|
74
|
+
throw new Error('Environment "prod" guards failed.');
|
|
75
|
+
});
|
|
76
|
+
await expect(initServer()).rejects.toThrow('Environment "prod" guards failed.');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('initServer sends a guard failure to Sentry and flushes it before throwing', async () => {
|
|
80
|
+
const error = new Error('Environment "prod" guards failed.');
|
|
81
|
+
initSentryServer.mockResolvedValueOnce(sentry);
|
|
82
|
+
checkEnvironmentGuards.mockImplementationOnce(() => {
|
|
83
|
+
throw error;
|
|
84
|
+
});
|
|
85
|
+
await expect(initServer()).rejects.toBe(error);
|
|
86
|
+
expect(captureException.mock.calls).toEqual([[error]]);
|
|
87
|
+
expect(flush).toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('initServer throws a guard failure without Sentry when Sentry was not started', async () => {
|
|
91
|
+
const error = new Error('Environment "prod" guards failed.');
|
|
92
|
+
checkEnvironmentGuards.mockImplementationOnce(() => {
|
|
93
|
+
throw error;
|
|
94
|
+
});
|
|
95
|
+
await expect(initServer()).rejects.toBe(error);
|
|
96
|
+
expect(captureException).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('initServer starts Sentry for a long-running server unless the entry is serverless', async () => {
|
|
100
|
+
await initServer();
|
|
101
|
+
await initServer({ serverless: true });
|
|
102
|
+
expect(initSentryServer.mock.calls).toEqual([[{ serverless: false }], [{ serverless: true }]]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('initServer logs that Sentry is enabled and returns its SDK when Sentry was started', async () => {
|
|
106
|
+
initSentryServer.mockResolvedValueOnce(sentry);
|
|
107
|
+
const result = await initServer();
|
|
108
|
+
expect(result.sentry).toBe(sentry);
|
|
109
|
+
expect(info.mock.calls).toEqual([['Sentry enabled: server']]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('initServer does not log Sentry when Sentry was not started', async () => {
|
|
113
|
+
const result = await initServer();
|
|
114
|
+
expect(result.sentry).toBe(null);
|
|
115
|
+
expect(info).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
@@ -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
|
+
// The rest of the request path after basePath and prefix, still encoded: the
|
|
18
|
+
// page matcher decodes each segment once, and Hono's c.req.path is partly
|
|
19
|
+
// decoded, so it is taken from the request URL. path goes to the matcher as it
|
|
20
|
+
// arrived; matchedPath is path with one trailing "/" removed, the form the
|
|
21
|
+
// client compares its navigations with.
|
|
22
|
+
function getRequestPath({ c, basePath, prefix }) {
|
|
23
|
+
const pathname = new URL(c.req.url).pathname.slice(basePath.length);
|
|
24
|
+
const path = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : '';
|
|
25
|
+
const matchedPath = path.endsWith('/') ? path.slice(0, -1) : path;
|
|
26
|
+
return { path, matchedPath };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default getRequestPath;
|
|
@@ -15,7 +15,14 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import path from 'node:path';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
createApiContext,
|
|
20
|
+
createRequestSignal,
|
|
21
|
+
ensureMcpOauthResource,
|
|
22
|
+
resolveAuthentication,
|
|
23
|
+
resolvePinnedOrganization,
|
|
24
|
+
resolveTenantPreflight,
|
|
25
|
+
} from '@lowdefy/api';
|
|
19
26
|
import { getSecretsFromEnv } from '@lowdefy/node-utils';
|
|
20
27
|
import { v4 as uuid } from 'uuid';
|
|
21
28
|
|
|
@@ -23,11 +30,13 @@ import agents from '../../build/plugins/agents.js';
|
|
|
23
30
|
import appMeta from '../../lib/build/appMeta.js';
|
|
24
31
|
import config from '../../lib/build/config.js';
|
|
25
32
|
import connections from '../../build/plugins/connections.js';
|
|
33
|
+
import captureSentryError from '../../lib/server/sentry/captureSentryError.js';
|
|
26
34
|
import createHandleError from '../../lib/server/log/createHandleError.js';
|
|
27
35
|
import createLogger from '../../lib/server/log/createLogger.js';
|
|
28
36
|
import fileCache from '../../lib/server/fileCache.js';
|
|
29
|
-
import
|
|
30
|
-
import
|
|
37
|
+
import getAuth from '../../lib/server/auth/getAuth.js';
|
|
38
|
+
import getClientAddress from '../../lib/server/getClientAddress.js';
|
|
39
|
+
import getStrategies from '../../lib/server/auth/getStrategies.js';
|
|
31
40
|
import i18nConfig from '../../lib/build/i18n.js';
|
|
32
41
|
import jsMap from '../../build/plugins/operators/serverJsMap.js';
|
|
33
42
|
import logRequest from '../../lib/server/log/logRequest.js';
|
|
@@ -39,6 +48,7 @@ import notifications, {
|
|
|
39
48
|
} from '../../build/plugins/notifications.js';
|
|
40
49
|
import operators from '../../build/plugins/operators/server.js';
|
|
41
50
|
import setSentryUser from '../../lib/server/sentry/setSentryUser.js';
|
|
51
|
+
import steps from '../../build/plugins/steps.js';
|
|
42
52
|
import websockets from '../../build/plugins/websockets.js';
|
|
43
53
|
|
|
44
54
|
const secrets = getSecretsFromEnv();
|
|
@@ -56,11 +66,19 @@ function getRequestId(c) {
|
|
|
56
66
|
return uuid();
|
|
57
67
|
}
|
|
58
68
|
|
|
69
|
+
// The MCP route authenticates by access token alone - the mcp option switches
|
|
70
|
+
// resolveAuthentication onto its bearer branch, so it is set only for the MCP
|
|
71
|
+
// path. Every other path leaves it false and authentication behaves as before.
|
|
72
|
+
function isMcpPath(path) {
|
|
73
|
+
return path.endsWith('/api/mcp');
|
|
74
|
+
}
|
|
75
|
+
|
|
59
76
|
// Replaces lib/server/apiWrapper.js. Builds the request context consumed by
|
|
60
77
|
// @lowdefy/api functions. Errors thrown by handlers are routed by Hono to the
|
|
61
78
|
// app-level error handler (src/middleware/errorHandler.js), which reads this
|
|
62
|
-
// context back from the Hono context.
|
|
63
|
-
|
|
79
|
+
// context back from the Hono context. clientAddressHeader is set by a platform
|
|
80
|
+
// entry whose edge supplies the client address (see getClientAddress).
|
|
81
|
+
function apiContext({ clientAddressHeader } = {}) {
|
|
64
82
|
return async function apiContextMiddleware(c, next) {
|
|
65
83
|
// The page-route mount ('/*') also matches /api/* paths that already
|
|
66
84
|
// built a context — never build twice for one request.
|
|
@@ -95,6 +113,13 @@ function apiContext() {
|
|
|
95
113
|
},
|
|
96
114
|
scrubSecrets,
|
|
97
115
|
secrets,
|
|
116
|
+
// Aborts when the client disconnects before the response is sent, or when the
|
|
117
|
+
// request timeout (src/middleware/requestTimeout.js) answers first.
|
|
118
|
+
signal: createRequestSignal({
|
|
119
|
+
clientSignal: c.req.raw.signal,
|
|
120
|
+
timeoutSignal: c.get('requestTimeoutSignal'),
|
|
121
|
+
}),
|
|
122
|
+
steps,
|
|
98
123
|
// On Vercel (fluid compute) the platform request context keeps the
|
|
99
124
|
// invocation alive until waitUntil promises settle; on long-lived hosts
|
|
100
125
|
// the lookup resolves to nothing and background promises just run.
|
|
@@ -103,21 +128,48 @@ function apiContext() {
|
|
|
103
128
|
websockets,
|
|
104
129
|
};
|
|
105
130
|
context.handleError = createHandleError({ context });
|
|
131
|
+
// Resolved once per request: the auth route hands it to BetterAuth, and the
|
|
132
|
+
// request log records it.
|
|
133
|
+
context.clientAddress = getClientAddress({
|
|
134
|
+
c,
|
|
135
|
+
clientAddressHeader,
|
|
136
|
+
logger: context.logger,
|
|
137
|
+
});
|
|
138
|
+
// Hoisted once per request - resolveAuthentication also needs it, and
|
|
139
|
+
// getBetterAuth memoizes the instance, but this keeps the auth engine
|
|
140
|
+
// construction to a single call site per request.
|
|
141
|
+
context.auth = getAuth({ logger: context.logger });
|
|
142
|
+
// The engine is constructed lazily on the first request, which would
|
|
143
|
+
// otherwise race the startup pinned-org ensure - await the memoized
|
|
144
|
+
// resolve so createApiContext reads a retained binding.
|
|
145
|
+
await resolvePinnedOrganization({ auth: context.auth, logger: context.logger });
|
|
146
|
+
// The one oauthResource row the MCP token audience validates against -
|
|
147
|
+
// ensured once per process, memoized like the pinned resolve above; a
|
|
148
|
+
// failure retries on the next request. No-op when the app is not an
|
|
149
|
+
// authorization server.
|
|
150
|
+
await ensureMcpOauthResource({ auth: context.auth, logger: context.logger });
|
|
106
151
|
if (!c.req.path.includes('/api/auth')) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
152
|
+
// resolveAuthentication is the single writer of context.user.
|
|
153
|
+
await resolveAuthentication(context, {
|
|
154
|
+
auth: context.auth,
|
|
155
|
+
headers: c.req.raw.headers,
|
|
156
|
+
strategies: getStrategies({ logger: context.logger }),
|
|
157
|
+
mcp: isMcpPath(c.req.path),
|
|
158
|
+
});
|
|
114
159
|
// Set Sentry user context for authenticated requests
|
|
115
160
|
setSentryUser({
|
|
116
|
-
user: context.
|
|
161
|
+
user: context.user,
|
|
117
162
|
sentryConfig: loggerConfig.sentry,
|
|
118
163
|
});
|
|
119
164
|
}
|
|
120
165
|
createApiContext(context);
|
|
166
|
+
// Under policy: tenant, report walled collections that hold unstamped rows
|
|
167
|
+
// (lazily-run-once, logged and captured once per offending collection). It
|
|
168
|
+
// never blocks or fails a request - a data fault must not take the app
|
|
169
|
+
// down - so it is started, not awaited. No-op under pinned.
|
|
170
|
+
void resolveTenantPreflight(context, {
|
|
171
|
+
captureError: (error) => captureSentryError({ error, context }),
|
|
172
|
+
});
|
|
121
173
|
c.set('lowdefyContext', context);
|
|
122
174
|
// Echo the request id so clients and proxies can quote it when reporting
|
|
123
175
|
// a failure, and it can be matched to the rid on the server log lines.
|
|
@@ -24,7 +24,18 @@ import { jest } from '@jest/globals';
|
|
|
24
24
|
const secret = 'planted-apicontext-secret';
|
|
25
25
|
process.env.LOWDEFY_SECRET_TEST = secret;
|
|
26
26
|
|
|
27
|
-
jest.unstable_mockModule('@lowdefy/api', () => ({
|
|
27
|
+
jest.unstable_mockModule('@lowdefy/api', () => ({
|
|
28
|
+
createApiContext: jest.fn(),
|
|
29
|
+
createRequestSignal: jest.fn(({ clientSignal, timeoutSignal }) =>
|
|
30
|
+
AbortSignal.any([clientSignal, timeoutSignal].filter(Boolean))
|
|
31
|
+
),
|
|
32
|
+
// Returns what the server passes, so the test reads the auth-hook system context inputs.
|
|
33
|
+
createSystemContext: jest.fn((options) => options),
|
|
34
|
+
ensureMcpOauthResource: jest.fn(async () => {}),
|
|
35
|
+
resolveAuthentication: jest.fn(async () => {}),
|
|
36
|
+
resolvePinnedOrganization: jest.fn(async () => {}),
|
|
37
|
+
resolveTenantPreflight: jest.fn(async () => {}),
|
|
38
|
+
}));
|
|
28
39
|
jest.unstable_mockModule('../../lib/build/appMeta.js', () => ({ default: {} }));
|
|
29
40
|
jest.unstable_mockModule('../../lib/build/config.js', () => ({ default: {} }));
|
|
30
41
|
jest.unstable_mockModule('../../lib/build/i18n.js', () => ({ default: {} }));
|
|
@@ -33,14 +44,19 @@ jest.unstable_mockModule('../../lib/server/log/createHandleError.js', () => ({
|
|
|
33
44
|
default: jest.fn(() => jest.fn()),
|
|
34
45
|
}));
|
|
35
46
|
jest.unstable_mockModule('../../lib/server/log/createLogger.js', () => ({
|
|
36
|
-
default: jest.fn(() => ({
|
|
47
|
+
default: jest.fn(() => ({
|
|
48
|
+
debug: jest.fn(),
|
|
49
|
+
info: jest.fn(),
|
|
50
|
+
warn: jest.fn(),
|
|
51
|
+
error: jest.fn(),
|
|
52
|
+
})),
|
|
37
53
|
}));
|
|
38
54
|
jest.unstable_mockModule('../../lib/server/fileCache.js', () => ({ default: {} }));
|
|
39
|
-
jest.unstable_mockModule('../../lib/server/auth/
|
|
40
|
-
default: jest.fn(
|
|
55
|
+
jest.unstable_mockModule('../../lib/server/auth/getAuth.js', () => ({
|
|
56
|
+
default: jest.fn(() => null),
|
|
41
57
|
}));
|
|
42
|
-
jest.unstable_mockModule('../../lib/server/auth/
|
|
43
|
-
default: jest.fn(
|
|
58
|
+
jest.unstable_mockModule('../../lib/server/auth/getStrategies.js', () => ({
|
|
59
|
+
default: jest.fn(() => null),
|
|
44
60
|
}));
|
|
45
61
|
jest.unstable_mockModule('../../lib/server/log/logRequest.js', () => ({ default: jest.fn() }));
|
|
46
62
|
jest.unstable_mockModule('../../lib/server/sentry/setSentryUser.js', () => ({
|
|
@@ -62,6 +78,7 @@ const fixtures = [
|
|
|
62
78
|
},
|
|
63
79
|
{ file: path.join('operators', 'server.js'), content: 'export default {};\n' },
|
|
64
80
|
{ file: path.join('operators', 'serverJsMap.js'), content: 'export default {};\n' },
|
|
81
|
+
{ file: 'steps.js', content: 'export default {};\n' },
|
|
65
82
|
{ file: 'websockets.js', content: 'export default {};\n' },
|
|
66
83
|
].map(({ file, content }) => ({ absolutePath: path.join(pluginsDir, file), content }));
|
|
67
84
|
|
|
@@ -89,6 +106,10 @@ afterAll(() => {
|
|
|
89
106
|
});
|
|
90
107
|
|
|
91
108
|
const { default: apiContext } = await import('./apiContext.js');
|
|
109
|
+
const { default: requestTimeout } = await import('./requestTimeout.js');
|
|
110
|
+
const { default: createSystemContext } = await import(
|
|
111
|
+
'../../lib/server/auth/createSystemContext.js'
|
|
112
|
+
);
|
|
92
113
|
|
|
93
114
|
function createApp() {
|
|
94
115
|
const app = new Hono();
|
|
@@ -116,3 +137,42 @@ test('apiContext scrubSecrets redacts a planted secret', async () => {
|
|
|
116
137
|
const body = await res.json();
|
|
117
138
|
expect(body.scrubbed).toEqual('token [REDACTED] end');
|
|
118
139
|
});
|
|
140
|
+
|
|
141
|
+
test('the auth-hook system context is built with mode prod and a scrubSecrets that redacts', () => {
|
|
142
|
+
const context = createSystemContext({ auth: null });
|
|
143
|
+
expect(context.mode).toEqual('prod');
|
|
144
|
+
expect(context.scrubSecrets(`token ${secret} end`)).toEqual('token [REDACTED] end');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('the request timeout answers 504 and cancels the work the request left running', async () => {
|
|
148
|
+
const app = new Hono();
|
|
149
|
+
app.use('*', requestTimeout({ timeoutMs: 20 }));
|
|
150
|
+
app.use('*', apiContext());
|
|
151
|
+
let reason;
|
|
152
|
+
app.get('/api/slow', async (c) => {
|
|
153
|
+
const { signal } = c.get('lowdefyContext');
|
|
154
|
+
reason = await new Promise((resolve) => {
|
|
155
|
+
signal.addEventListener('abort', () => resolve(signal.reason), { once: true });
|
|
156
|
+
});
|
|
157
|
+
return c.json({ finished: true });
|
|
158
|
+
});
|
|
159
|
+
const res = await app.request('/api/slow');
|
|
160
|
+
expect(res.status).toEqual(504);
|
|
161
|
+
expect(reason.name).toEqual('TimeoutError');
|
|
162
|
+
expect(reason.message).toEqual('The request timeout of 20ms was exceeded.');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('a request that answers within the timeout leaves its signal unaborted', async () => {
|
|
166
|
+
const app = new Hono();
|
|
167
|
+
app.use('*', requestTimeout({ timeoutMs: 20 }));
|
|
168
|
+
app.use('*', apiContext());
|
|
169
|
+
let signal;
|
|
170
|
+
app.get('/api/fast', (c) => {
|
|
171
|
+
signal = c.get('lowdefyContext').signal;
|
|
172
|
+
return c.json({ finished: true });
|
|
173
|
+
});
|
|
174
|
+
const res = await app.request('/api/fast');
|
|
175
|
+
expect(res.status).toEqual(200);
|
|
176
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
177
|
+
expect(signal.aborted).toBe(false);
|
|
178
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
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 { isSameOriginRequest } from '@lowdefy/api';
|
|
18
|
+
|
|
19
|
+
// The cross-site defence for /api/client-error, which only this app's own
|
|
20
|
+
// pages call. The rule itself - Sec-Fetch-Site, then Origin against Host - is
|
|
21
|
+
// isSameOriginRequest in @lowdefy/api, which every server shares. Unlike a
|
|
22
|
+
// websocket handshake, a fetch can carry X-Forwarded-Host where a CORS
|
|
23
|
+
// preflight allows it, so HTTP routes compare the Origin with Host only.
|
|
24
|
+
function createSameOriginGuard({ allowNoOrigin = false } = {}) {
|
|
25
|
+
// Returns the 403 response to answer with, or null when the request may
|
|
26
|
+
// proceed. A guard, not Hono middleware, so a route keeps one entry point
|
|
27
|
+
// and the refusal is visible at the top of the handler that owns it.
|
|
28
|
+
return function guardSameOrigin(c) {
|
|
29
|
+
if (isSameOriginRequest({ getHeader: (name) => c.req.header(name), allowNoOrigin })) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return c.json({ error: 'Forbidden' }, 403);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default createSameOriginGuard;
|
|
@@ -0,0 +1,102 @@
|
|
|
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 createSameOriginGuard from './createSameOriginGuard.js';
|
|
20
|
+
|
|
21
|
+
function createApp({ allowNoOrigin } = {}) {
|
|
22
|
+
const guardSameOrigin = createSameOriginGuard({ allowNoOrigin });
|
|
23
|
+
const app = new Hono();
|
|
24
|
+
app.all('/post', (c) => {
|
|
25
|
+
const forbidden = guardSameOrigin(c);
|
|
26
|
+
if (forbidden) {
|
|
27
|
+
return forbidden;
|
|
28
|
+
}
|
|
29
|
+
return c.json({ allowed: true });
|
|
30
|
+
});
|
|
31
|
+
return app;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function post({ allowNoOrigin, headers }) {
|
|
35
|
+
return createApp({ allowNoOrigin }).request('/post', { headers, method: 'POST' });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test.each([
|
|
39
|
+
['origin matches the host', { host: 'app.test', origin: 'https://app.test' }],
|
|
40
|
+
[
|
|
41
|
+
'sec-fetch-site says same-origin',
|
|
42
|
+
{ host: 'app.test', origin: 'https://app.test', 'sec-fetch-site': 'same-origin' },
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
'sec-fetch-site says none',
|
|
46
|
+
{ host: 'app.test', origin: 'https://app.test', 'sec-fetch-site': 'none' },
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
'the origin carries a port matching the host',
|
|
50
|
+
{ host: 'app.test:3000', origin: 'http://app.test:3000' },
|
|
51
|
+
],
|
|
52
|
+
])('the guard allows a post when %s', async (_, headers) => {
|
|
53
|
+
const res = await post({ headers });
|
|
54
|
+
|
|
55
|
+
expect(res.status).toEqual(200);
|
|
56
|
+
expect(await res.json()).toEqual({ allowed: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test.each([
|
|
60
|
+
['the origin is another site', { host: 'app.test', origin: 'https://evil.test' }],
|
|
61
|
+
['the origin is a sibling subdomain', { host: 'app.test', origin: 'https://other.app.test' }],
|
|
62
|
+
['the origin is not a url', { host: 'app.test', origin: 'not a url' }],
|
|
63
|
+
[
|
|
64
|
+
'sec-fetch-site says cross-site',
|
|
65
|
+
{ host: 'app.test', origin: 'https://app.test', 'sec-fetch-site': 'cross-site' },
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
'sec-fetch-site says same-site',
|
|
69
|
+
{ host: 'app.test', origin: 'https://app.test', 'sec-fetch-site': 'same-site' },
|
|
70
|
+
],
|
|
71
|
+
['there is no origin', { host: 'app.test' }],
|
|
72
|
+
])('the guard answers 403 when %s', async (_, headers) => {
|
|
73
|
+
const res = await post({ headers });
|
|
74
|
+
|
|
75
|
+
expect(res.status).toEqual(403);
|
|
76
|
+
expect(await res.json()).toEqual({ error: 'Forbidden' });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the guard lets a caller that sends no origin through when the route allows it', async () => {
|
|
80
|
+
const res = await post({ allowNoOrigin: true, headers: { host: 'app.test' } });
|
|
81
|
+
|
|
82
|
+
expect(res.status).toEqual(200);
|
|
83
|
+
expect(await res.json()).toEqual({ allowed: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('allowNoOrigin does not excuse a browser posting cross-site', async () => {
|
|
87
|
+
const res = await post({
|
|
88
|
+
allowNoOrigin: true,
|
|
89
|
+
headers: { host: 'app.test', origin: 'https://evil.test' },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(res.status).toEqual(403);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('allowNoOrigin does not excuse a cross-site sec-fetch-site with no origin', async () => {
|
|
96
|
+
const res = await post({
|
|
97
|
+
allowNoOrigin: true,
|
|
98
|
+
headers: { host: 'app.test', 'sec-fetch-site': 'cross-site' },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(res.status).toEqual(403);
|
|
102
|
+
});
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import * as Sentry from '@sentry/node';
|
|
18
17
|
import { redactErrorResponse } from '@lowdefy/api';
|
|
18
|
+
import { HTTPException } from 'hono/http-exception';
|
|
19
|
+
|
|
20
|
+
import sentrySdk from '../../lib/server/sentry/sentrySdk.js';
|
|
19
21
|
|
|
20
22
|
// Hono routes every handler error to the app-level error handler — upstream
|
|
21
23
|
// middleware try/catch never sees them (compose catches per dispatch level).
|
|
@@ -35,14 +37,64 @@ function createErrorHandler({ basePath = '', logger }) {
|
|
|
35
37
|
}
|
|
36
38
|
return c.text('Unauthorized', 401);
|
|
37
39
|
}
|
|
40
|
+
// An authenticated caller whose roles do not permit the resource. Expected
|
|
41
|
+
// traffic, not a fault: one warning line, no structured error log and no
|
|
42
|
+
// Sentry capture.
|
|
43
|
+
if (error.name === 'AuthorizationError') {
|
|
44
|
+
logger.warn(`Forbidden: ${c.req.method} ${c.req.path}`);
|
|
45
|
+
if (path.startsWith('/api/')) {
|
|
46
|
+
return c.json({ name: error.name, message: error.message }, 403);
|
|
47
|
+
}
|
|
48
|
+
return c.text('Forbidden', 403);
|
|
49
|
+
}
|
|
50
|
+
// An authorized caller who has not enrolled a second factor under
|
|
51
|
+
// auth.twoFactor.required. 403, not 401 - a 401 reads to the client as a dead
|
|
52
|
+
// session and bounces the user to sign-in, which is the loop the gate exists to
|
|
53
|
+
// avoid. Expected traffic under required: true, so one warning line and no
|
|
54
|
+
// Sentry capture, exactly as for AuthenticationError.
|
|
55
|
+
if (error.name === 'TwoFactorEnrolmentRequiredError') {
|
|
56
|
+
logger.warn(`Two-factor enrolment required: ${c.req.method} ${c.req.path}`);
|
|
57
|
+
if (path.startsWith('/api/')) {
|
|
58
|
+
return c.json({ name: error.name, message: error.message }, 403);
|
|
59
|
+
}
|
|
60
|
+
return c.text('Two-factor enrolment required', 403);
|
|
61
|
+
}
|
|
62
|
+
// An API client whose payload does not match the endpoint's declared
|
|
63
|
+
// payloadSchema (or any other UserError raised before a routine starts). A
|
|
64
|
+
// page's CallAPI payload the schema refuses is a ConfigError instead: the
|
|
65
|
+
// page's own config built it (validatePayload). The caller's own
|
|
66
|
+
// request is wrong, not the config or the server: a 400 with the message the
|
|
67
|
+
// caller needs, one warning line, no structured error log and no Sentry
|
|
68
|
+
// capture. A UserError raised inside a routine never gets here - it returns
|
|
69
|
+
// 200 with success: false through buildEndpointResult.
|
|
70
|
+
if (error.name === 'UserError') {
|
|
71
|
+
logger.warn(`Bad request: ${c.req.method} ${c.req.path} - ${error.message}`);
|
|
72
|
+
if (path.startsWith('/api/')) {
|
|
73
|
+
return c.json({ name: error.name, message: error.message }, 400);
|
|
74
|
+
}
|
|
75
|
+
return c.text('Bad Request', 400);
|
|
76
|
+
}
|
|
77
|
+
// A route or transport that has already decided its own HTTP answer - hono's
|
|
78
|
+
// HTTPException carries the response to send. The MCP transport refuses an
|
|
79
|
+
// unsupported protocol-version header this way (a 404 with a JSON-RPC body
|
|
80
|
+
// that the client SDK falls back on to negotiate a version both sides
|
|
81
|
+
// know). Not a fault: send its response and log one warning line, no
|
|
82
|
+
// structured error log and no Sentry capture. Turning it into a 500 would
|
|
83
|
+
// hide the status the client acts on.
|
|
84
|
+
if (error instanceof HTTPException) {
|
|
85
|
+
logger.warn(`${error.status} answered by the route: ${c.req.method} ${c.req.path}`);
|
|
86
|
+
return error.getResponse();
|
|
87
|
+
}
|
|
38
88
|
const context = c.get('lowdefyContext');
|
|
39
89
|
if (context) {
|
|
90
|
+
// context.handleError already captures to Sentry (createHandleError ->
|
|
91
|
+
// captureSentryError), so capturing here too would report the fault twice.
|
|
40
92
|
await context.handleError(error);
|
|
41
93
|
} else {
|
|
42
94
|
logger.error(error);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
95
|
+
if (sentrySdk.current) {
|
|
96
|
+
sentrySdk.current.captureException(error);
|
|
97
|
+
}
|
|
46
98
|
}
|
|
47
99
|
if (path.startsWith('/api/')) {
|
|
48
100
|
return c.json(redactErrorResponse(context, error), 500);
|