@lowdefy/server 6.1.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/client/App.jsx +41 -21
  2. package/client/Page.jsx +90 -26
  3. package/client/clearReloadedForTypes.js +29 -0
  4. package/client/clientBuildImports.test.mjs +6 -7
  5. package/client/getShownPage.js +28 -0
  6. package/client/getShownPage.test.mjs +52 -0
  7. package/client/loadAllIcons.js +45 -0
  8. package/client/loadPageTypes.js +44 -0
  9. package/client/main.jsx +22 -1
  10. package/client/reloadedForTypesKey.js +21 -0
  11. package/client/shouldReloadForTypes.js +38 -0
  12. package/client/shouldReloadForTypes.test.mjs +63 -0
  13. package/client/types.js +28 -0
  14. package/lib/build/security.js +26 -0
  15. package/lib/client/auth/Auth.jsx +11 -6
  16. package/lib/client/auth/AuthConfigured.jsx +222 -36
  17. package/lib/client/auth/AuthNotConfigured.js +44 -4
  18. package/lib/client/auth/createFetchCheckingBuild.js +45 -0
  19. package/lib/client/auth/createFetchCheckingBuild.test.mjs +100 -0
  20. package/lib/client/bundleBuildId.js +23 -0
  21. package/lib/client/createLogUsage.js +46 -25
  22. package/lib/client/createLogUsage.test.mjs +90 -0
  23. package/lib/client/getCspNonce.js +25 -0
  24. package/lib/server/auth/createSystemContext.js +70 -0
  25. package/lib/server/auth/{getAuthConfig.js → getAuth.js} +15 -9
  26. package/lib/server/auth/getStrategies.js +40 -0
  27. package/lib/server/getClientAddress.js +56 -0
  28. package/lib/server/getClientAddress.test.mjs +108 -0
  29. package/lib/server/log/createHandleError.js +7 -0
  30. package/lib/server/log/createHandleError.test.mjs +17 -0
  31. package/lib/server/log/createLogger.test.mjs +15 -0
  32. package/lib/server/log/logRequest.js +5 -3
  33. package/lib/server/log/projectErrorForLog.js +6 -0
  34. package/lib/server/log/serializeErrorForLog.test.mjs +13 -1
  35. package/lib/server/sentry/captureSentryError.js +17 -3
  36. package/lib/server/sentry/captureSentryError.test.mjs +37 -13
  37. package/lib/server/sentry/initSentry.js +39 -18
  38. package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
  39. package/lib/server/sentry/initSentry.test.mjs +132 -4
  40. package/lib/server/sentry/scrubEvent.js +12 -1
  41. package/lib/server/sentry/sentrySdk.js +23 -0
  42. package/lib/server/sentry/setSentryUser.js +3 -3
  43. package/lowdefy/build.mjs +1 -0
  44. package/lowdefy/build.test.mjs +68 -0
  45. package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
  46. package/package.json +42 -36
  47. package/package.original.json +42 -36
  48. package/src/app.js +74 -17
  49. package/src/app.test.mjs +118 -0
  50. package/src/html/collectChunkClosure.js +31 -0
  51. package/src/html/collectLazyImports.js +41 -0
  52. package/src/html/collectPageTypesAssets.js +57 -0
  53. package/src/html/collectPageTypesAssets.test.mjs +130 -0
  54. package/src/html/getAssets.js +20 -1
  55. package/src/html/getHomePath.js +29 -0
  56. package/src/html/getPageAssets.js +32 -0
  57. package/src/html/getPageAssets.test.mjs +47 -0
  58. package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
  59. package/src/html/renderPage.js +80 -19
  60. package/src/html/renderPage.test.mjs +189 -0
  61. package/src/html/template.js +50 -15
  62. package/src/html/template.test.mjs +126 -0
  63. package/src/index.js +28 -29
  64. package/src/initServer.js +61 -0
  65. package/src/initServer.test.mjs +116 -0
  66. package/src/lib/getRequestPath.js +29 -0
  67. package/src/middleware/apiContext.js +65 -13
  68. package/src/middleware/apiContext.test.mjs +66 -6
  69. package/src/middleware/createSameOriginGuard.js +36 -0
  70. package/src/middleware/createSameOriginGuard.test.mjs +102 -0
  71. package/src/middleware/errorHandler.js +56 -4
  72. package/src/middleware/errorHandler.test.mjs +440 -0
  73. package/{lib/server/auth/session.js → src/middleware/noStore.js} +9 -13
  74. package/src/middleware/refuseOtherBuild.js +49 -0
  75. package/src/middleware/refuseOtherBuild.test.mjs +63 -0
  76. package/src/middleware/requestTimeout.js +38 -0
  77. package/src/middleware/sentry.js +13 -5
  78. package/src/middleware/stampBuildId.js +30 -0
  79. package/src/middleware/stampBuildId.test.mjs +41 -0
  80. package/src/middleware/stampCaller.js +38 -0
  81. package/src/middleware/stampCaller.test.mjs +64 -0
  82. package/src/routes/agent.js +15 -3
  83. package/src/routes/apiPage.js +51 -9
  84. package/src/routes/apiPage.test.mjs +99 -15
  85. package/src/routes/auth.js +15 -9
  86. package/src/routes/auth.test.mjs +85 -0
  87. package/src/routes/clientError.js +11 -13
  88. package/src/routes/cron.js +3 -1
  89. package/src/routes/cronForward.js +3 -1
  90. package/src/routes/detached.js +22 -9
  91. package/src/routes/endpoints.js +27 -26
  92. package/src/routes/endpointsTextAnswer.test.mjs +77 -0
  93. package/src/routes/mcp.js +82 -5
  94. package/src/routes/mcp.test.mjs +215 -0
  95. package/src/routes/mcpMemberToken.test.mjs +125 -0
  96. package/src/routes/mcpProtectedResourceMetadata.js +34 -0
  97. package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
  98. package/src/routes/mountOauthDiscovery.js +44 -0
  99. package/src/routes/mountOauthDiscovery.test.mjs +79 -0
  100. package/src/routes/mountPageRoutes.js +29 -0
  101. package/src/routes/mountPageRoutes.test.mjs +56 -0
  102. package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
  103. package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
  104. package/src/routes/request.js +5 -3
  105. package/src/routes/requestBody.test.mjs +146 -0
  106. package/src/routes/usage.js +6 -2
  107. package/src/routes/user.js +27 -0
  108. package/src/routes/websocket.js +26 -3
  109. package/src/routes/websocket.test.mjs +106 -0
  110. package/src/routes/wellKnownFallback.js +30 -0
  111. package/vite.config.js +55 -2
  112. package/client/shouldReloadForBuild.js +0 -48
  113. package/client/shouldReloadForBuild.test.mjs +0 -75
@@ -0,0 +1,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
+ });
@@ -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
- throw new Error(t('agent.runtime.methodNotAllowed'));
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 } = await c.req.json();
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
 
@@ -17,24 +17,66 @@
17
17
  import { getPageConfig } from '@lowdefy/api';
18
18
 
19
19
  import appMeta from '../../lib/build/appMeta.js';
20
- import getPathSegments from '../lib/getPathSegments.js';
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
- // Page config as JSON for client-side SPA navigation. The first page load is
23
- // served embedded in the HTML; subsequent navigations fetch from here.
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 pageId = getPathSegments(c, '/api/page/').join('/');
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 pageConfig = await getPageConfig(context, { pageId, urlQuery: c.req.query() });
30
- if (!pageConfig) {
31
- context.logger.info({ event: 'api_page_not_found', pageId });
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({ buildId: appMeta.buildId, pageConfig });
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
- function createApp() {
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
- afterEach(() => {
44
- mockGetPageConfig.mockReset();
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 returns the pageConfig stamped with the build id', async () => {
48
- mockGetPageConfig.mockResolvedValue({ id: 'invoices' });
49
- const res = await createApp().request('/api/page/invoices');
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({ buildId: 'build-abc', pageConfig: { id: 'invoices' } });
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 the page is not found', async () => {
55
- mockGetPageConfig.mockResolvedValue(null);
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
+ });
@@ -14,24 +14,30 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { authHandler } from '@hono/auth-js';
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
- // Replaces pages/api/auth/[...nextauth].js. Hono routes HEAD requests through
22
- // GET handlers, so the corporate-email pre-check branch must live inside the
23
- // middleware, before delegating to the Auth.js handler. See:
24
- // https://next-auth.js.org/tutorials/avoid-corporate-link-checking-email-provider
25
- function authMiddleware() {
26
- const handler = authJson.configured === true ? authHandler() : null;
27
- return async function auth(c, next) {
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 handler(c, next);
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
- throw new Error('Only POST requests are supported.');
26
+ // A wrong-method request is client-caused: answer 405 rather than raising a
27
+ // fault that would be logged at error level and answered with a 500.
28
+ return c.json({ error: 'Method not allowed.' }, 405);
24
29
  }
25
30
  const context = c.get('lowdefyContext');
26
31
 
27
- const origin = c.req.header('origin');
28
- if (!origin) {
29
- return c.json({ error: 'Forbidden' }, 403);
30
- }
31
- try {
32
- if (new URL(origin).host !== c.req.header('host')) {
33
- return c.json({ error: 'Forbidden' }, 403);
34
- }
35
- } catch {
36
- return c.json({ error: 'Forbidden' }, 403);
32
+ const forbidden = guardSameOrigin(c);
33
+ if (forbidden) {
34
+ return forbidden;
37
35
  }
38
36
 
39
- const body = await c.req.json();
37
+ const body = parseRequestBody({ text: await c.req.text() });
40
38
  // Strip received from payload — prod doesn't need it for schema validation
41
39
  if (body?.['~e']) {
42
40
  delete body['~e'].received;
@@ -23,7 +23,9 @@ import getPathSegments from '../lib/getPathSegments.js';
23
23
  // cron expression in the `x-vercel-cron-schedule` header. Fails closed if CRON_SECRET is unset.
24
24
  async function cronHandler(c) {
25
25
  if (c.req.method !== 'GET') {
26
- throw new Error('Only GET requests are supported.');
26
+ // A wrong-method request is client-caused: answer 405 rather than raising a
27
+ // fault that would be logged at error level and answered with a 500.
28
+ return c.json({ error: 'Method not allowed.' }, 405);
27
29
  }
28
30
  const context = c.get('lowdefyContext');
29
31
 
@@ -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
- throw new Error('Only GET requests are supported.');
29
+ // A wrong-method request is client-caused: answer 405 rather than raising a
30
+ // fault that would be logged at error level and answered with a 500.
31
+ return c.json({ error: 'Method not allowed.' }, 405);
30
32
  }
31
33
  const context = c.get('lowdefyContext');
32
34