@lowdefy/server 6.1.0 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +2 -0
  44. package/lowdefy/build.test.mjs +88 -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,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,26 +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 config from '../../build/config.js';
24
- import callbacks from '../../../build/plugins/auth/callbacks.js';
25
- import events from '../../../build/plugins/auth/events.js';
23
+ import createSystemContext from './createSystemContext.js';
24
+ import lowdefyConfig from '../../build/config.js';
26
25
  import providers from '../../../build/plugins/auth/providers.js';
27
26
 
28
- function getAuthConfig({ logger }) {
29
- 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({
30
35
  appMeta,
31
36
  authJson,
32
- config,
37
+ config: lowdefyConfig,
38
+ createSystemContext,
33
39
  logger,
34
- plugins: { adapters, callbacks, events, providers },
40
+ plugins: { adapters, providers },
35
41
  secrets: getSecretsFromEnv(),
36
42
  });
37
43
  }
38
44
 
39
- 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,6 +24,7 @@ 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';
@@ -53,6 +54,9 @@ function getEventType(error) {
53
54
  if (error instanceof ConfigError) {
54
55
  return 'config_error';
55
56
  }
57
+ if (error instanceof TenantIntegrityError) {
58
+ return 'tenant_integrity_error';
59
+ }
56
60
  if (error instanceof LowdefyInternalError) {
57
61
  return 'lowdefy_error';
58
62
  }
@@ -107,6 +111,8 @@ function createHandleError({ context }) {
107
111
  errorMessage: error.message,
108
112
  isServiceError,
109
113
  pageId: context.pageId || null,
114
+ endpointId: error.endpointId ?? context.endpointId ?? null,
115
+ organization_id: error.organizationId ?? context.user?.organization_id ?? null,
110
116
  timestamp: new Date().toISOString(),
111
117
  source: error.source || null,
112
118
  config: error.config || null,
@@ -121,6 +127,7 @@ function createHandleError({ context }) {
121
127
  method: context.req.method,
122
128
  resolvedUrl: context.nextContext?.resolvedUrl,
123
129
  hostname: context.req.hostname,
130
+ client_address: context.clientAddress,
124
131
  headers: {
125
132
  'accept-language': headers['accept-language'],
126
133
  'sec-ch-ua-mobile': headers['sec-ch-ua-mobile'],
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { jest } from '@jest/globals';
18
+ import { TenantIntegrityError } from '@lowdefy/errors';
18
19
 
19
20
  const secret = 'planted-handle-error-secret';
20
21
 
@@ -83,3 +84,19 @@ test('handleError fallback output carries no axios config auth and no planted se
83
84
  expect(logged.code).toBe('ERR_BAD_REQUEST');
84
85
  expect(JSON.parse(console.error.mock.calls[2][0]).message).toBe('Log sink rejected [REDACTED]');
85
86
  });
87
+
88
+ test('handleError logs a TenantIntegrityError as tenant_integrity_error with endpoint and organization', async () => {
89
+ const context = testContext();
90
+ context.endpointId = 'save-contact';
91
+ context.user = { id: 'u1', organization_id: 'org_a' };
92
+ const handleError = createHandleError({ context });
93
+
94
+ await handleError(
95
+ new TenantIntegrityError('bad row', { collection: 'contacts', organizationId: 'org_a' })
96
+ );
97
+
98
+ const [fields] = context.logger.error.mock.calls[0];
99
+ expect(fields.event).toBe('tenant_integrity_error');
100
+ expect(fields.endpointId).toBe('save-contact');
101
+ expect(fields.organization_id).toBe('org_a');
102
+ });
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { jest } from '@jest/globals';
18
18
  import { createNodeLogger } from '@lowdefy/logger/node';
19
+ import { markCredential, runInCredentialScope } from '@lowdefy/node-utils';
19
20
 
20
21
  const secret = 'planted/secret+value=1';
21
22
  const shortSecret = 'short77';
@@ -100,3 +101,17 @@ test('prod logger leaves a secret shorter than 8 characters alone', () => {
100
101
  expect(msg).toBe(`value ${shortSecret}`);
101
102
  expect(detail).toBe(shortSecret);
102
103
  });
104
+
105
+ test('prod logger redacts a value the request marked as a credential', () => {
106
+ runInCredentialScope(() => {
107
+ markCredential('runtime-made-app-key-0001');
108
+ createLogger({ rid: 'req-1' }).debug(
109
+ { event: 'debug_control_return', response: { key: 'runtime-made-app-key-0001' } },
110
+ 'Returned'
111
+ );
112
+ });
113
+ createLogger({ rid: 'req-2' }).debug({ response: { key: 'runtime-made-app-key-0001' } });
114
+ expect(lines).toHaveLength(2);
115
+ expect(JSON.parse(lines[0]).response.key).toBe('[REDACTED]');
116
+ expect(JSON.parse(lines[1]).response.key).toBe('runtime-made-app-key-0001');
117
+ });
@@ -22,18 +22,20 @@ import redactUrlQuery from './redactUrlQuery.js';
22
22
  // full URL there and that URL can carry a sign-in token or a signed OAuth
23
23
  // query (see redactUrlQuery).
24
24
  function logRequest({ context, status, durationMs }) {
25
- const { headers = {}, user = {} } = context;
25
+ const { headers = {} } = context;
26
+ const user = context.user ?? {};
26
27
  context.logger.info(
27
28
  {
28
29
  user: {
29
30
  id: user.id,
30
31
  roles: user.roles,
31
- sub: user.sub,
32
- session_id: user.session_id, // TODO: Implement session id
33
32
  },
34
33
  url: context.req.url,
35
34
  method: context.req.method,
36
35
  hostname: context.req.hostname,
36
+ // The address the server resolved (see getClientAddress). The forwarding
37
+ // headers below are logged as the client sent them, and are not it.
38
+ client_address: context.clientAddress,
37
39
  status,
38
40
  duration_ms: durationMs,
39
41
  headers: {