@lowdefy/server 5.6.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/{pages/_app.js → client/App.jsx} +26 -34
- package/client/Page.jsx +122 -0
- package/client/clientBuildImports.test.mjs +61 -0
- package/client/main.jsx +39 -0
- package/client/shouldReloadForBuild.js +48 -0
- package/client/shouldReloadForBuild.test.mjs +75 -0
- package/lib/build/app.js +8 -1
- package/lib/build/appMeta.js +8 -1
- package/lib/build/auth.js +8 -1
- package/lib/build/config.js +8 -1
- package/lib/build/i18n.js +8 -1
- package/lib/build/logger.js +8 -1
- package/lib/build/theme.js +8 -1
- package/lib/client/auth/{Auth.js → Auth.jsx} +10 -2
- package/lib/client/auth/{AuthConfigured.js → AuthConfigured.jsx} +21 -10
- package/lib/client/sentry/captureSentryError.js +1 -1
- package/lib/client/sentry/initSentryClient.js +1 -1
- package/lib/client/sentry/setSentryUser.js +1 -1
- package/lib/server/auth/{getAuthOptions.js → getAuthConfig.js} +7 -4
- package/{pages/api/auth/[...nextauth].js → lib/server/auth/session.js} +9 -15
- package/lib/server/auth/strategies.js +47 -0
- package/lib/server/log/createHandleError.js +10 -5
- package/lib/server/log/createHandleError.test.mjs +85 -0
- package/lib/server/log/createLogger.js +28 -1
- package/lib/server/log/createLogger.test.mjs +102 -0
- package/lib/server/log/logRequest.js +47 -45
- 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/{auth/getServerSession.js → log/serializeErrorForLog.js} +5 -8
- package/lib/server/log/serializeErrorForLog.test.mjs +325 -0
- package/lib/server/scrubSecrets.js +21 -0
- package/lib/server/sentry/captureSentryError.js +13 -1
- package/lib/server/sentry/captureSentryError.test.mjs +104 -0
- package/lib/server/sentry/initSentry.js +17 -4
- 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/lib/server/sentry/setSentryUser.js +1 -1
- package/lowdefy/createCustomPluginTypesMap.mjs +1 -0
- package/package.json +54 -42
- package/package.original.json +56 -44
- package/src/app.js +140 -0
- package/src/html/getAssets.js +45 -0
- package/src/html/renderPage.js +80 -0
- package/src/html/template.js +95 -0
- package/src/index.js +95 -0
- package/src/lib/getPathSegments.js +25 -0
- package/src/lib/safeScriptJson.js +38 -0
- package/src/middleware/apiContext.js +138 -0
- package/src/middleware/apiContext.test.mjs +118 -0
- package/src/middleware/errorHandler.js +54 -0
- package/src/middleware/sentry.js +33 -0
- package/{pages/api/agent/[...path].js → src/routes/agent.js} +20 -42
- package/src/routes/apiPage.js +40 -0
- package/src/routes/apiPage.test.mjs +59 -0
- package/src/routes/auth.js +38 -0
- package/{pages/api/client-error.js → src/routes/clientError.js} +14 -16
- package/src/routes/cron.js +44 -0
- package/src/routes/cronForward.js +47 -0
- package/src/routes/detached.js +47 -0
- package/src/routes/endpoints.js +71 -0
- package/src/routes/mcp.js +34 -0
- package/{pages/api/request/[...path].js → src/routes/request.js} +10 -10
- package/src/routes/usage.js +46 -0
- package/src/routes/websocket.js +46 -0
- package/vite.config.js +61 -0
- package/.eslintrc.yaml +0 -1
- package/lib/client/Page.js +0 -56
- package/lib/server/apiWrapper.js +0 -87
- package/lib/server/serverSidePropsWrapper.js +0 -66
- package/next.config.js +0 -34
- package/pages/404.js +0 -49
- package/pages/[[...pageId]].js +0 -85
- package/pages/_document.js +0 -108
- package/pages/api/endpoints/[...endpointId].js +0 -32
- package/pages/api/usage.js +0 -67
- /package/{postcss.config.js → postcss.config.cjs} +0 -0
|
@@ -14,23 +14,26 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import { getAuthConfig as getApiAuthConfig } from '@lowdefy/api';
|
|
18
18
|
import { getSecretsFromEnv } from '@lowdefy/node-utils';
|
|
19
19
|
|
|
20
20
|
import adapters from '../../../build/plugins/auth/adapters.js';
|
|
21
|
+
import appMeta from '../../build/appMeta.js';
|
|
21
22
|
import authJson from '../../build/auth.js';
|
|
23
|
+
import config from '../../build/config.js';
|
|
22
24
|
import callbacks from '../../../build/plugins/auth/callbacks.js';
|
|
23
25
|
import events from '../../../build/plugins/auth/events.js';
|
|
24
26
|
import providers from '../../../build/plugins/auth/providers.js';
|
|
25
27
|
|
|
26
|
-
function
|
|
27
|
-
return
|
|
28
|
+
function getAuthConfig({ logger }) {
|
|
29
|
+
return getApiAuthConfig({
|
|
28
30
|
appMeta,
|
|
29
31
|
authJson,
|
|
32
|
+
config,
|
|
30
33
|
logger,
|
|
31
34
|
plugins: { adapters, callbacks, events, providers },
|
|
32
35
|
secrets: getSecretsFromEnv(),
|
|
33
36
|
});
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
export default
|
|
39
|
+
export default getAuthConfig;
|
|
@@ -14,24 +14,18 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import
|
|
17
|
+
import { getAuthUser } from '@hono/auth-js';
|
|
18
18
|
|
|
19
|
-
import
|
|
20
|
-
import authJson from '../../../lib/build/auth.js';
|
|
19
|
+
import authJson from '../../build/auth.js';
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
// Replaces getServerSession.js — reads the session from the Hono context
|
|
22
|
+
// populated by the initAuthConfig middleware.
|
|
23
|
+
async function getSession(c) {
|
|
23
24
|
if (authJson.configured !== true) {
|
|
24
|
-
return
|
|
25
|
-
message: 'Auth not configured',
|
|
26
|
-
});
|
|
25
|
+
return undefined;
|
|
27
26
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
// https://next-auth.js.org/tutorials/avoid-corporate-link-checking-email-provider
|
|
31
|
-
if (req.method === 'HEAD') {
|
|
32
|
-
return res.status(200).end();
|
|
33
|
-
}
|
|
34
|
-
return NextAuth(req, res, context.authOptions);
|
|
27
|
+
const authUser = await getAuthUser(c);
|
|
28
|
+
return authUser?.session ?? undefined;
|
|
35
29
|
}
|
|
36
30
|
|
|
37
|
-
export default
|
|
31
|
+
export default getSession;
|
|
@@ -0,0 +1,47 @@
|
|
|
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, resolveStrategyCaller } 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 strategies from '../../../build/plugins/auth/strategies.js';
|
|
23
|
+
|
|
24
|
+
// Secrets are process-static env, so read once at module load rather than
|
|
25
|
+
// on every sessionless request.
|
|
26
|
+
const secrets = getSecretsFromEnv();
|
|
27
|
+
|
|
28
|
+
// API auth strategies (apiKey/jwt headers) let MCP and service clients that
|
|
29
|
+
// cannot hold a session cookie authenticate. Tried only when no session
|
|
30
|
+
// resolved; a match is synthesized as session.user so createAuthorize and
|
|
31
|
+
// _user work unchanged.
|
|
32
|
+
async function getStrategyCaller(c, logger) {
|
|
33
|
+
const authStrategies = getAuthStrategies({
|
|
34
|
+
appMeta,
|
|
35
|
+
authJson,
|
|
36
|
+
logger,
|
|
37
|
+
plugins: { strategies },
|
|
38
|
+
secrets,
|
|
39
|
+
});
|
|
40
|
+
return resolveStrategyCaller({
|
|
41
|
+
headers: c.req.raw.headers,
|
|
42
|
+
logger,
|
|
43
|
+
strategies: authStrategies,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default getStrategyCaller;
|
|
@@ -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,9 +128,9 @@ 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
|
-
'x-
|
|
133
|
+
'x-forwarded-for': headers['x-forwarded-for'],
|
|
131
134
|
// Vercel headers
|
|
132
135
|
'x-vercel-id': headers['x-vercel-id'],
|
|
133
136
|
'x-real-ip': headers['x-real-ip'],
|
|
@@ -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
|
+
});
|
|
@@ -15,10 +15,37 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { createNodeLogger } from '@lowdefy/logger/node';
|
|
18
|
+
import { type } from '@lowdefy/helpers';
|
|
19
|
+
|
|
20
|
+
import appMeta from '../../build/appMeta.js';
|
|
21
|
+
import scrubSecrets from '../scrubSecrets.js';
|
|
22
|
+
import serializeErrorForLog from './serializeErrorForLog.js';
|
|
23
|
+
|
|
24
|
+
// Deploy identity on every log line — pid/hostname are stripped, so these
|
|
25
|
+
// fields are what correlates a line to an app build across replicas.
|
|
26
|
+
const base = { pid: undefined, hostname: undefined };
|
|
27
|
+
if (!type.isNone(appMeta.name)) {
|
|
28
|
+
base.app_name = appMeta.name;
|
|
29
|
+
}
|
|
30
|
+
if (!type.isNone(appMeta.version)) {
|
|
31
|
+
base.app_version = appMeta.version;
|
|
32
|
+
}
|
|
33
|
+
if (!type.isNone(appMeta.gitSha)) {
|
|
34
|
+
base.git_sha = appMeta.gitSha;
|
|
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
|
+
}
|
|
40
|
+
|
|
18
41
|
const logger = createNodeLogger({
|
|
19
42
|
name: 'lowdefy_server',
|
|
20
43
|
level: process.env.LOWDEFY_LOG_LEVEL ?? 'info',
|
|
21
|
-
base
|
|
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 },
|
|
22
49
|
});
|
|
23
50
|
|
|
24
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,53 +14,55 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
import redactUrlQuery from './redactUrlQuery.js';
|
|
18
|
+
|
|
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).
|
|
24
|
+
function logRequest({ context, status, durationMs }) {
|
|
19
25
|
const { headers = {}, user = {} } = context;
|
|
20
|
-
context.logger.info(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
'cf-ray': headers['cf-ray'],
|
|
58
|
-
'cf-ipcountry': headers['cf-ipcountry'],
|
|
59
|
-
'cf-visitor': headers['cf-visitor'],
|
|
26
|
+
context.logger.info(
|
|
27
|
+
{
|
|
28
|
+
user: {
|
|
29
|
+
id: user.id,
|
|
30
|
+
roles: user.roles,
|
|
31
|
+
sub: user.sub,
|
|
32
|
+
session_id: user.session_id, // TODO: Implement session id
|
|
33
|
+
},
|
|
34
|
+
url: context.req.url,
|
|
35
|
+
method: context.req.method,
|
|
36
|
+
hostname: context.req.hostname,
|
|
37
|
+
status,
|
|
38
|
+
duration_ms: durationMs,
|
|
39
|
+
headers: {
|
|
40
|
+
'accept-language': headers['accept-language'],
|
|
41
|
+
'sec-ch-ua-mobile': headers['sec-ch-ua-mobile'],
|
|
42
|
+
'sec-ch-ua-platform': headers['sec-ch-ua-platform'],
|
|
43
|
+
'sec-ch-ua': headers['sec-ch-ua'],
|
|
44
|
+
'user-agent': headers['user-agent'],
|
|
45
|
+
host: headers.host,
|
|
46
|
+
referer: redactUrlQuery(headers.referer),
|
|
47
|
+
'x-forwarded-for': headers['x-forwarded-for'],
|
|
48
|
+
// Vercel headers
|
|
49
|
+
'x-vercel-id': headers['x-vercel-id'],
|
|
50
|
+
'x-real-ip': headers['x-real-ip'],
|
|
51
|
+
'x-vercel-ip-country': headers['x-vercel-ip-country'],
|
|
52
|
+
'x-vercel-ip-country-region': headers['x-vercel-ip-country-region'],
|
|
53
|
+
'x-vercel-ip-city': headers['x-vercel-ip-city'],
|
|
54
|
+
'x-vercel-ip-latitude': headers['x-vercel-ip-latitude'],
|
|
55
|
+
'x-vercel-ip-longitude': headers['x-vercel-ip-longitude'],
|
|
56
|
+
'x-vercel-ip-timezone': headers['x-vercel-ip-timezone'],
|
|
57
|
+
// Cloudflare headers
|
|
58
|
+
'cf-connecting-ip': headers['cf-connecting-ip'],
|
|
59
|
+
'cf-ray': headers['cf-ray'],
|
|
60
|
+
'cf-ipcountry': headers['cf-ipcountry'],
|
|
61
|
+
'cf-visitor': headers['cf-visitor'],
|
|
62
|
+
},
|
|
60
63
|
},
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// Next local? nextContext.locale, nextContext.locales, nextContext.defaultLocale
|
|
64
|
+
'request completed'
|
|
65
|
+
);
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
export default logRequest;
|
|
@@ -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;
|