@lowdefy/server 6.0.0 → 6.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/Page.jsx +14 -1
- package/client/clientBuildImports.test.mjs +61 -0
- package/client/shouldReloadForBuild.js +48 -0
- package/client/shouldReloadForBuild.test.mjs +75 -0
- package/lib/client/auth/AuthConfigured.jsx +6 -7
- package/lib/server/auth/getAuthConfig.js +2 -0
- package/lib/server/log/createHandleError.js +9 -4
- package/lib/server/log/createHandleError.test.mjs +85 -0
- package/lib/server/log/createLogger.js +10 -0
- package/lib/server/log/createLogger.test.mjs +102 -0
- package/lib/server/log/logRequest.js +7 -1
- package/lib/server/log/maskCredentialKeys.js +37 -0
- package/lib/server/log/projectErrorForLog.js +55 -0
- package/lib/server/log/redactUrlQuery.js +75 -0
- package/lib/server/log/redactUrlQuery.test.mjs +73 -0
- package/lib/server/log/serializeErrorForLog.js +25 -0
- package/lib/server/log/serializeErrorForLog.test.mjs +325 -0
- package/lib/server/scrubSecrets.js +21 -0
- package/lib/server/sentry/captureSentryError.js +12 -0
- package/lib/server/sentry/captureSentryError.test.mjs +104 -0
- package/lib/server/sentry/initSentry.js +11 -0
- package/lib/server/sentry/initSentry.test.mjs +237 -0
- package/lib/server/sentry/scrubEvent.js +29 -0
- package/lib/server/sentry/scrubEvent.test.mjs +88 -0
- package/package.json +30 -31
- package/package.original.json +30 -31
- package/src/index.js +10 -0
- package/src/middleware/apiContext.js +3 -0
- package/src/middleware/apiContext.test.mjs +118 -0
- package/src/routes/apiPage.js +4 -1
- package/src/routes/apiPage.test.mjs +59 -0
- package/src/routes/cronForward.js +1 -1
- package/lib/server/apiWrapper.js +0 -87
package/client/Page.jsx
CHANGED
|
@@ -28,6 +28,9 @@ import blocks from '../build/plugins/blocks.js';
|
|
|
28
28
|
import icons from '../build/plugins/icons.js';
|
|
29
29
|
import operators from '../build/plugins/operators/client.js';
|
|
30
30
|
import jsMap from '../build/plugins/operators/clientJsMap.js';
|
|
31
|
+
import appMeta from '../build/appMeta.json';
|
|
32
|
+
|
|
33
|
+
import shouldReloadForBuild from './shouldReloadForBuild.js';
|
|
31
34
|
|
|
32
35
|
// Replaces lib/client/Page.js. The first page renders from the config
|
|
33
36
|
// embedded in the HTML; SPA navigations fetch /api/page/* and swap pageConfig.
|
|
@@ -66,8 +69,18 @@ function Page({ auth, config, lowdefy }) {
|
|
|
66
69
|
}
|
|
67
70
|
return;
|
|
68
71
|
}
|
|
69
|
-
const { pageConfig: nextPageConfig } = await res.json();
|
|
72
|
+
const { buildId, pageConfig: nextPageConfig } = await res.json();
|
|
70
73
|
if (token !== latestNavRef.current) return;
|
|
74
|
+
if (
|
|
75
|
+
shouldReloadForBuild({ bundleBuildId: appMeta.buildId, serverBuildId: buildId, window })
|
|
76
|
+
) {
|
|
77
|
+
// The app was redeployed after this bundle loaded, so the config may
|
|
78
|
+
// reference _js functions and plugins this bundle does not carry.
|
|
79
|
+
// The router has already pushed the target URL, so a reload lands
|
|
80
|
+
// on the requested page with the current bundle.
|
|
81
|
+
window.location.reload();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
71
84
|
setPageConfig(nextPageConfig);
|
|
72
85
|
} catch (error) {
|
|
73
86
|
// Network failure on SPA navigation — fall back to a full page load.
|
|
@@ -0,0 +1,61 @@
|
|
|
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 fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
// Everything the client imports from build/ ships in the browser bundle. build/config.json is
|
|
22
|
+
// server-only: it carries every deployment environment's settings (other environments' urls,
|
|
23
|
+
// secret names, email filters). The client learns only the current environment's name and
|
|
24
|
+
// switched-off features, through appMeta. This allowlist fails the build of any new client import
|
|
25
|
+
// of a build artifact until it has been checked for what it exposes.
|
|
26
|
+
const allowed = new Set([
|
|
27
|
+
'auth.json',
|
|
28
|
+
'appMeta.json',
|
|
29
|
+
'logger.json',
|
|
30
|
+
'i18n/antdLocales.js',
|
|
31
|
+
'i18n/antdXLocales.js',
|
|
32
|
+
'i18n/dayjsLocales.js',
|
|
33
|
+
'plugins/actions.js',
|
|
34
|
+
'plugins/blockMetas.json',
|
|
35
|
+
'plugins/blocks.js',
|
|
36
|
+
'plugins/icons.js',
|
|
37
|
+
'plugins/operators/client.js',
|
|
38
|
+
'plugins/operators/clientJsMap.js',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
42
|
+
|
|
43
|
+
function listSourceFiles(dir) {
|
|
44
|
+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
45
|
+
const full = path.join(dir, entry.name);
|
|
46
|
+
if (entry.isDirectory()) return listSourceFiles(full);
|
|
47
|
+
return /\.(js|jsx|mjs)$/.test(entry.name) && !entry.name.includes('.test.') ? [full] : [];
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('client code imports only client-safe build artifacts', () => {
|
|
52
|
+
const imported = ['client', 'lib/client'].flatMap((dir) =>
|
|
53
|
+
listSourceFiles(path.join(root, dir)).flatMap((file) =>
|
|
54
|
+
[...fs.readFileSync(file, 'utf8').matchAll(/from '[^']*\/build\/([^']+)'/g)].map(
|
|
55
|
+
(match) => ({ file: path.relative(root, file), artifact: match[1] })
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
);
|
|
59
|
+
expect(imported.length).toBeGreaterThan(0);
|
|
60
|
+
expect(imported.filter(({ artifact }) => !allowed.has(artifact))).toEqual([]);
|
|
61
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
|
|
19
|
+
// sessionStorage key holding the server build this tab last reloaded for.
|
|
20
|
+
const RELOADED_FOR_BUILD_KEY = 'lowdefy.reloadedForBuild';
|
|
21
|
+
|
|
22
|
+
// True when a page config fetched during SPA navigation came from a newer
|
|
23
|
+
// deploy than the bundle this tab is running, so the tab must do a full load
|
|
24
|
+
// to pick up the current bundle. Without this the new config can reference
|
|
25
|
+
// _js functions the old bundle never shipped, and the page throws mid-event.
|
|
26
|
+
//
|
|
27
|
+
// A tab reloads at most once per server build: if the freshly loaded bundle
|
|
28
|
+
// still disagrees with the server (a rolling deploy answering from mixed
|
|
29
|
+
// versions, a proxy caching old HTML), reloading again would loop forever.
|
|
30
|
+
// The old-config-on-old-bundle throw is the lesser failure in that case.
|
|
31
|
+
function shouldReloadForBuild({ bundleBuildId, serverBuildId, window }) {
|
|
32
|
+
if (type.isNone(serverBuildId) || serverBuildId === bundleBuildId) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
if (window.sessionStorage.getItem(RELOADED_FOR_BUILD_KEY) === serverBuildId) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
window.sessionStorage.setItem(RELOADED_FOR_BUILD_KEY, serverBuildId);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
// Browsers with storage disabled throw on sessionStorage access. Without
|
|
42
|
+
// the once-per-build record the loop guard cannot work, so do not reload.
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default shouldReloadForBuild;
|
|
@@ -0,0 +1,75 @@
|
|
|
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 shouldReloadForBuild from './shouldReloadForBuild.js';
|
|
18
|
+
|
|
19
|
+
function createWindow({ stored = {}, throws = false } = {}) {
|
|
20
|
+
return {
|
|
21
|
+
sessionStorage: {
|
|
22
|
+
getItem: (key) => {
|
|
23
|
+
if (throws) throw new Error('storage disabled');
|
|
24
|
+
return stored[key] ?? null;
|
|
25
|
+
},
|
|
26
|
+
setItem: (key, value) => {
|
|
27
|
+
if (throws) throw new Error('storage disabled');
|
|
28
|
+
stored[key] = value;
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test('shouldReloadForBuild is false when the server build matches the bundle', () => {
|
|
35
|
+
const window = createWindow();
|
|
36
|
+
expect(shouldReloadForBuild({ bundleBuildId: 'build-1', serverBuildId: 'build-1', window })).toBe(
|
|
37
|
+
false
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('shouldReloadForBuild is false when the server sends no build id', () => {
|
|
42
|
+
const window = createWindow();
|
|
43
|
+
expect(shouldReloadForBuild({ bundleBuildId: 'build-1', serverBuildId: undefined, window })).toBe(
|
|
44
|
+
false
|
|
45
|
+
);
|
|
46
|
+
expect(shouldReloadForBuild({ bundleBuildId: 'build-1', serverBuildId: null, window })).toBe(
|
|
47
|
+
false
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('shouldReloadForBuild is true once per newer server build, then false', () => {
|
|
52
|
+
const stored = {};
|
|
53
|
+
const window = createWindow({ stored });
|
|
54
|
+
expect(shouldReloadForBuild({ bundleBuildId: 'build-1', serverBuildId: 'build-2', window })).toBe(
|
|
55
|
+
true
|
|
56
|
+
);
|
|
57
|
+
expect(stored['lowdefy.reloadedForBuild']).toEqual('build-2');
|
|
58
|
+
expect(shouldReloadForBuild({ bundleBuildId: 'build-1', serverBuildId: 'build-2', window })).toBe(
|
|
59
|
+
false
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('shouldReloadForBuild reloads again when the server moves to a third build', () => {
|
|
64
|
+
const window = createWindow({ stored: { 'lowdefy.reloadedForBuild': 'build-2' } });
|
|
65
|
+
expect(shouldReloadForBuild({ bundleBuildId: 'build-2', serverBuildId: 'build-3', window })).toBe(
|
|
66
|
+
true
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('shouldReloadForBuild is false when sessionStorage is unavailable', () => {
|
|
71
|
+
const window = createWindow({ throws: true });
|
|
72
|
+
expect(shouldReloadForBuild({ bundleBuildId: 'build-1', serverBuildId: 'build-2', window })).toBe(
|
|
73
|
+
false
|
|
74
|
+
);
|
|
75
|
+
});
|
|
@@ -26,16 +26,15 @@ import {
|
|
|
26
26
|
useSession,
|
|
27
27
|
} from '@hono/auth-js/react';
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const lowdefyConfig = serializer.deserialize(rawLowdefyConfig);
|
|
29
|
+
// The app basePath, from Vite's BASE_URL (`${config.basePath}/`, set in
|
|
30
|
+
// vite.config.js). build/config.json is server-only - it carries every
|
|
31
|
+
// deployment environment's settings - so it is never imported into the client.
|
|
32
|
+
const basePath = import.meta.env.BASE_URL.replace(/\/$/, '');
|
|
34
33
|
|
|
35
34
|
// @hono/auth-js/react configures its fetch paths through a module-level
|
|
36
35
|
// manager instead of SessionProvider props.
|
|
37
|
-
if (
|
|
38
|
-
authConfigManager.setConfig({ basePath: `${
|
|
36
|
+
if (basePath) {
|
|
37
|
+
authConfigManager.setConfig({ basePath: `${basePath}/api/auth` });
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
function Session({ children }) {
|
|
@@ -20,6 +20,7 @@ import { getSecretsFromEnv } from '@lowdefy/node-utils';
|
|
|
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';
|
|
23
24
|
import callbacks from '../../../build/plugins/auth/callbacks.js';
|
|
24
25
|
import events from '../../../build/plugins/auth/events.js';
|
|
25
26
|
import providers from '../../../build/plugins/auth/providers.js';
|
|
@@ -28,6 +29,7 @@ function getAuthConfig({ logger }) {
|
|
|
28
29
|
return getApiAuthConfig({
|
|
29
30
|
appMeta,
|
|
30
31
|
authJson,
|
|
32
|
+
config,
|
|
31
33
|
logger,
|
|
32
34
|
plugins: { adapters, callbacks, events, providers },
|
|
33
35
|
secrets: getSecretsFromEnv(),
|
|
@@ -27,6 +27,9 @@ import {
|
|
|
27
27
|
} from '@lowdefy/errors';
|
|
28
28
|
|
|
29
29
|
import captureSentryError from '../sentry/captureSentryError.js';
|
|
30
|
+
import redactUrlQuery from './redactUrlQuery.js';
|
|
31
|
+
import scrubSecrets from '../scrubSecrets.js';
|
|
32
|
+
import serializeErrorForLog from './serializeErrorForLog.js';
|
|
30
33
|
|
|
31
34
|
function getEventType(error) {
|
|
32
35
|
if (error instanceof ServiceError) {
|
|
@@ -92,7 +95,7 @@ function createHandleError({ context }) {
|
|
|
92
95
|
error.config = location.config;
|
|
93
96
|
}
|
|
94
97
|
|
|
95
|
-
// Single structured log call — pino serializes error via
|
|
98
|
+
// Single structured log call — pino serializes the error via serializeErrorForLog,
|
|
96
99
|
// message string gives human-readable display via CLI logger
|
|
97
100
|
const errorName = error?.name || 'Error';
|
|
98
101
|
context.logger.error(
|
|
@@ -125,7 +128,7 @@ function createHandleError({ context }) {
|
|
|
125
128
|
'sec-ch-ua': headers['sec-ch-ua'],
|
|
126
129
|
'user-agent': headers['user-agent'],
|
|
127
130
|
host: headers.host,
|
|
128
|
-
referer: headers.referer,
|
|
131
|
+
referer: redactUrlQuery(headers.referer),
|
|
129
132
|
// Non localhost headers
|
|
130
133
|
'x-forwarded-for': headers['x-forwarded-for'],
|
|
131
134
|
// Vercel headers
|
|
@@ -154,9 +157,11 @@ function createHandleError({ context }) {
|
|
|
154
157
|
configLocation: location,
|
|
155
158
|
});
|
|
156
159
|
} catch (e) {
|
|
157
|
-
console.error
|
|
160
|
+
// console.error writes to stderr past pino and its line scrub, and would print the raw
|
|
161
|
+
// error - an axios error's config.auth included - so it gets both layers by hand.
|
|
162
|
+
console.error(scrubSecrets(JSON.stringify(serializeErrorForLog(error))));
|
|
158
163
|
console.error('An error occurred while logging the error.');
|
|
159
|
-
console.error(e);
|
|
164
|
+
console.error(scrubSecrets(JSON.stringify(serializeErrorForLog(e))));
|
|
160
165
|
}
|
|
161
166
|
};
|
|
162
167
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { jest } from '@jest/globals';
|
|
18
|
+
|
|
19
|
+
const secret = 'planted-handle-error-secret';
|
|
20
|
+
|
|
21
|
+
process.env.LOWDEFY_SECRET_API_KEY = secret;
|
|
22
|
+
|
|
23
|
+
const { default: createHandleError } = await import('./createHandleError.js');
|
|
24
|
+
|
|
25
|
+
function createAxiosError() {
|
|
26
|
+
const error = new Error(`Request failed for key ${secret}`);
|
|
27
|
+
error.name = 'AxiosError';
|
|
28
|
+
error.code = 'ERR_BAD_REQUEST';
|
|
29
|
+
error.config = {
|
|
30
|
+
auth: { username: 'svc-user', password: 'planted-config-password' },
|
|
31
|
+
params: { api_key: 'planted-config-param' },
|
|
32
|
+
headers: { Authorization: 'Bearer planted-config-header' },
|
|
33
|
+
};
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function testContext({ logger, headers } = {}) {
|
|
38
|
+
return {
|
|
39
|
+
headers,
|
|
40
|
+
logger: logger ?? { error: jest.fn() },
|
|
41
|
+
readConfigFile: jest.fn(async () => null),
|
|
42
|
+
req: { url: '/api/request/page/req', method: 'POST', hostname: 'app.example.com' },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
console.error = jest.fn();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('handleError logs the referer header with credential query parameters redacted', async () => {
|
|
51
|
+
const context = testContext({
|
|
52
|
+
headers: { referer: 'https://app.example.com/verify?token=planted-token&page=2' },
|
|
53
|
+
});
|
|
54
|
+
const handleError = createHandleError({ context });
|
|
55
|
+
|
|
56
|
+
await handleError(new Error('Failed.'));
|
|
57
|
+
|
|
58
|
+
const [fields] = context.logger.error.mock.calls[0];
|
|
59
|
+
expect(fields.headers.referer).toBe('https://app.example.com/verify?token=%5Bredacted%5D&page=2');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('handleError fallback output carries no axios config auth and no planted secret when the log call throws', async () => {
|
|
63
|
+
const logError = new Error(`Log sink rejected ${secret}`);
|
|
64
|
+
const context = testContext({
|
|
65
|
+
logger: {
|
|
66
|
+
error: jest.fn(() => {
|
|
67
|
+
throw logError;
|
|
68
|
+
}),
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
const handleError = createHandleError({ context });
|
|
72
|
+
|
|
73
|
+
await handleError(createAxiosError());
|
|
74
|
+
|
|
75
|
+
expect(console.error).toHaveBeenCalledTimes(3);
|
|
76
|
+
const output = console.error.mock.calls.map(([line]) => line).join('\n');
|
|
77
|
+
expect(output).not.toContain('planted-config-password');
|
|
78
|
+
expect(output).not.toContain('planted-config-param');
|
|
79
|
+
expect(output).not.toContain('planted-config-header');
|
|
80
|
+
expect(output).not.toContain(secret);
|
|
81
|
+
const logged = JSON.parse(console.error.mock.calls[0][0]);
|
|
82
|
+
expect(logged.message).toBe('Request failed for key [REDACTED]');
|
|
83
|
+
expect(logged.code).toBe('ERR_BAD_REQUEST');
|
|
84
|
+
expect(JSON.parse(console.error.mock.calls[2][0]).message).toBe('Log sink rejected [REDACTED]');
|
|
85
|
+
});
|
|
@@ -18,6 +18,8 @@ import { createNodeLogger } from '@lowdefy/logger/node';
|
|
|
18
18
|
import { type } from '@lowdefy/helpers';
|
|
19
19
|
|
|
20
20
|
import appMeta from '../../build/appMeta.js';
|
|
21
|
+
import scrubSecrets from '../scrubSecrets.js';
|
|
22
|
+
import serializeErrorForLog from './serializeErrorForLog.js';
|
|
21
23
|
|
|
22
24
|
// Deploy identity on every log line — pid/hostname are stripped, so these
|
|
23
25
|
// fields are what correlates a line to an app build across replicas.
|
|
@@ -31,11 +33,19 @@ if (!type.isNone(appMeta.version)) {
|
|
|
31
33
|
if (!type.isNone(appMeta.gitSha)) {
|
|
32
34
|
base.git_sha = appMeta.gitSha;
|
|
33
35
|
}
|
|
36
|
+
// The deployment environment (LOWDEFY_ENVIRONMENT), so one log drain can hold every environment.
|
|
37
|
+
if (!type.isNone(appMeta.environment)) {
|
|
38
|
+
base.environment = appMeta.environment;
|
|
39
|
+
}
|
|
34
40
|
|
|
35
41
|
const logger = createNodeLogger({
|
|
36
42
|
name: 'lowdefy_server',
|
|
37
43
|
level: process.env.LOWDEFY_LOG_LEVEL ?? 'info',
|
|
38
44
|
base,
|
|
45
|
+
serializers: { err: serializeErrorForLog },
|
|
46
|
+
// Scrubbing the finished line rather than the logged object covers every field of every
|
|
47
|
+
// call - debug payloads and request results as well as `err` - with no walker to miss one.
|
|
48
|
+
hooks: { streamWrite: scrubSecrets },
|
|
39
49
|
});
|
|
40
50
|
|
|
41
51
|
function createLogger(metadata = {}) {
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
import { createNodeLogger } from '@lowdefy/logger/node';
|
|
19
|
+
|
|
20
|
+
const secret = 'planted/secret+value=1';
|
|
21
|
+
const shortSecret = 'short77';
|
|
22
|
+
|
|
23
|
+
const lines = [];
|
|
24
|
+
const destination = {
|
|
25
|
+
write(line) {
|
|
26
|
+
lines.push(line);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// The prod logger writes to stdout; route the real logger to memory so each test reads the
|
|
31
|
+
// exact line pino would emit, after the streamWrite hook.
|
|
32
|
+
jest.unstable_mockModule('@lowdefy/logger/node', () => ({
|
|
33
|
+
createNodeLogger: (options) => createNodeLogger({ ...options, destination }),
|
|
34
|
+
}));
|
|
35
|
+
jest.unstable_mockModule('../../build/appMeta.js', () => ({ default: {} }));
|
|
36
|
+
|
|
37
|
+
process.env.LOWDEFY_SECRET_API_KEY = secret;
|
|
38
|
+
process.env.LOWDEFY_SECRET_SHORT = shortSecret;
|
|
39
|
+
process.env.LOWDEFY_LOG_LEVEL = 'debug';
|
|
40
|
+
|
|
41
|
+
const { default: createLogger } = await import('./createLogger.js');
|
|
42
|
+
|
|
43
|
+
function createAxiosError() {
|
|
44
|
+
const error = new Error('Request failed with status code 401');
|
|
45
|
+
error.name = 'AxiosError';
|
|
46
|
+
error.code = 'ERR_BAD_REQUEST';
|
|
47
|
+
error.config = {
|
|
48
|
+
auth: { username: 'svc-user', password: 'planted-config-password' },
|
|
49
|
+
params: { api_key: 'planted-config-param' },
|
|
50
|
+
headers: { Authorization: 'Bearer planted-config-header' },
|
|
51
|
+
baseURL: 'https://api.example.com',
|
|
52
|
+
};
|
|
53
|
+
error.response = { status: 401, data: { detail: 'planted-response-body' } };
|
|
54
|
+
return error;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
lines.length = 0;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('prod logger drops an axios error config auth, params and headers', () => {
|
|
62
|
+
createLogger().error({ err: createAxiosError() }, 'Request failed');
|
|
63
|
+
expect(lines).toHaveLength(1);
|
|
64
|
+
const line = lines[0];
|
|
65
|
+
expect(line).not.toContain('planted-config-password');
|
|
66
|
+
expect(line).not.toContain('planted-config-param');
|
|
67
|
+
expect(line).not.toContain('planted-config-header');
|
|
68
|
+
expect(line).not.toContain('planted-response-body');
|
|
69
|
+
const { err } = JSON.parse(line);
|
|
70
|
+
expect(err.config).toBeUndefined();
|
|
71
|
+
expect(err.code).toBe('ERR_BAD_REQUEST');
|
|
72
|
+
expect(err.statusCode).toBe(401);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('prod logger redacts a planted secret in a message raw, URL-encoded and base64', () => {
|
|
76
|
+
const basic = Buffer.from(`svc-user:${secret}`).toString('base64');
|
|
77
|
+
const error = new Error(`raw ${secret} encoded ${encodeURIComponent(secret)} basic ${basic}`);
|
|
78
|
+
createLogger().error({ err: error }, error.message);
|
|
79
|
+
expect(lines).toHaveLength(1);
|
|
80
|
+
const line = lines[0];
|
|
81
|
+
expect(line).not.toContain(secret);
|
|
82
|
+
expect(line).not.toContain(encodeURIComponent(secret));
|
|
83
|
+
expect(line).not.toContain(Buffer.from(secret).toString('base64').slice(0, 20));
|
|
84
|
+
const { msg, err } = JSON.parse(line);
|
|
85
|
+
expect(msg).toMatch(/^raw \[REDACTED\] encoded \[REDACTED\] basic \S*\[REDACTED\]/);
|
|
86
|
+
expect(err.message).toBe(msg);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('prod logger redacts a planted secret in a debug payload that is not an error', () => {
|
|
90
|
+
createLogger({ rid: 'req-1' }).debug({ response: { token: secret } }, 'Request result');
|
|
91
|
+
expect(lines).toHaveLength(1);
|
|
92
|
+
expect(lines[0]).not.toContain(secret);
|
|
93
|
+
expect(JSON.parse(lines[0]).response.token).toBe('[REDACTED]');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('prod logger leaves a secret shorter than 8 characters alone', () => {
|
|
97
|
+
createLogger().info({ detail: shortSecret }, `value ${shortSecret}`);
|
|
98
|
+
expect(lines).toHaveLength(1);
|
|
99
|
+
const { msg, detail } = JSON.parse(lines[0]);
|
|
100
|
+
expect(msg).toBe(`value ${shortSecret}`);
|
|
101
|
+
expect(detail).toBe(shortSecret);
|
|
102
|
+
});
|
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import redactUrlQuery from './redactUrlQuery.js';
|
|
18
|
+
|
|
17
19
|
// Emits one access-log line per request, after the response is finalized.
|
|
20
|
+
// `url` is the route path only (c.req.path). The referer keeps its query but
|
|
21
|
+
// with credential parameters redacted, because the browser sends the page's
|
|
22
|
+
// full URL there and that URL can carry a sign-in token or a signed OAuth
|
|
23
|
+
// query (see redactUrlQuery).
|
|
18
24
|
function logRequest({ context, status, durationMs }) {
|
|
19
25
|
const { headers = {}, user = {} } = context;
|
|
20
26
|
context.logger.info(
|
|
@@ -37,7 +43,7 @@ function logRequest({ context, status, durationMs }) {
|
|
|
37
43
|
'sec-ch-ua': headers['sec-ch-ua'],
|
|
38
44
|
'user-agent': headers['user-agent'],
|
|
39
45
|
host: headers.host,
|
|
40
|
-
referer: headers.referer,
|
|
46
|
+
referer: redactUrlQuery(headers.referer),
|
|
41
47
|
'x-forwarded-for': headers['x-forwarded-for'],
|
|
42
48
|
// Vercel headers
|
|
43
49
|
'x-vercel-id': headers['x-vercel-id'],
|
|
@@ -0,0 +1,37 @@
|
|
|
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 { mapPlainValues } from '@lowdefy/helpers';
|
|
18
|
+
|
|
19
|
+
// `received` is the one kept log field holding values fetched at runtime (a token
|
|
20
|
+
// from an earlier step sent as a header), which the by-value secret scrub
|
|
21
|
+
// cannot recognise, so credential-looking keys are masked by name instead.
|
|
22
|
+
const credentialKeyParts = ['authorization', 'token', 'secret', 'password', 'apikey', 'cookie'];
|
|
23
|
+
|
|
24
|
+
function isCredentialKey(key) {
|
|
25
|
+
const normalized = key.toLowerCase().replace(/[-_]/g, '');
|
|
26
|
+
return credentialKeyParts.some((part) => normalized.includes(part));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Shared references and cycles are copied as such, so the serializer walk still
|
|
30
|
+
// marks them '[Circular]'.
|
|
31
|
+
function maskCredentialKeys(value) {
|
|
32
|
+
return mapPlainValues(value, (item, key) =>
|
|
33
|
+
key !== undefined && isCredentialKey(key) ? '[REDACTED]' : item
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default maskCredentialKeys;
|
|
@@ -0,0 +1,55 @@
|
|
|
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 { lowdefyErrorNames, readErrorCodes } from '@lowdefy/errors';
|
|
18
|
+
|
|
19
|
+
import maskCredentialKeys from './maskCredentialKeys.js';
|
|
20
|
+
|
|
21
|
+
// Kept only on nodes named as a Lowdefy error class: libraries reuse some of
|
|
22
|
+
// these names for their own data - axios's `config` carries auth, params and
|
|
23
|
+
// headers - so keying on the field alone would let a library's copy through.
|
|
24
|
+
const lowdefyErrorFields = [
|
|
25
|
+
'configKey',
|
|
26
|
+
'source',
|
|
27
|
+
'config',
|
|
28
|
+
'handled',
|
|
29
|
+
'isLowdefyError',
|
|
30
|
+
'received',
|
|
31
|
+
'typeName',
|
|
32
|
+
'location',
|
|
33
|
+
'service',
|
|
34
|
+
'hint',
|
|
35
|
+
'methodName',
|
|
36
|
+
'metaData',
|
|
37
|
+
'blockId',
|
|
38
|
+
'pageId',
|
|
39
|
+
'isReject',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function projectErrorForLog(err) {
|
|
43
|
+
const { code, statusCode } = readErrorCodes(err);
|
|
44
|
+
const props = { name: err.name, message: err.message, stack: err.stack, code, statusCode };
|
|
45
|
+
if (lowdefyErrorNames.has(err.name)) {
|
|
46
|
+
lowdefyErrorFields.forEach((field) => {
|
|
47
|
+
props[field] = err[field];
|
|
48
|
+
});
|
|
49
|
+
props.received = maskCredentialKeys(err.received);
|
|
50
|
+
}
|
|
51
|
+
props.cause = err.cause;
|
|
52
|
+
return props;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default projectErrorForLog;
|