@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
|
@@ -32,11 +32,17 @@ const lowdefyErrorFields = [
|
|
|
32
32
|
'location',
|
|
33
33
|
'service',
|
|
34
34
|
'hint',
|
|
35
|
+
'retryAfter',
|
|
35
36
|
'methodName',
|
|
36
37
|
'metaData',
|
|
37
38
|
'blockId',
|
|
38
39
|
'pageId',
|
|
39
40
|
'isReject',
|
|
41
|
+
'collection',
|
|
42
|
+
'connectionId',
|
|
43
|
+
'organizationId',
|
|
44
|
+
'field',
|
|
45
|
+
'endpointId',
|
|
40
46
|
];
|
|
41
47
|
|
|
42
48
|
function projectErrorForLog(err) {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { OperatorError, RequestError, UserError } from '@lowdefy/errors';
|
|
17
|
+
import { OperatorError, RequestError, ServiceError, UserError } from '@lowdefy/errors';
|
|
18
18
|
|
|
19
19
|
import maskCredentialKeys from './maskCredentialKeys.js';
|
|
20
20
|
import projectErrorForLog from './projectErrorForLog.js';
|
|
@@ -37,6 +37,7 @@ const allowedErrorKeys = new Set([
|
|
|
37
37
|
'location',
|
|
38
38
|
'service',
|
|
39
39
|
'hint',
|
|
40
|
+
'retryAfter',
|
|
40
41
|
'methodName',
|
|
41
42
|
'metaData',
|
|
42
43
|
'blockId',
|
|
@@ -210,6 +211,17 @@ test('serializeErrorForLog keeps UserError metaData, blockId, pageId and isRejec
|
|
|
210
211
|
});
|
|
211
212
|
});
|
|
212
213
|
|
|
214
|
+
test('serializeErrorForLog keeps the Retry-After of a rate-limited ServiceError', () => {
|
|
215
|
+
const cause = new Error('Too Many Requests');
|
|
216
|
+
cause.statusCode = 429;
|
|
217
|
+
cause.responseHeaders = { 'retry-after': '30' };
|
|
218
|
+
const serialized = serializeErrorForLog(
|
|
219
|
+
new ServiceError(undefined, { cause, service: 'claude' })
|
|
220
|
+
);
|
|
221
|
+
expect(serialized.statusCode).toBe(429);
|
|
222
|
+
expect(serialized.retryAfter).toBe('30');
|
|
223
|
+
});
|
|
224
|
+
|
|
213
225
|
test('serializeErrorForLog keeps Lowdefy fields on an error that has a Lowdefy class name but not the class', () => {
|
|
214
226
|
const error = new Error('Not allowed.');
|
|
215
227
|
error.name = 'AuthorizationError';
|
|
@@ -14,14 +14,14 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import * as Sentry from '@sentry/node';
|
|
18
17
|
import { type } from '@lowdefy/helpers';
|
|
19
18
|
|
|
20
19
|
import serializeErrorForLog from '../log/serializeErrorForLog.js';
|
|
20
|
+
import sentrySdk from './sentrySdk.js';
|
|
21
21
|
|
|
22
22
|
function captureSentryError({ error, context, configLocation }) {
|
|
23
|
-
|
|
24
|
-
if (!
|
|
23
|
+
const Sentry = sentrySdk.current;
|
|
24
|
+
if (!Sentry) {
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -45,6 +45,19 @@ function captureSentryError({ error, context, configLocation }) {
|
|
|
45
45
|
tags.isServiceError = error.isServiceError;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Fields a data-integrity fault carries, so it can be filtered and grouped
|
|
49
|
+
// in Sentry by where it happened.
|
|
50
|
+
['collection', 'connectionId', 'organizationId', 'endpointId'].forEach((field) => {
|
|
51
|
+
if (!type.isNone(error?.[field])) {
|
|
52
|
+
tags[field] = error[field];
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
if (tags.collection || tags.connectionId || tags.organizationId || tags.endpointId) {
|
|
56
|
+
tags.errorName = error.name;
|
|
57
|
+
}
|
|
58
|
+
const fingerprint =
|
|
59
|
+
error?.name === 'TenantIntegrityError' ? [error.name, error.collection ?? ''] : undefined;
|
|
60
|
+
|
|
48
61
|
// Add config location context
|
|
49
62
|
if (configLocation) {
|
|
50
63
|
extra.configLocation = configLocation;
|
|
@@ -63,6 +76,7 @@ function captureSentryError({ error, context, configLocation }) {
|
|
|
63
76
|
Sentry.captureException(error, {
|
|
64
77
|
tags,
|
|
65
78
|
extra,
|
|
79
|
+
...(fingerprint ? { fingerprint } : {}),
|
|
66
80
|
});
|
|
67
81
|
}
|
|
68
82
|
|
|
@@ -15,16 +15,17 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { jest } from '@jest/globals';
|
|
18
|
-
import { RequestError } from '@lowdefy/errors';
|
|
18
|
+
import { RequestError, TenantIntegrityError } from '@lowdefy/errors';
|
|
19
19
|
|
|
20
20
|
const captureException = jest.fn();
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
process.env.SENTRY_DSN = 'https://public@o0.ingest.sentry.io/1';
|
|
25
|
-
|
|
22
|
+
const { default: sentrySdk } = await import('./sentrySdk.js');
|
|
26
23
|
const { default: captureSentryError } = await import('./captureSentryError.js');
|
|
27
24
|
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
sentrySdk.current = { captureException };
|
|
27
|
+
});
|
|
28
|
+
|
|
28
29
|
function createAxiosError() {
|
|
29
30
|
const error = new Error('Request failed with status code 401');
|
|
30
31
|
error.name = 'AxiosError';
|
|
@@ -92,13 +93,36 @@ test('captureSentryError leaves out the requestId tag when the context has no re
|
|
|
92
93
|
expect(Object.keys(tags)).not.toContain('requestId');
|
|
93
94
|
});
|
|
94
95
|
|
|
95
|
-
test('captureSentryError does nothing when
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
captureSentryError({ error: new Error('Failed.'), context: { rid: 'req-1' } });
|
|
100
|
-
} finally {
|
|
101
|
-
process.env.SENTRY_DSN = dsn;
|
|
102
|
-
}
|
|
96
|
+
test('captureSentryError does nothing when Sentry was not started', () => {
|
|
97
|
+
sentrySdk.current = null;
|
|
98
|
+
captureSentryError({ error: new Error('Failed.'), context: { rid: 'req-1' } });
|
|
103
99
|
expect(captureException).not.toHaveBeenCalled();
|
|
104
100
|
});
|
|
101
|
+
|
|
102
|
+
test('captureSentryError tags integrity fields and fingerprints a TenantIntegrityError by collection', () => {
|
|
103
|
+
captureException.mockClear();
|
|
104
|
+
const error = new TenantIntegrityError('bad row', {
|
|
105
|
+
collection: 'contacts',
|
|
106
|
+
connectionId: 'contacts_conn',
|
|
107
|
+
organizationId: 'org_a',
|
|
108
|
+
endpointId: 'save',
|
|
109
|
+
});
|
|
110
|
+
captureSentryError({ error, context: { rid: 'req-2' } });
|
|
111
|
+
const [, options] = captureException.mock.calls[0];
|
|
112
|
+
expect(options.tags).toMatchObject({
|
|
113
|
+
collection: 'contacts',
|
|
114
|
+
connectionId: 'contacts_conn',
|
|
115
|
+
organizationId: 'org_a',
|
|
116
|
+
endpointId: 'save',
|
|
117
|
+
errorName: 'TenantIntegrityError',
|
|
118
|
+
});
|
|
119
|
+
expect(options.fingerprint).toEqual(['TenantIntegrityError', 'contacts']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('captureSentryError sets no fingerprint or integrity tags on an ordinary error', () => {
|
|
123
|
+
captureException.mockClear();
|
|
124
|
+
captureSentryError({ error: new Error('x'), context: { rid: 'req-3' } });
|
|
125
|
+
const [, options] = captureException.mock.calls[0];
|
|
126
|
+
expect(options.fingerprint).toBeUndefined();
|
|
127
|
+
expect(options.tags.errorName).toBeUndefined();
|
|
128
|
+
});
|
|
@@ -14,43 +14,64 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import * as Sentry from '@sentry/node';
|
|
18
|
-
|
|
19
17
|
import loggerConfig from '../../build/logger.js';
|
|
20
18
|
import scrubEvent from './scrubEvent.js';
|
|
19
|
+
import sentrySdk from './sentrySdk.js';
|
|
21
20
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
// Starts Sentry and returns its SDK, or null when Sentry is off. Nothing Sentry is imported when
|
|
22
|
+
// it is off. A server that traces requests runs @sentry/node, which sets up OpenTelemetry and
|
|
23
|
+
// hooks every module the app imports after it. A server that only reports errors runs the SDK's
|
|
24
|
+
// light mode, which has neither: on a serverless function that start-up cost lands on every cold
|
|
25
|
+
// start, so serverless functions report errors only unless logger.sentry.serverTracing is true.
|
|
26
|
+
// This module runs before the logger exists, so the caller logs that Sentry is on.
|
|
27
|
+
async function initSentryServer({ serverless = false } = {}) {
|
|
26
28
|
if (!process.env.SENTRY_DSN) {
|
|
27
|
-
return
|
|
29
|
+
return null;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
const sentryConfig = loggerConfig.sentry
|
|
32
|
+
const sentryConfig = loggerConfig.sentry ?? {};
|
|
31
33
|
|
|
32
|
-
// No-op if server logging is explicitly disabled
|
|
33
34
|
if (sentryConfig.server === false) {
|
|
34
|
-
return
|
|
35
|
+
return null;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
const tracing = sentryConfig.serverTracing ?? !serverless;
|
|
39
|
+
const options = {
|
|
38
40
|
dsn: process.env.SENTRY_DSN,
|
|
39
41
|
environment: sentryConfig.environment || process.env.NODE_ENV || 'production',
|
|
40
|
-
|
|
42
|
+
// A serverless function stays busy until the flush after each request ends, and the SDK
|
|
43
|
+
// sends a client report (a count of dropped and unsampled events) with every flush that has
|
|
44
|
+
// one, so each request would wait on an upload. A long-running server sends them on a timer.
|
|
45
|
+
sendClientReports: !serverless,
|
|
41
46
|
// Every path out to Sentry gets the log's secret scrub: error events with their cause
|
|
42
47
|
// chain, and the spans and breadcrumbs that carry outgoing URLs with their query strings.
|
|
43
48
|
beforeSend: (event) => scrubEvent(event),
|
|
44
49
|
beforeSendTransaction: (event) => scrubEvent(event),
|
|
45
50
|
beforeSendSpan: (span) => scrubEvent(span),
|
|
46
51
|
beforeBreadcrumb: (breadcrumb) => scrubEvent(breadcrumb),
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Incoming request bodies are attached to error events by default - an endpoint payload,
|
|
55
|
+
// a sign-in password - and no value scrub can recognise them. Each SDK's Http integration is
|
|
56
|
+
// passed by name, replacing its default one rather than adding a second.
|
|
57
|
+
let Sentry;
|
|
58
|
+
if (tracing) {
|
|
59
|
+
Sentry = await import('@sentry/node');
|
|
60
|
+
Sentry.init({
|
|
61
|
+
...options,
|
|
62
|
+
tracesSampleRate: sentryConfig.tracesSampleRate ?? 0.1,
|
|
63
|
+
integrations: [Sentry.httpIntegration({ maxIncomingRequestBodySize: 'none' })],
|
|
64
|
+
});
|
|
65
|
+
} else {
|
|
66
|
+
Sentry = await import('@sentry/node-core/light');
|
|
67
|
+
Sentry.init({
|
|
68
|
+
...options,
|
|
69
|
+
integrations: [Sentry.httpIntegration({ maxRequestBodySize: 'none' })],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
52
72
|
|
|
53
|
-
|
|
73
|
+
sentrySdk.current = Sentry;
|
|
74
|
+
return Sentry;
|
|
54
75
|
}
|
|
55
76
|
|
|
56
77
|
export default initSentryServer;
|
|
@@ -0,0 +1,165 @@
|
|
|
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 http from 'node:http';
|
|
18
|
+
|
|
19
|
+
import { jest } from '@jest/globals';
|
|
20
|
+
import * as RealLight from '@sentry/node-core/light';
|
|
21
|
+
|
|
22
|
+
const envelopes = [];
|
|
23
|
+
function createStubTransport() {
|
|
24
|
+
return {
|
|
25
|
+
send: async (envelope) => {
|
|
26
|
+
envelopes.push(envelope);
|
|
27
|
+
return {};
|
|
28
|
+
},
|
|
29
|
+
flush: async () => true,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const imported = { node: false, light: false };
|
|
34
|
+
const nodeInit = jest.fn();
|
|
35
|
+
const nodeHttpIntegration = jest.fn(() => ({ name: 'Http' }));
|
|
36
|
+
|
|
37
|
+
// @sentry/node is a stub that records its import, so a test can tell which SDK started. The light
|
|
38
|
+
// SDK is real, with its network transport swapped for memory.
|
|
39
|
+
jest.unstable_mockModule('@sentry/node', () => {
|
|
40
|
+
imported.node = true;
|
|
41
|
+
return { init: nodeInit, httpIntegration: nodeHttpIntegration };
|
|
42
|
+
});
|
|
43
|
+
jest.unstable_mockModule('@sentry/node-core/light', () => {
|
|
44
|
+
imported.light = true;
|
|
45
|
+
return {
|
|
46
|
+
...RealLight,
|
|
47
|
+
init: (options) => RealLight.init({ ...options, transport: createStubTransport }),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
const loggerConfig = { sentry: {} };
|
|
51
|
+
jest.unstable_mockModule('../../build/logger.js', () => ({ default: loggerConfig }));
|
|
52
|
+
|
|
53
|
+
const { default: initSentryServer } = await import('./initSentry.js');
|
|
54
|
+
const { default: sentrySdk } = await import('./sentrySdk.js');
|
|
55
|
+
const { default: captureSentryError } = await import('./captureSentryError.js');
|
|
56
|
+
const { default: setSentryUser } = await import('./setSentryUser.js');
|
|
57
|
+
|
|
58
|
+
function sentEvents() {
|
|
59
|
+
return envelopes.flatMap(([, items]) =>
|
|
60
|
+
items.filter(([header]) => header.type === 'event').map(([, payload]) => payload)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
envelopes.length = 0;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(async () => {
|
|
69
|
+
await RealLight.close();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('initSentry imports no Sentry SDK and returns null when SENTRY_DSN is not set', async () => {
|
|
73
|
+
delete process.env.SENTRY_DSN;
|
|
74
|
+
expect(await initSentryServer({ serverless: true })).toBe(null);
|
|
75
|
+
expect(imported).toEqual({ node: false, light: false });
|
|
76
|
+
expect(sentrySdk.current).toBe(null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('initSentry on a serverless function reports errors with the light SDK, without tracing or client reports', async () => {
|
|
80
|
+
process.env.SENTRY_DSN = 'https://public@o0.ingest.sentry.io/1';
|
|
81
|
+
const sentry = await initSentryServer({ serverless: true });
|
|
82
|
+
expect(imported).toEqual({ node: false, light: true });
|
|
83
|
+
expect(sentrySdk.current).toBe(sentry);
|
|
84
|
+
const client = RealLight.getClient();
|
|
85
|
+
expect(client).toBeInstanceOf(RealLight.LightNodeClient);
|
|
86
|
+
const options = client.getOptions();
|
|
87
|
+
expect(options.tracesSampleRate).toBeUndefined();
|
|
88
|
+
expect(options.sendClientReports).toBe(false);
|
|
89
|
+
expect(options.integrations.filter(({ name }) => name === 'Http')).toHaveLength(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// A request handler that, like the Vercel function entry, reads the body with for await, sets the
|
|
93
|
+
// caller from a header and, when asked, waits and then captures an error, so a second request
|
|
94
|
+
// sets its own user while the first is still running.
|
|
95
|
+
async function handleRequest(req, res) {
|
|
96
|
+
const chunks = [];
|
|
97
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
98
|
+
setSentryUser({ user: { id: req.headers['x-user'] } });
|
|
99
|
+
if (req.headers['x-fail'] === 'true') {
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
101
|
+
captureSentryError({ error: new Error('Endpoint failed.'), context: { rid: 'req-1' } });
|
|
102
|
+
}
|
|
103
|
+
res.end('{}');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function startServer() {
|
|
107
|
+
const server = http.createServer((req, res) => {
|
|
108
|
+
handleRequest(req, res);
|
|
109
|
+
});
|
|
110
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
111
|
+
return server;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function post({ port, user, fail }) {
|
|
115
|
+
return fetch(`http://127.0.0.1:${port}/api/endpoints/sign-in`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'content-type': 'application/json', 'x-user': user, 'x-fail': String(fail) },
|
|
118
|
+
body: JSON.stringify({ email: 'user@example.com', password: 'planted-user-password' }),
|
|
119
|
+
}).then((response) => response.text());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
test('a serverless request that captures nothing leaves nothing for the flush to send', async () => {
|
|
123
|
+
const server = await startServer();
|
|
124
|
+
try {
|
|
125
|
+
await post({ port: server.address().port, user: 'user-a', fail: false });
|
|
126
|
+
} finally {
|
|
127
|
+
await new Promise((resolve) => server.close(resolve));
|
|
128
|
+
}
|
|
129
|
+
expect(await sentrySdk.current.flush(2000)).toBe(true);
|
|
130
|
+
expect(envelopes).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a serverless request that captures an error sends it on flush, with its own user and no request body', async () => {
|
|
134
|
+
const server = await startServer();
|
|
135
|
+
const { port } = server.address();
|
|
136
|
+
try {
|
|
137
|
+
const failing = post({ port, user: 'user-a', fail: true });
|
|
138
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
139
|
+
await post({ port, user: 'user-b', fail: false });
|
|
140
|
+
await failing;
|
|
141
|
+
} finally {
|
|
142
|
+
await new Promise((resolve) => server.close(resolve));
|
|
143
|
+
}
|
|
144
|
+
await sentrySdk.current.flush(2000);
|
|
145
|
+
const events = sentEvents();
|
|
146
|
+
expect(events).toHaveLength(1);
|
|
147
|
+
expect(events[0].exception.values[0].value).toEqual('Endpoint failed.');
|
|
148
|
+
expect(events[0].user).toEqual({ id: 'user-a' });
|
|
149
|
+
expect(events[0].request.url).toMatch(/\/api\/endpoints\/sign-in$/);
|
|
150
|
+
expect(events[0].request.data).toBeUndefined();
|
|
151
|
+
expect(JSON.stringify(events[0])).not.toContain('planted-user-password');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('initSentry on a serverless function with serverTracing runs @sentry/node with tracing and no client reports', async () => {
|
|
155
|
+
loggerConfig.sentry = { serverTracing: true, tracesSampleRate: 0.2 };
|
|
156
|
+
const sentry = await initSentryServer({ serverless: true });
|
|
157
|
+
expect(imported.node).toBe(true);
|
|
158
|
+
expect(sentrySdk.current).toBe(sentry);
|
|
159
|
+
expect(nodeHttpIntegration.mock.calls).toEqual([[{ maxIncomingRequestBodySize: 'none' }]]);
|
|
160
|
+
expect(nodeInit).toHaveBeenCalledTimes(1);
|
|
161
|
+
const [options] = nodeInit.mock.calls[0];
|
|
162
|
+
expect(options.tracesSampleRate).toBe(0.2);
|
|
163
|
+
expect(options.sendClientReports).toBe(false);
|
|
164
|
+
expect(options.integrations).toEqual([{ name: 'Http' }]);
|
|
165
|
+
});
|
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
import http from 'node:http';
|
|
18
18
|
|
|
19
19
|
import { jest } from '@jest/globals';
|
|
20
|
+
import { Hono } from 'hono';
|
|
21
|
+
import { serve } from '@hono/node-server';
|
|
20
22
|
import * as RealSentry from '@sentry/node';
|
|
23
|
+
import { markCredential, runInCredentialScope } from '@lowdefy/node-utils';
|
|
21
24
|
|
|
22
25
|
const secret = 'planted/secret+value=1';
|
|
23
26
|
|
|
@@ -45,14 +48,18 @@ jest.unstable_mockModule('@sentry/node', () => ({
|
|
|
45
48
|
return integration;
|
|
46
49
|
},
|
|
47
50
|
}));
|
|
48
|
-
jest.unstable_mockModule('../../build/logger.js', () => ({
|
|
51
|
+
jest.unstable_mockModule('../../build/logger.js', () => ({
|
|
52
|
+
default: { sentry: { tracesSampleRate: 1 } },
|
|
53
|
+
}));
|
|
49
54
|
|
|
50
55
|
process.env.SENTRY_DSN = 'https://public@o0.ingest.sentry.io/1';
|
|
51
56
|
process.env.LOWDEFY_SECRET_API_KEY = secret;
|
|
52
57
|
|
|
53
58
|
const { default: initSentryServer } = await import('./initSentry.js');
|
|
59
|
+
const { default: sentrySdk } = await import('./sentrySdk.js');
|
|
60
|
+
const { default: sentryMiddleware } = await import('../../../src/middleware/sentry.js');
|
|
54
61
|
|
|
55
|
-
const
|
|
62
|
+
const sentry = await initSentryServer();
|
|
56
63
|
const client = RealSentry.getClient();
|
|
57
64
|
const options = client.getOptions();
|
|
58
65
|
|
|
@@ -62,6 +69,22 @@ function sentEvents() {
|
|
|
62
69
|
);
|
|
63
70
|
}
|
|
64
71
|
|
|
72
|
+
function sentTransactions() {
|
|
73
|
+
return envelopes.flatMap(([, items]) =>
|
|
74
|
+
items.filter(([header]) => header.type === 'transaction').map(([, payload]) => payload)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function listen(server) {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
if (server.listening) {
|
|
81
|
+
resolve(server.address().port);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
server.once('listening', () => resolve(server.address().port));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
65
88
|
beforeEach(() => {
|
|
66
89
|
envelopes.length = 0;
|
|
67
90
|
});
|
|
@@ -70,9 +93,15 @@ afterAll(async () => {
|
|
|
70
93
|
await RealSentry.close();
|
|
71
94
|
});
|
|
72
95
|
|
|
73
|
-
test('initSentry initializes Sentry when SENTRY_DSN is set', () => {
|
|
74
|
-
expect(enabled).toBe(true);
|
|
96
|
+
test('initSentry initializes Sentry when SENTRY_DSN is set and makes its SDK the server one', () => {
|
|
75
97
|
expect(client).toBeDefined();
|
|
98
|
+
expect(sentry.captureException).toBe(RealSentry.captureException);
|
|
99
|
+
expect(sentrySdk.current).toBe(sentry);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('initSentry traces requests and keeps client reports on a long-running server', () => {
|
|
103
|
+
expect(options.tracesSampleRate).toBe(1);
|
|
104
|
+
expect(options.sendClientReports).toBe(true);
|
|
76
105
|
});
|
|
77
106
|
|
|
78
107
|
test('initSentry installs exactly one Http integration, the one that attaches no request bodies', () => {
|
|
@@ -235,3 +264,102 @@ test('an error event captured during a request with a JSON body carries no reque
|
|
|
235
264
|
expect(events[0].request.data).toBeUndefined();
|
|
236
265
|
expect(JSON.stringify(events[0])).not.toContain('planted-user-password');
|
|
237
266
|
});
|
|
267
|
+
|
|
268
|
+
test('a transaction sent after the response is scrubbed of the credentials its request marked', async () => {
|
|
269
|
+
const markedKey = 'runtime-made-app-key-0001';
|
|
270
|
+
const upstream = http.createServer((req, res) => res.end('{}'));
|
|
271
|
+
upstream.listen(0, '127.0.0.1');
|
|
272
|
+
const upstreamPort = await listen(upstream);
|
|
273
|
+
|
|
274
|
+
// The two middlewares the production app runs first, in its order.
|
|
275
|
+
const app = new Hono();
|
|
276
|
+
app.use('*', (c, next) => runInCredentialScope(next));
|
|
277
|
+
app.use('*', sentryMiddleware());
|
|
278
|
+
app.post('/api/endpoints/create-app', async (c) => {
|
|
279
|
+
markCredential(markedKey);
|
|
280
|
+
const upstreamResponse = await fetch(
|
|
281
|
+
`http://127.0.0.1:${upstreamPort}/v1/apps?key=${markedKey}`
|
|
282
|
+
);
|
|
283
|
+
await upstreamResponse.text();
|
|
284
|
+
return c.json({ ok: true });
|
|
285
|
+
});
|
|
286
|
+
// @hono/node-server writes the response after the middleware chain has returned, so the
|
|
287
|
+
// request's root span ends, and its transaction is sent, outside the credential scope.
|
|
288
|
+
const server = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' });
|
|
289
|
+
const port = await listen(server);
|
|
290
|
+
try {
|
|
291
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/endpoints/create-app`, {
|
|
292
|
+
method: 'POST',
|
|
293
|
+
});
|
|
294
|
+
await response.text();
|
|
295
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
296
|
+
} finally {
|
|
297
|
+
await new Promise((resolve) => server.close(resolve));
|
|
298
|
+
await new Promise((resolve) => upstream.close(resolve));
|
|
299
|
+
}
|
|
300
|
+
await RealSentry.flush();
|
|
301
|
+
const transaction = sentTransactions().find(
|
|
302
|
+
({ transaction: name }) => name === 'POST /api/endpoints/create-app'
|
|
303
|
+
);
|
|
304
|
+
expect(transaction).toBeDefined();
|
|
305
|
+
const clientSpan = transaction.spans.find(({ op }) => op === 'http.client');
|
|
306
|
+
expect(clientSpan.data['url.full']).toEqual(
|
|
307
|
+
`http://127.0.0.1:${upstreamPort}/v1/apps?key=[REDACTED]`
|
|
308
|
+
);
|
|
309
|
+
expect(clientSpan.data['http.query']).toEqual('key=[REDACTED]');
|
|
310
|
+
expect(JSON.stringify(transaction)).not.toContain(markedKey);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('concurrent requests each scrub their transaction with their own marked credentials', async () => {
|
|
314
|
+
const keyA = 'runtime-made-app-key-aaaa';
|
|
315
|
+
const keyB = 'runtime-made-app-key-bbbb';
|
|
316
|
+
const upstream = http.createServer((req, res) => res.end('{}'));
|
|
317
|
+
upstream.listen(0, '127.0.0.1');
|
|
318
|
+
const upstreamPort = await listen(upstream);
|
|
319
|
+
|
|
320
|
+
let markedB;
|
|
321
|
+
const bMarked = new Promise((resolve) => {
|
|
322
|
+
markedB = resolve;
|
|
323
|
+
});
|
|
324
|
+
const app = new Hono();
|
|
325
|
+
app.use('*', (c, next) => runInCredentialScope(next));
|
|
326
|
+
app.use('*', sentryMiddleware());
|
|
327
|
+
// The first request's middleware runs before the second's, and its fetch after the second
|
|
328
|
+
// has marked its own key, so a scrub shared between them would miss the first key.
|
|
329
|
+
app.post('/api/endpoints/first', async (c) => {
|
|
330
|
+
markCredential(keyA);
|
|
331
|
+
await bMarked;
|
|
332
|
+
await (await fetch(`http://127.0.0.1:${upstreamPort}/v1/first?key=${keyA}`)).text();
|
|
333
|
+
return c.json({ ok: true });
|
|
334
|
+
});
|
|
335
|
+
app.post('/api/endpoints/second', async (c) => {
|
|
336
|
+
markCredential(keyB);
|
|
337
|
+
markedB();
|
|
338
|
+
await (await fetch(`http://127.0.0.1:${upstreamPort}/v1/second?key=${keyB}`)).text();
|
|
339
|
+
return c.json({ ok: true });
|
|
340
|
+
});
|
|
341
|
+
const server = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' });
|
|
342
|
+
const port = await listen(server);
|
|
343
|
+
try {
|
|
344
|
+
const first = fetch(`http://127.0.0.1:${port}/api/endpoints/first`, { method: 'POST' });
|
|
345
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
346
|
+
const second = fetch(`http://127.0.0.1:${port}/api/endpoints/second`, { method: 'POST' });
|
|
347
|
+
await Promise.all([(await first).text(), (await second).text()]);
|
|
348
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
349
|
+
} finally {
|
|
350
|
+
await new Promise((resolve) => server.close(resolve));
|
|
351
|
+
await new Promise((resolve) => upstream.close(resolve));
|
|
352
|
+
}
|
|
353
|
+
await RealSentry.flush();
|
|
354
|
+
const transactions = sentTransactions();
|
|
355
|
+
const firstTransaction = transactions.find(
|
|
356
|
+
({ transaction: name }) => name === 'POST /api/endpoints/first'
|
|
357
|
+
);
|
|
358
|
+
const secondTransaction = transactions.find(
|
|
359
|
+
({ transaction: name }) => name === 'POST /api/endpoints/second'
|
|
360
|
+
);
|
|
361
|
+
expect(JSON.stringify(firstTransaction)).toContain('/v1/first?key=[REDACTED]');
|
|
362
|
+
expect(JSON.stringify(secondTransaction)).toContain('/v1/second?key=[REDACTED]');
|
|
363
|
+
expect(JSON.stringify([firstTransaction, secondTransaction])).not.toContain(keyA);
|
|
364
|
+
expect(JSON.stringify([firstTransaction, secondTransaction])).not.toContain(keyB);
|
|
365
|
+
});
|
|
@@ -18,12 +18,23 @@ import { mapPlainValues, type } from '@lowdefy/helpers';
|
|
|
18
18
|
|
|
19
19
|
import scrubSecrets from '../scrubSecrets.js';
|
|
20
20
|
|
|
21
|
+
function returnUnchanged(value) {
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
21
25
|
// Class instances are left as they are: Sentry normalizes those before beforeSend
|
|
22
26
|
// runs, so the event hook still reaches their strings. Breadcrumbs reach
|
|
23
27
|
// beforeBreadcrumb un-normalized - a console breadcrumb holds the logged arguments
|
|
24
28
|
// as they are - so the walk must be cycle-safe, which mapPlainValues is.
|
|
29
|
+
// An event also carries its request's credential scrub (the Sentry middleware puts it on the
|
|
30
|
+
// request's isolation scope), which reaches the credentials that request marked when the event
|
|
31
|
+
// is sent after the request's scope has ended, as a transaction is.
|
|
25
32
|
function scrubEvent(value) {
|
|
26
|
-
|
|
33
|
+
const scrubRequestCredentials =
|
|
34
|
+
value?.sdkProcessingMetadata?.scrubRequestCredentials ?? returnUnchanged;
|
|
35
|
+
return mapPlainValues(value, (item) =>
|
|
36
|
+
type.isString(item) ? scrubRequestCredentials(scrubSecrets(item)) : item
|
|
37
|
+
);
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
export default scrubEvent;
|
|
@@ -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
|
+
// The Sentry SDK the server started, or null while Sentry is off. initSentryServer sets it once,
|
|
18
|
+
// before the app loads. The SDK is imported only when Sentry is on, and which SDK depends on
|
|
19
|
+
// whether the server traces requests, so nothing else imports a Sentry package: every server
|
|
20
|
+
// module that reports to Sentry reads it from here.
|
|
21
|
+
const sentrySdk = { current: null };
|
|
22
|
+
|
|
23
|
+
export default sentrySdk;
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import
|
|
17
|
+
import sentrySdk from './sentrySdk.js';
|
|
18
18
|
|
|
19
19
|
function setSentryUser({ user, sentryConfig }) {
|
|
20
|
-
|
|
21
|
-
if (!
|
|
20
|
+
const Sentry = sentrySdk.current;
|
|
21
|
+
if (!Sentry) {
|
|
22
22
|
return;
|
|
23
23
|
}
|
|
24
24
|
|
package/lowdefy/build.mjs
CHANGED
|
@@ -54,7 +54,9 @@ async function run() {
|
|
|
54
54
|
customTypesMap,
|
|
55
55
|
directories,
|
|
56
56
|
logger,
|
|
57
|
+
pluginInstallFollows: process.env.LOWDEFY_BUILD_PLUGIN_INSTALL_FOLLOWS === 'true',
|
|
57
58
|
refResolver: argv.refResolver || process.env.LOWDEFY_BUILD_REF_RESOLVER,
|
|
59
|
+
skipPluginChecks: process.env.LOWDEFY_BUILD_SKIP_PLUGIN_CHECKS === 'true',
|
|
58
60
|
});
|
|
59
61
|
}
|
|
60
62
|
|