@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
|
@@ -0,0 +1,41 @@
|
|
|
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 stampBuildId from './stampBuildId.js';
|
|
20
|
+
|
|
21
|
+
function createApp() {
|
|
22
|
+
const app = new Hono();
|
|
23
|
+
app.use('/api/auth/*', stampBuildId({ buildId: 'build-2' }));
|
|
24
|
+
app.get('/api/auth/ok', (c) => c.json({ ok: true }));
|
|
25
|
+
app.get('/api/auth/raw', () => new Response('not found', { status: 404 }));
|
|
26
|
+
app.get('/api/auth/throws', () => {
|
|
27
|
+
throw new Error('Auth failed.');
|
|
28
|
+
});
|
|
29
|
+
app.onError((error, c) => c.json({ message: error.message }, 500));
|
|
30
|
+
return app;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test.each([
|
|
34
|
+
['/api/auth/ok', 200],
|
|
35
|
+
['/api/auth/raw', 404],
|
|
36
|
+
['/api/auth/throws', 500],
|
|
37
|
+
])('stampBuildId sets x-lowdefy-build on %s (%i)', async (path, status) => {
|
|
38
|
+
const res = await createApp().request(path);
|
|
39
|
+
expect(res.status).toBe(status);
|
|
40
|
+
expect(res.headers.get('x-lowdefy-build')).toBe('build-2');
|
|
41
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
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 { callerStamp } from '@lowdefy/helpers';
|
|
18
|
+
|
|
19
|
+
// Stamps page, request and endpoint responses with the caller the server
|
|
20
|
+
// answered as, in full (see callerStamp): user and organization, session, roles
|
|
21
|
+
// there and every organization the person belongs to. The client compares it
|
|
22
|
+
// with the caller it renders and reloads when the person, organization or
|
|
23
|
+
// roles differ, so a tab never shows one caller's data or access after the
|
|
24
|
+
// session moved on. Registered after apiContext, so the resolved caller is
|
|
25
|
+
// always on the context here; set after next() so error responses carry it
|
|
26
|
+
// too.
|
|
27
|
+
function stampCaller() {
|
|
28
|
+
return async function stampCallerMiddleware(c, next) {
|
|
29
|
+
await next();
|
|
30
|
+
const context = c.get('lowdefyContext');
|
|
31
|
+
c.header(
|
|
32
|
+
'x-lowdefy-caller',
|
|
33
|
+
callerStamp({ user: context.user, authSession: context.authSession })
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default stampCaller;
|
|
@@ -0,0 +1,64 @@
|
|
|
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 stampCaller from './stampCaller.js';
|
|
20
|
+
|
|
21
|
+
function createApp({ user, authSession }) {
|
|
22
|
+
const app = new Hono();
|
|
23
|
+
app.use('/api/*', async (c, next) => {
|
|
24
|
+
c.set('lowdefyContext', { user, authSession });
|
|
25
|
+
return next();
|
|
26
|
+
});
|
|
27
|
+
app.use('/api/request/*', stampCaller());
|
|
28
|
+
app.post('/api/request/page/ok', (c) => c.json({ ok: true }));
|
|
29
|
+
app.post('/api/request/page/throws', () => {
|
|
30
|
+
throw new Error('Request failed.');
|
|
31
|
+
});
|
|
32
|
+
app.onError((error, c) => c.json({ message: error.message }, 500));
|
|
33
|
+
return app;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test.each([
|
|
37
|
+
['/api/request/page/ok', 200],
|
|
38
|
+
['/api/request/page/throws', 500],
|
|
39
|
+
])('stampCaller sets x-lowdefy-caller on %s (%i)', async (path, status) => {
|
|
40
|
+
const app = createApp({
|
|
41
|
+
user: { id: 'user_1', organization_id: 'org_1', roles: ['editor', 'admin'] },
|
|
42
|
+
authSession: { id: 'session_1', memberOrganizationIds: ['org_2', 'org_1'] },
|
|
43
|
+
});
|
|
44
|
+
const res = await app.request(path, { method: 'POST' });
|
|
45
|
+
expect(res.status).toBe(status);
|
|
46
|
+
// The fixed vector callerStamp's own tests pin, so server and client agree.
|
|
47
|
+
expect(res.headers.get('x-lowdefy-caller')).toBe(
|
|
48
|
+
'82e92667feb5824d; session=3a1d7b6848ba4816; roles=228d50a09b1b1e2b; memberships=476954726056d64d'
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('stampCaller names the caller and roles alone for a caller with no session', async () => {
|
|
53
|
+
const app = createApp({
|
|
54
|
+
user: { id: 'user_1', organization_id: 'org_1', roles: ['editor', 'admin'] },
|
|
55
|
+
});
|
|
56
|
+
const res = await app.request('/api/request/page/ok', { method: 'POST' });
|
|
57
|
+
expect(res.headers.get('x-lowdefy-caller')).toBe('82e92667feb5824d; roles=228d50a09b1b1e2b');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('stampCaller sends an empty x-lowdefy-caller for an anonymous caller', async () => {
|
|
61
|
+
const app = createApp({ user: null });
|
|
62
|
+
const res = await app.request('/api/request/page/ok', { method: 'POST' });
|
|
63
|
+
expect(res.headers.get('x-lowdefy-caller')).toBe('');
|
|
64
|
+
});
|
package/src/routes/agent.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { callAgent } from '@lowdefy/api';
|
|
17
|
+
import { callAgent, parseRequestBody } from '@lowdefy/api';
|
|
18
18
|
import { translate, type } from '@lowdefy/helpers';
|
|
19
19
|
|
|
20
20
|
import getPathSegments from '../lib/getPathSegments.js';
|
|
@@ -23,7 +23,9 @@ async function agentHandler(c) {
|
|
|
23
23
|
const context = c.get('lowdefyContext');
|
|
24
24
|
const t = (key, values) => translate({ key, values, i18n: context.i18n });
|
|
25
25
|
if (c.req.method !== 'POST') {
|
|
26
|
-
|
|
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: t('agent.runtime.methodNotAllowed') }, 405);
|
|
27
29
|
}
|
|
28
30
|
const segments = getPathSegments(c, '/api/agent/');
|
|
29
31
|
if (segments.length < 2) {
|
|
@@ -33,22 +35,32 @@ async function agentHandler(c) {
|
|
|
33
35
|
const pageId = segments.slice(0, -1).join('/');
|
|
34
36
|
context.logger.info({ event: 'call_agent', agentId, pageId });
|
|
35
37
|
const { conversationId } = c.req.query();
|
|
36
|
-
const { messages, urlQuery, sharedState } =
|
|
38
|
+
const { messages, pathParams, urlQuery, sharedState, sharedStateReadOnly } = parseRequestBody({
|
|
39
|
+
text: await c.req.text(),
|
|
40
|
+
});
|
|
37
41
|
if (!Array.isArray(messages)) {
|
|
38
42
|
return c.json({ error: t('agent.runtime.messagesMustBeArray') }, 400);
|
|
39
43
|
}
|
|
40
44
|
if (urlQuery != null && (typeof urlQuery !== 'object' || Array.isArray(urlQuery))) {
|
|
41
45
|
return c.json({ error: t('agent.runtime.urlQueryMustBeObject') }, 400);
|
|
42
46
|
}
|
|
47
|
+
if (pathParams != null && !type.isObject(pathParams)) {
|
|
48
|
+
return c.json({ error: t('agent.runtime.pathParamsMustBeObject') }, 400);
|
|
49
|
+
}
|
|
43
50
|
if (sharedState != null && !type.isObject(sharedState)) {
|
|
44
51
|
return c.json({ error: t('agent.runtime.sharedStateMustBeObject') }, 400);
|
|
45
52
|
}
|
|
53
|
+
if (sharedStateReadOnly != null && !type.isBoolean(sharedStateReadOnly)) {
|
|
54
|
+
return c.json({ error: t('agent.runtime.sharedStateReadOnlyMustBeBoolean') }, 400);
|
|
55
|
+
}
|
|
46
56
|
const { response: webResponse } = await callAgent(context, {
|
|
47
57
|
agentId,
|
|
48
58
|
pageId,
|
|
49
59
|
messages,
|
|
50
60
|
conversationId: conversationId ?? undefined,
|
|
51
61
|
sharedState: sharedState ?? undefined,
|
|
62
|
+
sharedStateReadOnly: sharedStateReadOnly ?? undefined,
|
|
63
|
+
pathParams: pathParams ?? undefined,
|
|
52
64
|
urlQuery: urlQuery ?? undefined,
|
|
53
65
|
});
|
|
54
66
|
|
package/src/routes/apiPage.js
CHANGED
|
@@ -17,24 +17,66 @@
|
|
|
17
17
|
import { getPageConfig } from '@lowdefy/api';
|
|
18
18
|
|
|
19
19
|
import appMeta from '../../lib/build/appMeta.js';
|
|
20
|
-
import
|
|
20
|
+
import authJson from '../../lib/build/auth.js';
|
|
21
|
+
import lowdefyConfig from '../../lib/build/config.js';
|
|
22
|
+
import getRequestPath from '../lib/getRequestPath.js';
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
const basePath = lowdefyConfig.basePath ?? '';
|
|
25
|
+
|
|
26
|
+
// Page config as JSON for client-side SPA navigation, by the page's path (for a
|
|
27
|
+
// page without a path, its id). The first page load is served embedded in the
|
|
28
|
+
// HTML; subsequent navigations fetch from here.
|
|
24
29
|
async function apiPageHandler(c) {
|
|
25
30
|
const context = c.get('lowdefyContext');
|
|
26
|
-
const
|
|
31
|
+
const { path, matchedPath } = getRequestPath({ c, basePath, prefix: '/api/page/' });
|
|
32
|
+
// The callbackUrl of the sign-in and enrolment redirects: the requested page,
|
|
33
|
+
// query included.
|
|
34
|
+
const callbackUrl = `${basePath}/${path}${new URL(c.req.url).search}`;
|
|
27
35
|
// The client forwards its current query string on the fetch so Dynamic block
|
|
28
36
|
// resolution sees the same urlQuery as an initial HTML load.
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
const result = await getPageConfig(context, { path, urlQuery: c.req.query() });
|
|
38
|
+
const { pageId, pathParams } = result;
|
|
39
|
+
if (result.status === 'unauthenticated') {
|
|
40
|
+
// The client follows this redirect with a full page load, so the login
|
|
41
|
+
// page can return to the requested page after sign-in.
|
|
42
|
+
context.logger.info({ event: 'api_page_unauthenticated', pageId, path });
|
|
43
|
+
return c.json(
|
|
44
|
+
{
|
|
45
|
+
redirect: `${basePath}${authJson.authPages.signIn}?callbackUrl=${encodeURIComponent(
|
|
46
|
+
callbackUrl
|
|
47
|
+
)}`,
|
|
48
|
+
},
|
|
49
|
+
401
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (result.status === 'enrol_required') {
|
|
53
|
+
// 403, not the 401 the signed-out branch above uses: a 401 is the client's
|
|
54
|
+
// dead-session signal and would bounce the user to sign-in, which is the loop
|
|
55
|
+
// the enrolment gate exists to avoid.
|
|
56
|
+
context.logger.info({ event: 'api_page_enrol_required', pageId, path });
|
|
57
|
+
return c.json(
|
|
58
|
+
{
|
|
59
|
+
redirect: `${basePath}${authJson.authPages.twoFactorEnrol}?callbackUrl=${encodeURIComponent(
|
|
60
|
+
callbackUrl
|
|
61
|
+
)}`,
|
|
62
|
+
},
|
|
63
|
+
403
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (result.status !== 'ok') {
|
|
67
|
+
context.logger.info({ event: 'api_page_not_found', pageId, path });
|
|
32
68
|
return c.json({ pageConfig: null }, 404);
|
|
33
69
|
}
|
|
34
|
-
context.logger.info({ event: 'api_page_view', pageId });
|
|
70
|
+
context.logger.info({ event: 'api_page_view', pageId, path });
|
|
35
71
|
// buildId lets a tab that loaded its bundle from an earlier deploy notice
|
|
36
72
|
// that this config comes from a newer build and reload (client/Page.jsx).
|
|
37
|
-
return c.json({
|
|
73
|
+
return c.json({
|
|
74
|
+
buildId: appMeta.buildId,
|
|
75
|
+
pageId,
|
|
76
|
+
pathParams,
|
|
77
|
+
matchedPath,
|
|
78
|
+
pageConfig: result.pageConfig,
|
|
79
|
+
});
|
|
38
80
|
}
|
|
39
81
|
|
|
40
82
|
export default apiPageHandler;
|
|
@@ -17,22 +17,46 @@
|
|
|
17
17
|
import { Hono } from 'hono';
|
|
18
18
|
import { jest } from '@jest/globals';
|
|
19
19
|
|
|
20
|
-
const mockGetPageConfig = jest.fn();
|
|
21
|
-
jest.unstable_mockModule('@lowdefy/api', () => ({
|
|
22
|
-
getPageConfig: mockGetPageConfig,
|
|
23
|
-
}));
|
|
24
|
-
|
|
25
20
|
jest.unstable_mockModule('../../lib/build/appMeta.js', () => ({
|
|
26
21
|
default: { buildId: 'build-abc' },
|
|
27
22
|
}));
|
|
28
23
|
|
|
24
|
+
jest.unstable_mockModule('../../lib/build/auth.js', () => ({
|
|
25
|
+
default: { authPages: { signIn: '/auth/login', twoFactorEnrol: '/two-factor-enrol' } },
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
jest.unstable_mockModule('../../lib/build/config.js', () => ({
|
|
29
|
+
default: { basePath: '' },
|
|
30
|
+
}));
|
|
31
|
+
|
|
29
32
|
const { default: apiPageHandler } = await import('./apiPage.js');
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
const routes = [
|
|
35
|
+
{ pageId: 'home', path: 'home' },
|
|
36
|
+
{ pageId: 'ticket', path: '{space}/tickets/{ticket_id}' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const pages = {
|
|
40
|
+
home: { id: 'home' },
|
|
41
|
+
ticket: { id: 'ticket' },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function createApp({
|
|
45
|
+
outcome = 'allow',
|
|
46
|
+
pagesProtectedByDefault = false,
|
|
47
|
+
user = { id: 'u1' },
|
|
48
|
+
} = {}) {
|
|
32
49
|
const app = new Hono();
|
|
33
50
|
app.use('*', async (c, next) => {
|
|
34
51
|
c.set('lowdefyContext', {
|
|
52
|
+
authEnforcement: { pagesProtectedByDefault },
|
|
53
|
+
authorizeOutcome: () => outcome,
|
|
35
54
|
logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn() },
|
|
55
|
+
readConfigFile: async (file) => {
|
|
56
|
+
if (file === 'routes.json') return routes;
|
|
57
|
+
return pages[file.slice('pages/'.length, -'.json'.length)] ?? null;
|
|
58
|
+
},
|
|
59
|
+
user,
|
|
36
60
|
});
|
|
37
61
|
await next();
|
|
38
62
|
});
|
|
@@ -40,20 +64,80 @@ function createApp() {
|
|
|
40
64
|
return app;
|
|
41
65
|
}
|
|
42
66
|
|
|
43
|
-
|
|
44
|
-
|
|
67
|
+
test('apiPageHandler returns pageId, pathParams and matchedPath for a patterned page', async () => {
|
|
68
|
+
const res = await createApp().request('/api/page/support/tickets/1234');
|
|
69
|
+
expect(res.status).toEqual(200);
|
|
70
|
+
expect(await res.json()).toEqual({
|
|
71
|
+
buildId: 'build-abc',
|
|
72
|
+
pageId: 'ticket',
|
|
73
|
+
pathParams: { space: 'support', ticket_id: '1234' },
|
|
74
|
+
matchedPath: 'support/tickets/1234',
|
|
75
|
+
pageConfig: { id: 'ticket' },
|
|
76
|
+
});
|
|
45
77
|
});
|
|
46
78
|
|
|
47
|
-
test('apiPageHandler
|
|
48
|
-
|
|
49
|
-
const
|
|
79
|
+
test('apiPageHandler decodes an encoded "/" in a value once and keeps matchedPath encoded', async () => {
|
|
80
|
+
const res = await createApp().request('/api/page/support/tickets/a%2Fb/');
|
|
81
|
+
const body = await res.json();
|
|
82
|
+
expect(body.pathParams).toEqual({ space: 'support', ticket_id: 'a/b' });
|
|
83
|
+
expect(body.matchedPath).toEqual('support/tickets/a%2Fb');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('apiPageHandler serves a page without path at its id', async () => {
|
|
87
|
+
const res = await createApp().request('/api/page/home');
|
|
50
88
|
expect(res.status).toEqual(200);
|
|
51
|
-
expect(await res.json()).toEqual({
|
|
89
|
+
expect(await res.json()).toEqual({
|
|
90
|
+
buildId: 'build-abc',
|
|
91
|
+
pageId: 'home',
|
|
92
|
+
pathParams: {},
|
|
93
|
+
matchedPath: 'home',
|
|
94
|
+
pageConfig: { id: 'home' },
|
|
95
|
+
});
|
|
52
96
|
});
|
|
53
97
|
|
|
54
|
-
test('apiPageHandler returns 404 when
|
|
55
|
-
|
|
56
|
-
const res = await createApp().request('/api/page/missing');
|
|
98
|
+
test('apiPageHandler returns 404 when no page matches the path', async () => {
|
|
99
|
+
const res = await createApp().request('/api/page/support/tickets');
|
|
57
100
|
expect(res.status).toEqual(404);
|
|
58
101
|
expect(await res.json()).toEqual({ pageConfig: null });
|
|
59
102
|
});
|
|
103
|
+
|
|
104
|
+
test('apiPageHandler returns a 401 sign-in redirect with the path and query when no page matches under pagesProtectedByDefault', async () => {
|
|
105
|
+
const res = await createApp({ pagesProtectedByDefault: true, user: null }).request(
|
|
106
|
+
'/api/page/support/tickets?tab=2'
|
|
107
|
+
);
|
|
108
|
+
expect(res.status).toEqual(401);
|
|
109
|
+
expect(await res.json()).toEqual({
|
|
110
|
+
redirect: `/auth/login?callbackUrl=${encodeURIComponent('/support/tickets?tab=2')}`,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('apiPageHandler unauthenticated redirect carries the request path and query on callbackUrl', async () => {
|
|
115
|
+
const res = await createApp({ outcome: 'deny', user: null }).request(
|
|
116
|
+
'/api/page/support/tickets/1234?tab=2'
|
|
117
|
+
);
|
|
118
|
+
expect(res.status).toEqual(401);
|
|
119
|
+
expect(await res.json()).toEqual({
|
|
120
|
+
redirect: `/auth/login?callbackUrl=${encodeURIComponent('/support/tickets/1234?tab=2')}`,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('apiPageHandler enrol_required redirect carries the request query on callbackUrl', async () => {
|
|
125
|
+
const res = await createApp({ outcome: 'enrol_required' }).request('/api/page/home?id=123&tab=2');
|
|
126
|
+
expect(res.status).toEqual(403);
|
|
127
|
+
expect(await res.json()).toEqual({
|
|
128
|
+
redirect: `/two-factor-enrol?callbackUrl=${encodeURIComponent('/home?id=123&tab=2')}`,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('apiPageHandler enrol_required redirect is path-only when the request has no query', async () => {
|
|
133
|
+
const res = await createApp({ outcome: 'enrol_required' }).request('/api/page/home');
|
|
134
|
+
expect(res.status).toEqual(403);
|
|
135
|
+
expect(await res.json()).toEqual({
|
|
136
|
+
redirect: `/two-factor-enrol?callbackUrl=${encodeURIComponent('/home')}`,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('apiPageHandler returns 404 for a signed-in caller without access', async () => {
|
|
141
|
+
const res = await createApp({ outcome: 'deny' }).request('/api/page/home');
|
|
142
|
+
expect(res.status).toEqual(404);
|
|
143
|
+
});
|
package/src/routes/auth.js
CHANGED
|
@@ -14,24 +14,30 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import { handleAuthRequest } from '@lowdefy/api';
|
|
18
18
|
|
|
19
19
|
import authJson from '../../lib/build/auth.js';
|
|
20
|
+
import getAuth from '../../lib/server/auth/getAuth.js';
|
|
20
21
|
|
|
21
|
-
//
|
|
22
|
-
// GET handlers, so
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
// Mounts BetterAuth's Web Standard handler on /api/auth/*. Hono routes HEAD
|
|
23
|
+
// requests through GET handlers, so HEAD short-circuits before the handler -
|
|
24
|
+
// corporate email link-checkers pre-fetch magic-link and verification URLs
|
|
25
|
+
// with HEAD, and letting those reach the handler would consume the one-time
|
|
26
|
+
// token before the user clicks. The client address apiContext resolved is
|
|
27
|
+
// handed to BetterAuth for its rate limits and session records.
|
|
28
|
+
function authMiddleware({ logger }) {
|
|
29
|
+
return async function auth(c) {
|
|
28
30
|
if (authJson.configured !== true) {
|
|
29
31
|
return c.json({ message: 'Auth not configured' }, 404);
|
|
30
32
|
}
|
|
31
33
|
if (c.req.method === 'HEAD') {
|
|
32
34
|
return c.body(null, 200);
|
|
33
35
|
}
|
|
34
|
-
return
|
|
36
|
+
return handleAuthRequest({
|
|
37
|
+
auth: getAuth({ logger }),
|
|
38
|
+
request: c.req.raw,
|
|
39
|
+
clientAddress: c.get('lowdefyContext').clientAddress,
|
|
40
|
+
});
|
|
35
41
|
};
|
|
36
42
|
}
|
|
37
43
|
|
|
@@ -0,0 +1,85 @@
|
|
|
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 { serve } from '@hono/node-server';
|
|
18
|
+
import { Hono } from 'hono';
|
|
19
|
+
import { jest } from '@jest/globals';
|
|
20
|
+
|
|
21
|
+
// The auth engine is replaced by one that echoes what reached it, so the route
|
|
22
|
+
// is exercised on a real @hono/node-server connection: the request the engine
|
|
23
|
+
// receives is rebuilt from @hono/node-server's own request object.
|
|
24
|
+
const echoAuth = {
|
|
25
|
+
handler: async (request) =>
|
|
26
|
+
Response.json({
|
|
27
|
+
method: request.method,
|
|
28
|
+
clientAddress: request.headers.get('x-lowdefy-client-address'),
|
|
29
|
+
body: await request.text(),
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
jest.unstable_mockModule('../../lib/build/auth.js', () => ({ default: { configured: true } }));
|
|
33
|
+
jest.unstable_mockModule('../../lib/build/config.js', () => ({ default: {} }));
|
|
34
|
+
jest.unstable_mockModule('../../lib/server/auth/getAuth.js', () => ({
|
|
35
|
+
default: jest.fn(() => echoAuth),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const { default: authMiddleware } = await import('./auth.js');
|
|
39
|
+
const { default: getClientAddress } = await import('../../lib/server/getClientAddress.js');
|
|
40
|
+
|
|
41
|
+
const logger = { debug: jest.fn(), error: jest.fn(), info: jest.fn(), warn: jest.fn() };
|
|
42
|
+
|
|
43
|
+
let server;
|
|
44
|
+
let baseUrl;
|
|
45
|
+
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
const app = new Hono();
|
|
48
|
+
app.use('*', async (c, next) => {
|
|
49
|
+
c.set('lowdefyContext', { clientAddress: getClientAddress({ c, logger }) });
|
|
50
|
+
await next();
|
|
51
|
+
});
|
|
52
|
+
app.all('/api/auth/*', authMiddleware({ logger }));
|
|
53
|
+
await new Promise((resolve) => {
|
|
54
|
+
server = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' }, (info) => {
|
|
55
|
+
baseUrl = `http://127.0.0.1:${info.port}`;
|
|
56
|
+
resolve();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterAll(async () => {
|
|
62
|
+
await new Promise((resolve) => server.close(resolve));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('the auth route hands the engine the connection address, not the forwarded or client-sent one', async () => {
|
|
66
|
+
const response = await fetch(`${baseUrl}/api/auth/sign-in/email`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
'content-type': 'application/json',
|
|
70
|
+
'x-forwarded-for': '203.0.113.9',
|
|
71
|
+
'x-lowdefy-client-address': '203.0.113.10',
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify({ email: 'user@example.com' }),
|
|
74
|
+
});
|
|
75
|
+
expect(await response.json()).toEqual({
|
|
76
|
+
method: 'POST',
|
|
77
|
+
clientAddress: '127.0.0.1',
|
|
78
|
+
body: JSON.stringify({ email: 'user@example.com' }),
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the auth route hands the engine a GET without a body', async () => {
|
|
83
|
+
const response = await fetch(`${baseUrl}/api/auth/get-session`);
|
|
84
|
+
expect(await response.json()).toEqual({ method: 'GET', clientAddress: '127.0.0.1', body: '' });
|
|
85
|
+
});
|
|
@@ -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
|
-
|
|
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
|
|
28
|
-
if (
|
|
29
|
-
return
|
|
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.
|
|
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;
|
package/src/routes/cron.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
@@ -26,7 +26,9 @@ import getPathSegments from '../lib/getPathSegments.js';
|
|
|
26
26
|
// fails closed).
|
|
27
27
|
async function cronForwardHandler(c) {
|
|
28
28
|
if (c.req.method !== 'GET') {
|
|
29
|
-
|
|
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
|
|