@lowdefy/server 6.0.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/client/App.jsx +41 -21
  2. package/client/Page.jsx +101 -24
  3. package/client/clearReloadedForTypes.js +29 -0
  4. package/client/clientBuildImports.test.mjs +60 -0
  5. package/client/getShownPage.js +28 -0
  6. package/client/getShownPage.test.mjs +52 -0
  7. package/client/loadAllIcons.js +45 -0
  8. package/client/loadPageTypes.js +44 -0
  9. package/client/main.jsx +22 -1
  10. package/client/reloadedForTypesKey.js +21 -0
  11. package/client/shouldReloadForTypes.js +38 -0
  12. package/client/shouldReloadForTypes.test.mjs +63 -0
  13. package/client/types.js +28 -0
  14. package/lib/build/security.js +26 -0
  15. package/lib/client/auth/Auth.jsx +11 -6
  16. package/lib/client/auth/AuthConfigured.jsx +224 -39
  17. package/lib/client/auth/AuthNotConfigured.js +44 -4
  18. package/lib/client/auth/createFetchCheckingBuild.js +45 -0
  19. package/lib/client/auth/createFetchCheckingBuild.test.mjs +100 -0
  20. package/lib/client/bundleBuildId.js +23 -0
  21. package/lib/client/createLogUsage.js +46 -25
  22. package/lib/client/createLogUsage.test.mjs +90 -0
  23. package/lib/client/getCspNonce.js +25 -0
  24. package/lib/server/auth/createSystemContext.js +70 -0
  25. package/lib/server/auth/{getAuthConfig.js → getAuth.js} +15 -7
  26. package/lib/server/auth/getStrategies.js +40 -0
  27. package/lib/server/getClientAddress.js +56 -0
  28. package/lib/server/getClientAddress.test.mjs +108 -0
  29. package/lib/server/log/createHandleError.js +16 -4
  30. package/lib/server/log/createHandleError.test.mjs +102 -0
  31. package/lib/server/log/createLogger.js +10 -0
  32. package/lib/server/log/createLogger.test.mjs +117 -0
  33. package/lib/server/log/logRequest.js +12 -4
  34. package/lib/server/log/maskCredentialKeys.js +37 -0
  35. package/lib/server/log/projectErrorForLog.js +61 -0
  36. package/lib/server/log/redactUrlQuery.js +75 -0
  37. package/lib/server/log/redactUrlQuery.test.mjs +73 -0
  38. package/lib/server/{auth/session.js → log/serializeErrorForLog.js} +5 -11
  39. package/lib/server/log/serializeErrorForLog.test.mjs +337 -0
  40. package/lib/server/scrubSecrets.js +21 -0
  41. package/lib/server/sentry/captureSentryError.js +29 -3
  42. package/lib/server/sentry/captureSentryError.test.mjs +128 -0
  43. package/lib/server/sentry/initSentry.js +46 -14
  44. package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
  45. package/lib/server/sentry/initSentry.test.mjs +365 -0
  46. package/lib/server/sentry/scrubEvent.js +40 -0
  47. package/lib/server/sentry/scrubEvent.test.mjs +88 -0
  48. package/lib/server/sentry/sentrySdk.js +23 -0
  49. package/lib/server/sentry/setSentryUser.js +3 -3
  50. package/lowdefy/build.mjs +1 -0
  51. package/lowdefy/build.test.mjs +68 -0
  52. package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
  53. package/package.json +47 -42
  54. package/package.original.json +47 -42
  55. package/src/app.js +74 -17
  56. package/src/app.test.mjs +118 -0
  57. package/src/html/collectChunkClosure.js +31 -0
  58. package/src/html/collectLazyImports.js +41 -0
  59. package/src/html/collectPageTypesAssets.js +57 -0
  60. package/src/html/collectPageTypesAssets.test.mjs +130 -0
  61. package/src/html/getAssets.js +20 -1
  62. package/src/html/getHomePath.js +29 -0
  63. package/src/html/getPageAssets.js +32 -0
  64. package/src/html/getPageAssets.test.mjs +47 -0
  65. package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
  66. package/src/html/renderPage.js +80 -19
  67. package/src/html/renderPage.test.mjs +189 -0
  68. package/src/html/template.js +50 -15
  69. package/src/html/template.test.mjs +126 -0
  70. package/src/index.js +28 -19
  71. package/src/initServer.js +61 -0
  72. package/src/initServer.test.mjs +116 -0
  73. package/src/lib/getRequestPath.js +29 -0
  74. package/src/middleware/apiContext.js +68 -13
  75. package/src/middleware/apiContext.test.mjs +178 -0
  76. package/src/middleware/createSameOriginGuard.js +36 -0
  77. package/src/middleware/createSameOriginGuard.test.mjs +102 -0
  78. package/src/middleware/errorHandler.js +56 -4
  79. package/src/middleware/errorHandler.test.mjs +440 -0
  80. package/src/middleware/noStore.js +27 -0
  81. package/src/middleware/refuseOtherBuild.js +49 -0
  82. package/src/middleware/refuseOtherBuild.test.mjs +63 -0
  83. package/src/middleware/requestTimeout.js +38 -0
  84. package/src/middleware/sentry.js +13 -5
  85. package/src/middleware/stampBuildId.js +30 -0
  86. package/src/middleware/stampBuildId.test.mjs +41 -0
  87. package/src/middleware/stampCaller.js +38 -0
  88. package/src/middleware/stampCaller.test.mjs +64 -0
  89. package/src/routes/agent.js +15 -3
  90. package/src/routes/apiPage.js +54 -9
  91. package/src/routes/apiPage.test.mjs +143 -0
  92. package/src/routes/auth.js +15 -9
  93. package/src/routes/auth.test.mjs +85 -0
  94. package/src/routes/clientError.js +11 -13
  95. package/src/routes/cron.js +3 -1
  96. package/src/routes/cronForward.js +4 -2
  97. package/src/routes/detached.js +22 -9
  98. package/src/routes/endpoints.js +27 -26
  99. package/src/routes/endpointsTextAnswer.test.mjs +77 -0
  100. package/src/routes/mcp.js +82 -5
  101. package/src/routes/mcp.test.mjs +215 -0
  102. package/src/routes/mcpMemberToken.test.mjs +125 -0
  103. package/src/routes/mcpProtectedResourceMetadata.js +34 -0
  104. package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
  105. package/src/routes/mountOauthDiscovery.js +44 -0
  106. package/src/routes/mountOauthDiscovery.test.mjs +79 -0
  107. package/src/routes/mountPageRoutes.js +29 -0
  108. package/src/routes/mountPageRoutes.test.mjs +56 -0
  109. package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
  110. package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
  111. package/src/routes/request.js +5 -3
  112. package/src/routes/requestBody.test.mjs +146 -0
  113. package/src/routes/usage.js +6 -2
  114. package/src/routes/user.js +27 -0
  115. package/src/routes/websocket.js +26 -3
  116. package/src/routes/websocket.test.mjs +106 -0
  117. package/src/routes/wellKnownFallback.js +30 -0
  118. package/vite.config.js +55 -2
  119. package/lib/server/apiWrapper.js +0 -87
@@ -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 { HTTPException } from 'hono/http-exception';
18
+ import { timeout } from 'hono/timeout';
19
+
20
+ // Caps how long a request may run, and cancels the work it leaves behind. The 504 is
21
+ // answered while the handler is still running; the TimeoutError on the request's
22
+ // timeout signal (which apiContext folds into context.signal) cancels the upstream
23
+ // calls the handler is waiting on, such as an AI provider call, instead of letting
24
+ // them run, and bill, after the response has gone.
25
+ function requestTimeout({ timeoutMs }) {
26
+ return function requestTimeoutMiddleware(c, next) {
27
+ const controller = new AbortController();
28
+ c.set('requestTimeoutSignal', controller.signal);
29
+ return timeout(timeoutMs, () => {
30
+ controller.abort(
31
+ new DOMException(`The request timeout of ${timeoutMs}ms was exceeded.`, 'TimeoutError')
32
+ );
33
+ return new HTTPException(504, { message: 'Gateway Timeout' });
34
+ })(c, next);
35
+ };
36
+ }
37
+
38
+ export default requestTimeout;
@@ -14,16 +14,24 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import * as Sentry from '@sentry/node';
17
+ import { bindCredentialScrub } from '@lowdefy/node-utils';
18
18
 
19
- // Replaces @sentry/nextjs route auto-instrumentation: every request becomes
20
- // an http.server transaction. Handler errors never propagate through
21
- // middleware in Hono — captureException happens in the app error handler.
19
+ import sentrySdk from '../../lib/server/sentry/sentrySdk.js';
20
+
21
+ // Every request becomes an http.server transaction when the server traces requests. Handler
22
+ // errors never propagate through middleware in Hono — captureException happens in the app error
23
+ // handler.
22
24
  function sentryMiddleware() {
23
25
  return async function sentry(c, next) {
24
- if (!process.env.SENTRY_DSN) {
26
+ const Sentry = sentrySdk.current;
27
+ if (!Sentry) {
25
28
  return next();
26
29
  }
30
+ // The request's transaction is sent once the response is written, after its credential scope
31
+ // has ended, so the scope's scrub rides on the request's isolation scope to scrubEvent.
32
+ Sentry.getIsolationScope().setSDKProcessingMetadata({
33
+ scrubRequestCredentials: bindCredentialScrub(),
34
+ });
27
35
  return Sentry.startSpan({ name: `${c.req.method} ${c.req.path}`, op: 'http.server' }, () =>
28
36
  next()
29
37
  );
@@ -0,0 +1,30 @@
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
+ // Stamps the server's build on auth responses. Auth calls are not refused on a
18
+ // mismatch (they are Lowdefy's routes, not app config, and a session refetch on
19
+ // window focus would reload away unsaved input); instead the client reloads
20
+ // when an auth call fails and this header shows a newer build answered
21
+ // (lib/client/auth/createFetchCheckingBuild.js). Set after next() so error responses
22
+ // and the raw responses BetterAuth returns carry it too.
23
+ function stampBuildId({ buildId }) {
24
+ return async function stampBuildIdMiddleware(c, next) {
25
+ await next();
26
+ c.header('x-lowdefy-build', buildId);
27
+ };
28
+ }
29
+
30
+ export default stampBuildId;
@@ -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
 
@@ -16,22 +16,67 @@
16
16
 
17
17
  import { getPageConfig } from '@lowdefy/api';
18
18
 
19
- import getPathSegments from '../lib/getPathSegments.js';
19
+ import appMeta from '../../lib/build/appMeta.js';
20
+ import authJson from '../../lib/build/auth.js';
21
+ import lowdefyConfig from '../../lib/build/config.js';
22
+ import getRequestPath from '../lib/getRequestPath.js';
20
23
 
21
- // Page config as JSON for client-side SPA navigation. The first page load is
22
- // 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.
23
29
  async function apiPageHandler(c) {
24
30
  const context = c.get('lowdefyContext');
25
- 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}`;
26
35
  // The client forwards its current query string on the fetch so Dynamic block
27
36
  // resolution sees the same urlQuery as an initial HTML load.
28
- const pageConfig = await getPageConfig(context, { pageId, urlQuery: c.req.query() });
29
- if (!pageConfig) {
30
- 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 });
31
68
  return c.json({ pageConfig: null }, 404);
32
69
  }
33
- context.logger.info({ event: 'api_page_view', pageId });
34
- return c.json({ pageConfig });
70
+ context.logger.info({ event: 'api_page_view', pageId, path });
71
+ // buildId lets a tab that loaded its bundle from an earlier deploy notice
72
+ // that this config comes from a newer build and reload (client/Page.jsx).
73
+ return c.json({
74
+ buildId: appMeta.buildId,
75
+ pageId,
76
+ pathParams,
77
+ matchedPath,
78
+ pageConfig: result.pageConfig,
79
+ });
35
80
  }
36
81
 
37
82
  export default apiPageHandler;
@@ -0,0 +1,143 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { Hono } from 'hono';
18
+ import { jest } from '@jest/globals';
19
+
20
+ jest.unstable_mockModule('../../lib/build/appMeta.js', () => ({
21
+ default: { buildId: 'build-abc' },
22
+ }));
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
+
32
+ const { default: apiPageHandler } = await import('./apiPage.js');
33
+
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
+ } = {}) {
49
+ const app = new Hono();
50
+ app.use('*', async (c, next) => {
51
+ c.set('lowdefyContext', {
52
+ authEnforcement: { pagesProtectedByDefault },
53
+ authorizeOutcome: () => outcome,
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,
60
+ });
61
+ await next();
62
+ });
63
+ app.get('/api/page/*', apiPageHandler);
64
+ return app;
65
+ }
66
+
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
+ });
77
+ });
78
+
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');
88
+ expect(res.status).toEqual(200);
89
+ expect(await res.json()).toEqual({
90
+ buildId: 'build-abc',
91
+ pageId: 'home',
92
+ pathParams: {},
93
+ matchedPath: 'home',
94
+ pageConfig: { id: 'home' },
95
+ });
96
+ });
97
+
98
+ test('apiPageHandler returns 404 when no page matches the path', async () => {
99
+ const res = await createApp().request('/api/page/support/tickets');
100
+ expect(res.status).toEqual(404);
101
+ expect(await res.json()).toEqual({ pageConfig: null });
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
+ });