@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.
- package/client/App.jsx +41 -21
- package/client/Page.jsx +90 -26
- package/client/clearReloadedForTypes.js +29 -0
- package/client/clientBuildImports.test.mjs +6 -7
- package/client/getShownPage.js +28 -0
- package/client/getShownPage.test.mjs +52 -0
- package/client/loadAllIcons.js +45 -0
- package/client/loadPageTypes.js +44 -0
- package/client/main.jsx +22 -1
- package/client/reloadedForTypesKey.js +21 -0
- package/client/shouldReloadForTypes.js +38 -0
- package/client/shouldReloadForTypes.test.mjs +63 -0
- package/client/types.js +28 -0
- package/lib/build/security.js +26 -0
- package/lib/client/auth/Auth.jsx +11 -6
- package/lib/client/auth/AuthConfigured.jsx +222 -36
- package/lib/client/auth/AuthNotConfigured.js +44 -4
- package/lib/client/auth/createFetchCheckingBuild.js +45 -0
- package/lib/client/auth/createFetchCheckingBuild.test.mjs +100 -0
- package/lib/client/bundleBuildId.js +23 -0
- package/lib/client/createLogUsage.js +46 -25
- package/lib/client/createLogUsage.test.mjs +90 -0
- package/lib/client/getCspNonce.js +25 -0
- package/lib/server/auth/createSystemContext.js +70 -0
- package/lib/server/auth/{getAuthConfig.js → getAuth.js} +15 -9
- package/lib/server/auth/getStrategies.js +40 -0
- package/lib/server/getClientAddress.js +56 -0
- package/lib/server/getClientAddress.test.mjs +108 -0
- package/lib/server/log/createHandleError.js +7 -0
- package/lib/server/log/createHandleError.test.mjs +17 -0
- package/lib/server/log/createLogger.test.mjs +15 -0
- package/lib/server/log/logRequest.js +5 -3
- package/lib/server/log/projectErrorForLog.js +6 -0
- package/lib/server/log/serializeErrorForLog.test.mjs +13 -1
- package/lib/server/sentry/captureSentryError.js +17 -3
- package/lib/server/sentry/captureSentryError.test.mjs +37 -13
- package/lib/server/sentry/initSentry.js +39 -18
- package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
- package/lib/server/sentry/initSentry.test.mjs +132 -4
- package/lib/server/sentry/scrubEvent.js +12 -1
- package/lib/server/sentry/sentrySdk.js +23 -0
- package/lib/server/sentry/setSentryUser.js +3 -3
- package/lowdefy/build.mjs +2 -0
- package/lowdefy/build.test.mjs +88 -0
- package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
- package/package.json +42 -36
- package/package.original.json +42 -36
- package/src/app.js +74 -17
- package/src/app.test.mjs +118 -0
- package/src/html/collectChunkClosure.js +31 -0
- package/src/html/collectLazyImports.js +41 -0
- package/src/html/collectPageTypesAssets.js +57 -0
- package/src/html/collectPageTypesAssets.test.mjs +130 -0
- package/src/html/getAssets.js +20 -1
- package/src/html/getHomePath.js +29 -0
- package/src/html/getPageAssets.js +32 -0
- package/src/html/getPageAssets.test.mjs +47 -0
- package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
- package/src/html/renderPage.js +80 -19
- package/src/html/renderPage.test.mjs +189 -0
- package/src/html/template.js +50 -15
- package/src/html/template.test.mjs +126 -0
- package/src/index.js +28 -29
- package/src/initServer.js +61 -0
- package/src/initServer.test.mjs +116 -0
- package/src/lib/getRequestPath.js +29 -0
- package/src/middleware/apiContext.js +65 -13
- package/src/middleware/apiContext.test.mjs +66 -6
- package/src/middleware/createSameOriginGuard.js +36 -0
- package/src/middleware/createSameOriginGuard.test.mjs +102 -0
- package/src/middleware/errorHandler.js +56 -4
- package/src/middleware/errorHandler.test.mjs +440 -0
- package/{lib/server/auth/session.js → src/middleware/noStore.js} +9 -13
- package/src/middleware/refuseOtherBuild.js +49 -0
- package/src/middleware/refuseOtherBuild.test.mjs +63 -0
- package/src/middleware/requestTimeout.js +38 -0
- package/src/middleware/sentry.js +13 -5
- package/src/middleware/stampBuildId.js +30 -0
- package/src/middleware/stampBuildId.test.mjs +41 -0
- package/src/middleware/stampCaller.js +38 -0
- package/src/middleware/stampCaller.test.mjs +64 -0
- package/src/routes/agent.js +15 -3
- package/src/routes/apiPage.js +51 -9
- package/src/routes/apiPage.test.mjs +99 -15
- package/src/routes/auth.js +15 -9
- package/src/routes/auth.test.mjs +85 -0
- package/src/routes/clientError.js +11 -13
- package/src/routes/cron.js +3 -1
- package/src/routes/cronForward.js +3 -1
- package/src/routes/detached.js +22 -9
- package/src/routes/endpoints.js +27 -26
- package/src/routes/endpointsTextAnswer.test.mjs +77 -0
- package/src/routes/mcp.js +82 -5
- package/src/routes/mcp.test.mjs +215 -0
- package/src/routes/mcpMemberToken.test.mjs +125 -0
- package/src/routes/mcpProtectedResourceMetadata.js +34 -0
- package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
- package/src/routes/mountOauthDiscovery.js +44 -0
- package/src/routes/mountOauthDiscovery.test.mjs +79 -0
- package/src/routes/mountPageRoutes.js +29 -0
- package/src/routes/mountPageRoutes.test.mjs +56 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
- package/src/routes/request.js +5 -3
- package/src/routes/requestBody.test.mjs +146 -0
- package/src/routes/usage.js +6 -2
- package/src/routes/user.js +27 -0
- package/src/routes/websocket.js +26 -3
- package/src/routes/websocket.test.mjs +106 -0
- package/src/routes/wellKnownFallback.js +30 -0
- package/vite.config.js +55 -2
- package/client/shouldReloadForBuild.js +0 -48
- package/client/shouldReloadForBuild.test.mjs +0 -75
|
@@ -0,0 +1,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 {
|
|
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
|
|
24
|
-
import
|
|
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
|
-
|
|
29
|
-
|
|
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,
|
|
40
|
+
plugins: { adapters, providers },
|
|
35
41
|
secrets: getSecretsFromEnv(),
|
|
36
42
|
});
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
export default
|
|
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 = {}
|
|
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: {
|