@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,23 @@
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 appMeta from '../../build/appMeta.json';
18
+
19
+ // The build this client bundle was made from. The auth templates in lib/client/auth are kept
20
+ // byte-identical with server-dev's, so each server supplies the build id from outside them.
21
+ const bundleBuildId = appMeta.buildId;
22
+
23
+ export default bundleBuildId;
@@ -14,44 +14,65 @@
14
14
  limitations under the License.
15
15
  */
16
16
  import { v4 as uuid } from 'uuid';
17
+ import { createUrl } from '@lowdefy/client/adapters/url.js';
17
18
 
18
- // What if this fails?
19
19
  // What about public usage
20
20
 
21
- function createLogUsage({ usageDataRef }) {
21
+ // Browser storage can be disabled (a private window, blocked site data), and then every access
22
+ // throws: fall back to an id for this page load.
23
+ function getMachineId() {
24
+ try {
25
+ let machine = localStorage.getItem('lowdefy_machine_id');
26
+ if (!machine) {
27
+ machine = uuid();
28
+ localStorage.setItem('lowdefy_machine_id', machine);
29
+ }
30
+ return machine;
31
+ } catch (error) {
32
+ return uuid();
33
+ }
34
+ }
35
+
36
+ function createLogUsage({ basePath, usageDataRef }) {
22
37
  let lastTimestamp = 0;
23
38
  let isOffline = false;
24
- let machine = localStorage.getItem('lowdefy_machine_id');
25
- if (!machine) {
26
- machine = uuid();
27
- localStorage.setItem('lowdefy_machine_id', machine);
28
- }
39
+ const machine = getMachineId();
29
40
 
41
+ // Runs on every event and nothing awaits it, so a failure (the usage route unreachable, a proxy
42
+ // answering with an HTML page, telemetry blocked) is dropped here instead of surfacing as an
43
+ // unhandled rejection. Usage logging never affects the app.
30
44
  async function logUsage() {
31
45
  if (isOffline || lastTimestamp > Date.now() - 1000 * 60 * 15) {
32
46
  return;
33
47
  }
34
48
  lastTimestamp = Date.now();
35
49
 
36
- const res = await fetch('/api/usage', {
37
- method: 'POST',
38
- headers: {
39
- 'Content-Type': 'application/json',
40
- },
41
- body: JSON.stringify({ user: usageDataRef.current.user, machine }),
42
- });
43
- const { offline, data } = await res.json();
44
- if (offline) {
45
- isOffline = true;
46
- return;
50
+ try {
51
+ const res = await fetch(createUrl({ basePath, pathname: '/api/usage' }), {
52
+ method: 'POST',
53
+ headers: {
54
+ 'Content-Type': 'application/json',
55
+ },
56
+ body: JSON.stringify({ user: usageDataRef.current.user, machine }),
57
+ });
58
+ if (!res.ok) {
59
+ return;
60
+ }
61
+ const { offline, data } = await res.json();
62
+ if (offline) {
63
+ isOffline = true;
64
+ return;
65
+ }
66
+ await fetch('https://api.lowdefy.net/v4/telemetry/usage', {
67
+ method: 'POST',
68
+ headers: {
69
+ 'Content-Type': 'application/json',
70
+ },
71
+ body: JSON.stringify(data),
72
+ });
73
+ } catch (error) {
74
+ // Dropped: see above.
47
75
  }
48
- await fetch('https://api.lowdefy.net/v4/telemetry/usage', {
49
- method: 'POST',
50
- headers: {
51
- 'Content-Type': 'application/json',
52
- },
53
- body: JSON.stringify(data),
54
- });
55
76
  }
56
77
  return logUsage;
57
78
  }
@@ -0,0 +1,90 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { jest } from '@jest/globals';
18
+
19
+ import createLogUsage from './createLogUsage.js';
20
+
21
+ const usageDataRef = { current: { user: { id: 'u1' } } };
22
+
23
+ function jsonResponse(body) {
24
+ return { ok: true, json: async () => body };
25
+ }
26
+
27
+ beforeEach(() => {
28
+ const stored = {};
29
+ globalThis.localStorage = {
30
+ getItem: (key) => stored[key] ?? null,
31
+ setItem: (key, value) => {
32
+ stored[key] = value;
33
+ },
34
+ };
35
+ globalThis.fetch = jest.fn();
36
+ });
37
+
38
+ afterEach(() => {
39
+ delete globalThis.localStorage;
40
+ delete globalThis.fetch;
41
+ });
42
+
43
+ test.each([
44
+ { basePath: '', url: '/api/usage' },
45
+ { basePath: '/app', url: '/app/api/usage' },
46
+ ])('createLogUsage posts usage to $url', async ({ basePath, url }) => {
47
+ globalThis.fetch.mockResolvedValueOnce(jsonResponse({ offline: true }));
48
+ await createLogUsage({ basePath, usageDataRef })();
49
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
50
+ expect(globalThis.fetch.mock.calls[0][0]).toBe(url);
51
+ });
52
+
53
+ test.each([
54
+ {
55
+ name: 'an HTML page instead of JSON',
56
+ response: {
57
+ ok: true,
58
+ json: async () => {
59
+ throw new SyntaxError('Unexpected token < in JSON at position 0');
60
+ },
61
+ },
62
+ },
63
+ { name: 'an error status', response: { ok: false, json: async () => ({}) } },
64
+ ])('createLogUsage drops a usage route answering with $name', async ({ response }) => {
65
+ globalThis.fetch.mockResolvedValueOnce(response);
66
+ await expect(createLogUsage({ basePath: '', usageDataRef })()).resolves.toBeUndefined();
67
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
68
+ });
69
+
70
+ test('createLogUsage drops a failed telemetry post', async () => {
71
+ globalThis.fetch
72
+ .mockResolvedValueOnce(jsonResponse({ offline: false, data: { machine: 'm' } }))
73
+ .mockRejectedValueOnce(new TypeError('Failed to fetch'));
74
+ await expect(createLogUsage({ basePath: '', usageDataRef })()).resolves.toBeUndefined();
75
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
76
+ });
77
+
78
+ test('createLogUsage works when browser storage throws', async () => {
79
+ globalThis.localStorage = {
80
+ getItem: () => {
81
+ throw new Error('SecurityError');
82
+ },
83
+ setItem: () => {
84
+ throw new Error('SecurityError');
85
+ },
86
+ };
87
+ globalThis.fetch.mockResolvedValueOnce(jsonResponse({ offline: true }));
88
+ await createLogUsage({ basePath: '', usageDataRef })();
89
+ expect(JSON.parse(globalThis.fetch.mock.calls[0][1].body).machine).toEqual(expect.any(String));
90
+ });
@@ -0,0 +1,25 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ // The page's Content-Security-Policy nonce, from the csp-nonce meta tag the server
18
+ // writes when config.security.contentSecurityPolicy is on (Vite reads the same tag).
19
+ // Browsers hide nonce attributes from getAttribute, so it is read from the property.
20
+ // An empty string when the policy is off.
21
+ function getCspNonce() {
22
+ return document.querySelector('meta[property="csp-nonce"]')?.nonce ?? '';
23
+ }
24
+
25
+ export default getCspNonce;
@@ -0,0 +1,70 @@
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 path from 'node:path';
18
+ import { createSystemContext as buildSystemContext } from '@lowdefy/api';
19
+ import { getSecretsFromEnv } from '@lowdefy/node-utils';
20
+ import { v4 as uuid } from 'uuid';
21
+
22
+ import agents from '../../../build/plugins/agents.js';
23
+ import appMeta from '../../build/appMeta.js';
24
+ import config from '../../build/config.js';
25
+ import connections from '../../../build/plugins/connections.js';
26
+ import createHandleError from '../log/createHandleError.js';
27
+ import createLogger from '../log/createLogger.js';
28
+ import fileCache from '../fileCache.js';
29
+ import i18nConfig from '../../build/i18n.js';
30
+ import jsMap from '../../../build/plugins/operators/serverJsMap.js';
31
+ import notifications from '../../../build/plugins/notifications.js';
32
+ import operators from '../../../build/plugins/operators/server.js';
33
+ import scrubSecrets from '../scrubSecrets.js';
34
+ import steps from '../../../build/plugins/steps.js';
35
+ import websockets from '../../../build/plugins/websockets.js';
36
+
37
+ const secrets = getSecretsFromEnv();
38
+
39
+ // Builds a fresh system context per auth hook fire - the trusted internal
40
+ // caller the BetterAuth engine uses to invoke hook endpoints. All fields are
41
+ // startup singletons except the per-fire rid, logger, handleError, and auth -
42
+ // auth is threaded in by the caller (createHookDispatch) rather than
43
+ // imported directly, since it comes from the same BetterAuth construction
44
+ // that this factory is a dependency of (see createHookDispatch.js).
45
+ function createSystemContext({ auth } = {}) {
46
+ const rid = uuid();
47
+ return buildSystemContext({
48
+ rid,
49
+ agents,
50
+ appMeta,
51
+ auth,
52
+ buildDirectory: path.join(process.cwd(), 'build'),
53
+ config,
54
+ connections,
55
+ createHandleError,
56
+ fileCache,
57
+ i18n: i18nConfig,
58
+ jsMap,
59
+ logger: createLogger({ rid }),
60
+ mode: 'prod',
61
+ notifications,
62
+ operators,
63
+ scrubSecrets,
64
+ secrets,
65
+ steps,
66
+ websockets,
67
+ });
68
+ }
69
+
70
+ export default createSystemContext;
@@ -14,24 +14,32 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { getAuthConfig as getApiAuthConfig } from '@lowdefy/api';
17
+ import { getBetterAuth } from '@lowdefy/api';
18
18
  import { getSecretsFromEnv } from '@lowdefy/node-utils';
19
19
 
20
20
  import adapters from '../../../build/plugins/auth/adapters.js';
21
21
  import appMeta from '../../build/appMeta.js';
22
22
  import authJson from '../../build/auth.js';
23
- import callbacks from '../../../build/plugins/auth/callbacks.js';
24
- import events from '../../../build/plugins/auth/events.js';
23
+ import createSystemContext from './createSystemContext.js';
24
+ import lowdefyConfig from '../../build/config.js';
25
25
  import providers from '../../../build/plugins/auth/providers.js';
26
26
 
27
- function getAuthConfig({ logger }) {
28
- return getApiAuthConfig({
27
+ // Returns the BetterAuth instance (memoized in @lowdefy/api), or null when
28
+ // auth is not configured - it handles /api/auth/* and resolves the session
29
+ // for every request.
30
+ function getAuth({ logger }) {
31
+ if (authJson.configured !== true) {
32
+ return null;
33
+ }
34
+ return getBetterAuth({
29
35
  appMeta,
30
36
  authJson,
37
+ config: lowdefyConfig,
38
+ createSystemContext,
31
39
  logger,
32
- plugins: { adapters, callbacks, events, providers },
40
+ plugins: { adapters, providers },
33
41
  secrets: getSecretsFromEnv(),
34
42
  });
35
43
  }
36
44
 
37
- export default getAuthConfig;
45
+ export default getAuth;
@@ -0,0 +1,40 @@
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 { getAuthStrategies } from '@lowdefy/api';
18
+ import { getSecretsFromEnv } from '@lowdefy/node-utils';
19
+
20
+ import appMeta from '../../build/appMeta.js';
21
+ import authJson from '../../build/auth.js';
22
+ import strategyTypes from '../../../build/plugins/auth/strategies.js';
23
+
24
+ // Returns the API auth strategy verifiers (memoized in @lowdefy/api), or an
25
+ // empty list when auth is not configured - resolveAuthentication tries them
26
+ // in config order when no session resolves.
27
+ function getStrategies({ logger }) {
28
+ if (authJson.configured !== true) {
29
+ return [];
30
+ }
31
+ return getAuthStrategies({
32
+ appMeta,
33
+ authJson,
34
+ logger,
35
+ plugins: { strategies: strategyTypes },
36
+ secrets: getSecretsFromEnv(),
37
+ });
38
+ }
39
+
40
+ export default getStrategies;
@@ -0,0 +1,56 @@
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 { type } from '@lowdefy/helpers';
18
+ import { createClientAddressResolver } from '@lowdefy/node-utils';
19
+
20
+ import config from '../build/config.js';
21
+
22
+ const resolveClientAddress = createClientAddressResolver({
23
+ trustedProxies: config.trustedProxies,
24
+ });
25
+
26
+ let unconfiguredProxyWarned = false;
27
+
28
+ // The address of the client a request came from, resolved once per request by
29
+ // apiContext. A platform entry whose edge sets the client address on every
30
+ // request (Vercel's x-real-ip) names that header. Otherwise the address is the
31
+ // connection's peer, which @hono/node-server hands the app as c.env.incoming,
32
+ // or the hop behind it when the peer is one of config.trustedProxies. A request
33
+ // the app receives without a Node connection (app.fetch from a test) has no
34
+ // peer, and its address is unknown.
35
+ function getClientAddress({ c, clientAddressHeader, logger }) {
36
+ if (type.isString(clientAddressHeader)) {
37
+ return c.req.header(clientAddressHeader) ?? null;
38
+ }
39
+ const forwardedFor = c.req.header('x-forwarded-for');
40
+ if (
41
+ !unconfiguredProxyWarned &&
42
+ type.isString(forwardedFor) &&
43
+ type.isNone(config.trustedProxies)
44
+ ) {
45
+ unconfiguredProxyWarned = true;
46
+ logger.warn(
47
+ 'A request carried X-Forwarded-For, which is ignored because config.trustedProxies is not set: the client address is the connecting peer. If the app runs behind a reverse proxy or load balancer, add its address to config.trustedProxies, or every client shares the proxy address for auth rate limits.'
48
+ );
49
+ }
50
+ return resolveClientAddress({
51
+ peerAddress: c.env?.incoming?.socket?.remoteAddress,
52
+ forwardedFor,
53
+ });
54
+ }
55
+
56
+ export default getClientAddress;
@@ -0,0 +1,108 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { Hono } from 'hono';
18
+ import { jest } from '@jest/globals';
19
+
20
+ const mockConfig = {};
21
+ jest.unstable_mockModule('../build/config.js', () => ({ default: mockConfig }));
22
+
23
+ // The resolver is built from config at module load, so each case loads a fresh
24
+ // module instance (the query string) against its own trustedProxies.
25
+ let loadCount = 0;
26
+ async function loadGetClientAddress({ trustedProxies }) {
27
+ mockConfig.trustedProxies = trustedProxies;
28
+ loadCount += 1;
29
+ const { default: getClientAddress } = await import(`./getClientAddress.js?case=${loadCount}`);
30
+ return getClientAddress;
31
+ }
32
+
33
+ // Runs one request through a Hono app the way @hono/node-server does, with the
34
+ // Node request (and its socket) as c.env.incoming.
35
+ async function resolve({ getClientAddress, clientAddressHeader, headers, peerAddress, logger }) {
36
+ const app = new Hono();
37
+ app.get('/', (c) =>
38
+ c.json({ clientAddress: getClientAddress({ c, clientAddressHeader, logger }) })
39
+ );
40
+ const env = { incoming: { socket: { remoteAddress: peerAddress } } };
41
+ const response = await app.request('/', { headers }, env);
42
+ return (await response.json()).clientAddress;
43
+ }
44
+
45
+ function createLogger() {
46
+ return { warn: jest.fn() };
47
+ }
48
+
49
+ test('getClientAddress takes the connection peer and ignores X-Forwarded-For without trusted proxies', async () => {
50
+ const getClientAddress = await loadGetClientAddress({ trustedProxies: undefined });
51
+ const clientAddress = await resolve({
52
+ getClientAddress,
53
+ headers: { 'x-forwarded-for': '203.0.113.9' },
54
+ peerAddress: '198.51.100.4',
55
+ logger: createLogger(),
56
+ });
57
+ expect(clientAddress).toBe('198.51.100.4');
58
+ });
59
+
60
+ test('getClientAddress reads X-Forwarded-For behind a trusted proxy', async () => {
61
+ const getClientAddress = await loadGetClientAddress({ trustedProxies: ['10.0.0.0/8'] });
62
+ const clientAddress = await resolve({
63
+ getClientAddress,
64
+ headers: { 'x-forwarded-for': '198.51.100.66, 203.0.113.9' },
65
+ peerAddress: '10.0.0.2',
66
+ logger: createLogger(),
67
+ });
68
+ expect(clientAddress).toBe('203.0.113.9');
69
+ });
70
+
71
+ test('getClientAddress takes the platform header when the entry names one', async () => {
72
+ const getClientAddress = await loadGetClientAddress({ trustedProxies: undefined });
73
+ const clientAddress = await resolve({
74
+ getClientAddress,
75
+ clientAddressHeader: 'x-real-ip',
76
+ headers: { 'x-real-ip': '203.0.113.9', 'x-forwarded-for': '198.51.100.66' },
77
+ peerAddress: '10.0.0.2',
78
+ logger: createLogger(),
79
+ });
80
+ expect(clientAddress).toBe('203.0.113.9');
81
+ });
82
+
83
+ test('getClientAddress warns once when X-Forwarded-For arrives and trustedProxies is not set', async () => {
84
+ const getClientAddress = await loadGetClientAddress({ trustedProxies: undefined });
85
+ const logger = createLogger();
86
+ const request = {
87
+ getClientAddress,
88
+ headers: { 'x-forwarded-for': '203.0.113.9' },
89
+ peerAddress: '10.0.0.2',
90
+ logger,
91
+ };
92
+ await resolve(request);
93
+ await resolve(request);
94
+ expect(logger.warn).toHaveBeenCalledTimes(1);
95
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('config.trustedProxies'));
96
+ });
97
+
98
+ test('getClientAddress does not warn when trustedProxies is set', async () => {
99
+ const getClientAddress = await loadGetClientAddress({ trustedProxies: [] });
100
+ const logger = createLogger();
101
+ await resolve({
102
+ getClientAddress,
103
+ headers: { 'x-forwarded-for': '203.0.113.9' },
104
+ peerAddress: '10.0.0.2',
105
+ logger,
106
+ });
107
+ expect(logger.warn).not.toHaveBeenCalled();
108
+ });
@@ -24,9 +24,13 @@ import {
24
24
  RequestError,
25
25
  loadAndResolveErrorLocation,
26
26
  ServiceError,
27
+ TenantIntegrityError,
27
28
  } from '@lowdefy/errors';
28
29
 
29
30
  import captureSentryError from '../sentry/captureSentryError.js';
31
+ import redactUrlQuery from './redactUrlQuery.js';
32
+ import scrubSecrets from '../scrubSecrets.js';
33
+ import serializeErrorForLog from './serializeErrorForLog.js';
30
34
 
31
35
  function getEventType(error) {
32
36
  if (error instanceof ServiceError) {
@@ -50,6 +54,9 @@ function getEventType(error) {
50
54
  if (error instanceof ConfigError) {
51
55
  return 'config_error';
52
56
  }
57
+ if (error instanceof TenantIntegrityError) {
58
+ return 'tenant_integrity_error';
59
+ }
53
60
  if (error instanceof LowdefyInternalError) {
54
61
  return 'lowdefy_error';
55
62
  }
@@ -92,7 +99,7 @@ function createHandleError({ context }) {
92
99
  error.config = location.config;
93
100
  }
94
101
 
95
- // Single structured log call — pino serializes error via extractErrorProps,
102
+ // Single structured log call — pino serializes the error via serializeErrorForLog,
96
103
  // message string gives human-readable display via CLI logger
97
104
  const errorName = error?.name || 'Error';
98
105
  context.logger.error(
@@ -104,6 +111,8 @@ function createHandleError({ context }) {
104
111
  errorMessage: error.message,
105
112
  isServiceError,
106
113
  pageId: context.pageId || null,
114
+ endpointId: error.endpointId ?? context.endpointId ?? null,
115
+ organization_id: error.organizationId ?? context.user?.organization_id ?? null,
107
116
  timestamp: new Date().toISOString(),
108
117
  source: error.source || null,
109
118
  config: error.config || null,
@@ -118,6 +127,7 @@ function createHandleError({ context }) {
118
127
  method: context.req.method,
119
128
  resolvedUrl: context.nextContext?.resolvedUrl,
120
129
  hostname: context.req.hostname,
130
+ client_address: context.clientAddress,
121
131
  headers: {
122
132
  'accept-language': headers['accept-language'],
123
133
  'sec-ch-ua-mobile': headers['sec-ch-ua-mobile'],
@@ -125,7 +135,7 @@ function createHandleError({ context }) {
125
135
  'sec-ch-ua': headers['sec-ch-ua'],
126
136
  'user-agent': headers['user-agent'],
127
137
  host: headers.host,
128
- referer: headers.referer,
138
+ referer: redactUrlQuery(headers.referer),
129
139
  // Non localhost headers
130
140
  'x-forwarded-for': headers['x-forwarded-for'],
131
141
  // Vercel headers
@@ -154,9 +164,11 @@ function createHandleError({ context }) {
154
164
  configLocation: location,
155
165
  });
156
166
  } catch (e) {
157
- console.error(error);
167
+ // console.error writes to stderr past pino and its line scrub, and would print the raw
168
+ // error - an axios error's config.auth included - so it gets both layers by hand.
169
+ console.error(scrubSecrets(JSON.stringify(serializeErrorForLog(error))));
158
170
  console.error('An error occurred while logging the error.');
159
- console.error(e);
171
+ console.error(scrubSecrets(JSON.stringify(serializeErrorForLog(e))));
160
172
  }
161
173
  };
162
174
  }