@lowdefy/server 6.0.0 → 7.0.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 +101 -24
- package/client/clearReloadedForTypes.js +29 -0
- package/client/clientBuildImports.test.mjs +60 -0
- 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 +224 -39
- 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 -7
- 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 +16 -4
- package/lib/server/log/createHandleError.test.mjs +102 -0
- package/lib/server/log/createLogger.js +10 -0
- package/lib/server/log/createLogger.test.mjs +117 -0
- package/lib/server/log/logRequest.js +12 -4
- package/lib/server/log/maskCredentialKeys.js +37 -0
- package/lib/server/log/projectErrorForLog.js +61 -0
- package/lib/server/log/redactUrlQuery.js +75 -0
- package/lib/server/log/redactUrlQuery.test.mjs +73 -0
- package/lib/server/{auth/session.js → log/serializeErrorForLog.js} +5 -11
- package/lib/server/log/serializeErrorForLog.test.mjs +337 -0
- package/lib/server/scrubSecrets.js +21 -0
- package/lib/server/sentry/captureSentryError.js +29 -3
- package/lib/server/sentry/captureSentryError.test.mjs +128 -0
- package/lib/server/sentry/initSentry.js +46 -14
- package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
- package/lib/server/sentry/initSentry.test.mjs +365 -0
- package/lib/server/sentry/scrubEvent.js +40 -0
- package/lib/server/sentry/scrubEvent.test.mjs +88 -0
- package/lib/server/sentry/sentrySdk.js +23 -0
- package/lib/server/sentry/setSentryUser.js +3 -3
- package/lowdefy/build.mjs +1 -0
- package/lowdefy/build.test.mjs +68 -0
- package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
- package/package.json +47 -42
- package/package.original.json +47 -42
- 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 -19
- 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 +68 -13
- package/src/middleware/apiContext.test.mjs +178 -0
- 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/src/middleware/noStore.js +27 -0
- 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 +54 -9
- package/src/routes/apiPage.test.mjs +143 -0
- 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 +4 -2
- 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/lib/server/apiWrapper.js +0 -87
|
@@ -0,0 +1,337 @@
|
|
|
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 { OperatorError, RequestError, ServiceError, UserError } from '@lowdefy/errors';
|
|
18
|
+
|
|
19
|
+
import maskCredentialKeys from './maskCredentialKeys.js';
|
|
20
|
+
import projectErrorForLog from './projectErrorForLog.js';
|
|
21
|
+
import serializeErrorForLog from './serializeErrorForLog.js';
|
|
22
|
+
|
|
23
|
+
const allowedErrorKeys = new Set([
|
|
24
|
+
'name',
|
|
25
|
+
'message',
|
|
26
|
+
'stack',
|
|
27
|
+
'code',
|
|
28
|
+
'statusCode',
|
|
29
|
+
'cause',
|
|
30
|
+
'configKey',
|
|
31
|
+
'source',
|
|
32
|
+
'config',
|
|
33
|
+
'handled',
|
|
34
|
+
'isLowdefyError',
|
|
35
|
+
'received',
|
|
36
|
+
'typeName',
|
|
37
|
+
'location',
|
|
38
|
+
'service',
|
|
39
|
+
'hint',
|
|
40
|
+
'retryAfter',
|
|
41
|
+
'methodName',
|
|
42
|
+
'metaData',
|
|
43
|
+
'blockId',
|
|
44
|
+
'pageId',
|
|
45
|
+
'isReject',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// Every serialized error node carries `stack`; free-form values under
|
|
49
|
+
// `received` in these fixtures never do.
|
|
50
|
+
function collectErrorNodes(value, nodes = []) {
|
|
51
|
+
if (value === null || typeof value !== 'object') return nodes;
|
|
52
|
+
if (!Array.isArray(value) && Object.hasOwn(value, 'stack')) nodes.push(value);
|
|
53
|
+
Object.values(value).forEach((item) => collectErrorNodes(item, nodes));
|
|
54
|
+
return nodes;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function createAxiosError() {
|
|
58
|
+
const error = new Error('Request failed with status code 401');
|
|
59
|
+
error.name = 'AxiosError';
|
|
60
|
+
error.code = 'ERR_BAD_REQUEST';
|
|
61
|
+
error.config = {
|
|
62
|
+
auth: { username: 'svc-user', password: 'planted-config-password' },
|
|
63
|
+
params: { api_key: 'planted-config-param' },
|
|
64
|
+
headers: { Authorization: 'Bearer planted-config-header' },
|
|
65
|
+
baseURL: 'https://api.example.com',
|
|
66
|
+
};
|
|
67
|
+
error.request = { path: '/v1/items' };
|
|
68
|
+
error.response = { status: 401, data: { detail: 'planted-response-body' } };
|
|
69
|
+
return error;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test('serializeErrorForLog drops axios config, request and response and keeps code and statusCode', () => {
|
|
73
|
+
const serialized = serializeErrorForLog(createAxiosError());
|
|
74
|
+
expect(serialized).toEqual({
|
|
75
|
+
name: 'AxiosError',
|
|
76
|
+
message: 'Request failed with status code 401',
|
|
77
|
+
stack: expect.any(String),
|
|
78
|
+
code: 'ERR_BAD_REQUEST',
|
|
79
|
+
statusCode: 401,
|
|
80
|
+
});
|
|
81
|
+
expect(JSON.stringify(serialized)).not.toMatch(/planted/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('serializeErrorForLog drops axios fields on the cause of a RequestError', () => {
|
|
85
|
+
const error = new RequestError(undefined, {
|
|
86
|
+
cause: createAxiosError(),
|
|
87
|
+
typeName: 'AxiosHttp',
|
|
88
|
+
configKey: 'key-1',
|
|
89
|
+
});
|
|
90
|
+
const serialized = serializeErrorForLog(error);
|
|
91
|
+
expect(serialized.code).toEqual('ERR_BAD_REQUEST');
|
|
92
|
+
expect(serialized.statusCode).toEqual(401);
|
|
93
|
+
expect(serialized.typeName).toEqual('AxiosHttp');
|
|
94
|
+
expect(serialized.configKey).toEqual('key-1');
|
|
95
|
+
expect(serialized.cause).toEqual({
|
|
96
|
+
name: 'AxiosError',
|
|
97
|
+
message: 'Request failed with status code 401',
|
|
98
|
+
stack: expect.any(String),
|
|
99
|
+
code: 'ERR_BAD_REQUEST',
|
|
100
|
+
statusCode: 401,
|
|
101
|
+
});
|
|
102
|
+
expect(JSON.stringify(serialized)).not.toMatch(/planted/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('serializeErrorForLog keeps a pg error code and drops detail, schema, table and constraint', () => {
|
|
106
|
+
const error = new Error('duplicate key value violates unique constraint "users_email_key"');
|
|
107
|
+
error.code = '23505';
|
|
108
|
+
error.detail = 'Key (email)=(planted@example.com) already exists.';
|
|
109
|
+
error.schema = 'public';
|
|
110
|
+
error.table = 'users';
|
|
111
|
+
error.constraint = 'users_email_key';
|
|
112
|
+
const serialized = serializeErrorForLog(error);
|
|
113
|
+
expect(serialized).toEqual({
|
|
114
|
+
name: 'Error',
|
|
115
|
+
message: 'duplicate key value violates unique constraint "users_email_key"',
|
|
116
|
+
stack: expect.any(String),
|
|
117
|
+
code: '23505',
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Built by hand in Node's shape: a TypeError thrown by `new URL` comes from
|
|
122
|
+
// the main realm, which is not `instanceof Error` inside Jest's VM context.
|
|
123
|
+
test('serializeErrorForLog drops input from a Node ERR_INVALID_URL error', () => {
|
|
124
|
+
const error = new TypeError('Invalid URL');
|
|
125
|
+
error.code = 'ERR_INVALID_URL';
|
|
126
|
+
error.input = 'https://user:planted@example';
|
|
127
|
+
const serialized = serializeErrorForLog(error);
|
|
128
|
+
expect(serialized.code).toEqual('ERR_INVALID_URL');
|
|
129
|
+
expect(Object.keys(serialized)).not.toContain('input');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('serializeErrorForLog masks credential keys in received and keeps the other received properties', () => {
|
|
133
|
+
const received = {
|
|
134
|
+
url: 'https://api.example.com/items',
|
|
135
|
+
method: 'get',
|
|
136
|
+
headers: { Authorization: 'Bearer runtime-token-1', Accept: 'application/json' },
|
|
137
|
+
params: { access_token: 'runtime-token-2', page: 2 },
|
|
138
|
+
'x-api-key': 'runtime-token-3',
|
|
139
|
+
};
|
|
140
|
+
const error = new RequestError('Upstream failed.', {
|
|
141
|
+
typeName: 'AxiosHttp',
|
|
142
|
+
received,
|
|
143
|
+
cause: new OperatorError('Inner failed.', { typeName: '_step', received }),
|
|
144
|
+
});
|
|
145
|
+
const expectedReceived = {
|
|
146
|
+
url: 'https://api.example.com/items',
|
|
147
|
+
method: 'get',
|
|
148
|
+
headers: { Authorization: '[REDACTED]', Accept: 'application/json' },
|
|
149
|
+
params: { access_token: '[REDACTED]', page: 2 },
|
|
150
|
+
'x-api-key': '[REDACTED]',
|
|
151
|
+
};
|
|
152
|
+
const serialized = serializeErrorForLog(error);
|
|
153
|
+
expect(serialized.received).toEqual(expectedReceived);
|
|
154
|
+
expect(serialized.cause.received).toEqual(expectedReceived);
|
|
155
|
+
expect(JSON.stringify(serialized)).not.toMatch(/runtime-token/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('serializeErrorForLog does not mutate the received it masks', () => {
|
|
159
|
+
const received = { headers: { Authorization: 'Bearer runtime-token' } };
|
|
160
|
+
serializeErrorForLog(new RequestError('Upstream failed.', { received }));
|
|
161
|
+
expect(received).toEqual({ headers: { Authorization: 'Bearer runtime-token' } });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('serializeErrorForLog projects an Error nested seven levels deep inside a cause property', () => {
|
|
165
|
+
let nested = createAxiosError();
|
|
166
|
+
for (let level = 7; level >= 1; level -= 1) {
|
|
167
|
+
nested = new RequestError(`Level ${level}.`, { received: nested });
|
|
168
|
+
}
|
|
169
|
+
// An explicit received, so the top node does not inherit the cause's.
|
|
170
|
+
const error = new RequestError('Top.', {
|
|
171
|
+
received: { page: 1 },
|
|
172
|
+
cause: new OperatorError('Cause.', { received: nested }),
|
|
173
|
+
});
|
|
174
|
+
const serialized = serializeErrorForLog(error);
|
|
175
|
+
|
|
176
|
+
let node = serialized.cause.received;
|
|
177
|
+
for (let level = 1; level <= 7; level += 1) {
|
|
178
|
+
expect(node.message).toEqual(`Level ${level}.`);
|
|
179
|
+
node = node.received;
|
|
180
|
+
}
|
|
181
|
+
expect(node.name).toEqual('AxiosError');
|
|
182
|
+
expect(node.statusCode).toEqual(401);
|
|
183
|
+
|
|
184
|
+
const errorNodes = collectErrorNodes(serialized);
|
|
185
|
+
expect(errorNodes).toHaveLength(10);
|
|
186
|
+
errorNodes.forEach((errorNode) => {
|
|
187
|
+
Object.keys(errorNode).forEach((key) => {
|
|
188
|
+
expect(allowedErrorKeys.has(key)).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
expect(JSON.stringify(serialized)).not.toMatch(/planted/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('serializeErrorForLog keeps UserError metaData, blockId, pageId and isReject', () => {
|
|
195
|
+
const error = new UserError('Please fill in the form.', {
|
|
196
|
+
blockId: 'submit_button',
|
|
197
|
+
pageId: 'orders',
|
|
198
|
+
isReject: true,
|
|
199
|
+
metaData: { step: 'validate' },
|
|
200
|
+
});
|
|
201
|
+
const serialized = serializeErrorForLog(error);
|
|
202
|
+
expect(serialized).toEqual({
|
|
203
|
+
name: 'UserError',
|
|
204
|
+
message: 'Please fill in the form.',
|
|
205
|
+
stack: expect.any(String),
|
|
206
|
+
isLowdefyError: true,
|
|
207
|
+
blockId: 'submit_button',
|
|
208
|
+
pageId: 'orders',
|
|
209
|
+
isReject: true,
|
|
210
|
+
metaData: { step: 'validate' },
|
|
211
|
+
});
|
|
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
|
+
|
|
225
|
+
test('serializeErrorForLog keeps Lowdefy fields on an error that has a Lowdefy class name but not the class', () => {
|
|
226
|
+
const error = new Error('Not allowed.');
|
|
227
|
+
error.name = 'AuthorizationError';
|
|
228
|
+
error.configKey = 'key-2';
|
|
229
|
+
error.isLowdefyError = true;
|
|
230
|
+
error.internalState = 'dropped';
|
|
231
|
+
const serialized = serializeErrorForLog(error);
|
|
232
|
+
expect(serialized).toEqual({
|
|
233
|
+
name: 'AuthorizationError',
|
|
234
|
+
message: 'Not allowed.',
|
|
235
|
+
stack: expect.any(String),
|
|
236
|
+
configKey: 'key-2',
|
|
237
|
+
isLowdefyError: true,
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('projectErrorForLog drops Lowdefy field names from a non-Lowdefy error', () => {
|
|
242
|
+
const error = new Error('Library failure.');
|
|
243
|
+
error.received = { password: 'planted' };
|
|
244
|
+
error.source = 'library-internal';
|
|
245
|
+
error.location = 'somewhere';
|
|
246
|
+
const projected = projectErrorForLog(error);
|
|
247
|
+
expect(projected.received).toBeUndefined();
|
|
248
|
+
expect(projected.source).toBeUndefined();
|
|
249
|
+
expect(projected.location).toBeUndefined();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('projectErrorForLog reads statusCode from the node and passes the cause through for the walk', () => {
|
|
253
|
+
const cause = new Error('Inner.');
|
|
254
|
+
const error = new Error('Outer.', { cause });
|
|
255
|
+
error.status = 503;
|
|
256
|
+
const projected = projectErrorForLog(error);
|
|
257
|
+
expect(projected.statusCode).toEqual(503);
|
|
258
|
+
expect(projected.cause).toBe(cause);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('maskCredentialKeys masks keys containing each credential word after lower-casing and removing - and _', () => {
|
|
262
|
+
expect(
|
|
263
|
+
maskCredentialKeys({
|
|
264
|
+
Authorization: 'a',
|
|
265
|
+
access_token: 'b',
|
|
266
|
+
'x-api-key': 'c',
|
|
267
|
+
client_secret: 'd',
|
|
268
|
+
Password: 'e',
|
|
269
|
+
Cookie: 'f',
|
|
270
|
+
API_KEY: 'g',
|
|
271
|
+
tokenCount: 3,
|
|
272
|
+
name: 'kept',
|
|
273
|
+
})
|
|
274
|
+
).toEqual({
|
|
275
|
+
Authorization: '[REDACTED]',
|
|
276
|
+
access_token: '[REDACTED]',
|
|
277
|
+
'x-api-key': '[REDACTED]',
|
|
278
|
+
client_secret: '[REDACTED]',
|
|
279
|
+
Password: '[REDACTED]',
|
|
280
|
+
Cookie: '[REDACTED]',
|
|
281
|
+
API_KEY: '[REDACTED]',
|
|
282
|
+
tokenCount: '[REDACTED]',
|
|
283
|
+
name: 'kept',
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('maskCredentialKeys replaces a whole object value under a credential key', () => {
|
|
288
|
+
expect(maskCredentialKeys({ tokens: { access: 'a', refresh: 'b' } })).toEqual({
|
|
289
|
+
tokens: '[REDACTED]',
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('maskCredentialKeys recurses into arrays and nested objects', () => {
|
|
294
|
+
expect(
|
|
295
|
+
maskCredentialKeys({ items: [{ secret: 'a', id: 1 }, [{ cookie: 'b' }], 'plain'] })
|
|
296
|
+
).toEqual({ items: [{ secret: '[REDACTED]', id: 1 }, [{ cookie: '[REDACTED]' }], 'plain'] });
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('maskCredentialKeys returns primitives, Dates, Errors and class instances unchanged', () => {
|
|
300
|
+
class Client {
|
|
301
|
+
constructor() {
|
|
302
|
+
this.password = 'internal';
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const date = new Date(0);
|
|
306
|
+
const error = new Error('x');
|
|
307
|
+
const client = new Client();
|
|
308
|
+
expect(maskCredentialKeys('text')).toEqual('text');
|
|
309
|
+
expect(maskCredentialKeys(undefined)).toBeUndefined();
|
|
310
|
+
expect(maskCredentialKeys(date)).toBe(date);
|
|
311
|
+
expect(maskCredentialKeys(error)).toBe(error);
|
|
312
|
+
expect(maskCredentialKeys(client)).toBe(client);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('serializeErrorForLog marks a class instance in received by type name only', () => {
|
|
316
|
+
class Client {
|
|
317
|
+
constructor() {
|
|
318
|
+
this.password = 'planted';
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const serialized = serializeErrorForLog(
|
|
322
|
+
new RequestError('Failed.', { received: { client: new Client() } })
|
|
323
|
+
);
|
|
324
|
+
expect(serialized.received).toEqual({ client: '[Object: Client]' });
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('serializeErrorForLog marks a cycle in received as circular', () => {
|
|
328
|
+
const received = { name: 'loop' };
|
|
329
|
+
received.self = received;
|
|
330
|
+
const serialized = serializeErrorForLog(new RequestError('Failed.', { received }));
|
|
331
|
+
expect(serialized.received).toEqual({ name: 'loop', self: '[Circular]' });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('serializeErrorForLog returns a non-Error value unchanged', () => {
|
|
335
|
+
expect(serializeErrorForLog('plain string')).toEqual('plain string');
|
|
336
|
+
expect(serializeErrorForLog({ message: 'object' })).toEqual({ message: 'object' });
|
|
337
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
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 { createSecretScrubber, getSecretsFromEnv } from '@lowdefy/node-utils';
|
|
18
|
+
|
|
19
|
+
const scrubSecrets = createSecretScrubber({ secrets: getSecretsFromEnv() });
|
|
20
|
+
|
|
21
|
+
export default scrubSecrets;
|
|
@@ -14,11 +14,14 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import
|
|
17
|
+
import { type } from '@lowdefy/helpers';
|
|
18
|
+
|
|
19
|
+
import serializeErrorForLog from '../log/serializeErrorForLog.js';
|
|
20
|
+
import sentrySdk from './sentrySdk.js';
|
|
18
21
|
|
|
19
22
|
function captureSentryError({ error, context, configLocation }) {
|
|
20
|
-
|
|
21
|
-
if (!
|
|
23
|
+
const Sentry = sentrySdk.current;
|
|
24
|
+
if (!Sentry) {
|
|
22
25
|
return;
|
|
23
26
|
}
|
|
24
27
|
|
|
@@ -30,6 +33,10 @@ function captureSentryError({ error, context, configLocation }) {
|
|
|
30
33
|
tags.pageId = context.pageId;
|
|
31
34
|
}
|
|
32
35
|
|
|
36
|
+
if (!type.isNone(context?.rid)) {
|
|
37
|
+
tags.requestId = context.rid;
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
if (error?.blockId) {
|
|
34
41
|
tags.blockId = error.blockId;
|
|
35
42
|
}
|
|
@@ -38,6 +45,19 @@ function captureSentryError({ error, context, configLocation }) {
|
|
|
38
45
|
tags.isServiceError = error.isServiceError;
|
|
39
46
|
}
|
|
40
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
|
+
|
|
41
61
|
// Add config location context
|
|
42
62
|
if (configLocation) {
|
|
43
63
|
extra.configLocation = configLocation;
|
|
@@ -48,9 +68,15 @@ function captureSentryError({ error, context, configLocation }) {
|
|
|
48
68
|
extra.configKey = error.configKey;
|
|
49
69
|
}
|
|
50
70
|
|
|
71
|
+
// Sentry's own serialization of the exception carries only names, messages and stacks, so
|
|
72
|
+
// the log's projection is what gives Sentry the error's other fields - the same ones the log
|
|
73
|
+
// keeps, with the library fields it drops left out.
|
|
74
|
+
extra.error = serializeErrorForLog(error);
|
|
75
|
+
|
|
51
76
|
Sentry.captureException(error, {
|
|
52
77
|
tags,
|
|
53
78
|
extra,
|
|
79
|
+
...(fingerprint ? { fingerprint } : {}),
|
|
54
80
|
});
|
|
55
81
|
}
|
|
56
82
|
|
|
@@ -0,0 +1,128 @@
|
|
|
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 { RequestError, TenantIntegrityError } from '@lowdefy/errors';
|
|
19
|
+
|
|
20
|
+
const captureException = jest.fn();
|
|
21
|
+
|
|
22
|
+
const { default: sentrySdk } = await import('./sentrySdk.js');
|
|
23
|
+
const { default: captureSentryError } = await import('./captureSentryError.js');
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
sentrySdk.current = { captureException };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function createAxiosError() {
|
|
30
|
+
const error = new Error('Request failed with status code 401');
|
|
31
|
+
error.name = 'AxiosError';
|
|
32
|
+
error.code = 'ERR_BAD_REQUEST';
|
|
33
|
+
error.config = {
|
|
34
|
+
auth: { username: 'svc-user', password: 'planted-config-password' },
|
|
35
|
+
params: { api_key: 'planted-config-param' },
|
|
36
|
+
headers: { Authorization: 'Bearer planted-config-header' },
|
|
37
|
+
baseURL: 'https://api.example.com',
|
|
38
|
+
};
|
|
39
|
+
error.response = { status: 401, data: { detail: 'planted-response-body' } };
|
|
40
|
+
return error;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createRequestError() {
|
|
44
|
+
return new RequestError(undefined, {
|
|
45
|
+
cause: createAxiosError(),
|
|
46
|
+
typeName: 'AxiosHttp',
|
|
47
|
+
configKey: 'key-1',
|
|
48
|
+
received: {
|
|
49
|
+
url: 'https://api.example.com/items',
|
|
50
|
+
headers: { Authorization: 'Bearer runtime-token', Accept: 'application/json' },
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test('captureSentryError sends the log projection as extra.error, without axios config and with received credential keys masked', () => {
|
|
56
|
+
const error = createRequestError();
|
|
57
|
+
captureSentryError({ error, context: { rid: 'req-1', pageId: 'orders' } });
|
|
58
|
+
expect(captureException).toHaveBeenCalledTimes(1);
|
|
59
|
+
const [captured, { extra }] = captureException.mock.calls[0];
|
|
60
|
+
expect(captured).toBe(error);
|
|
61
|
+
expect(extra.error.typeName).toEqual('AxiosHttp');
|
|
62
|
+
expect(extra.error.statusCode).toEqual(401);
|
|
63
|
+
expect(extra.error.received).toEqual({
|
|
64
|
+
url: 'https://api.example.com/items',
|
|
65
|
+
headers: { Authorization: '[REDACTED]', Accept: 'application/json' },
|
|
66
|
+
});
|
|
67
|
+
expect(extra.error.cause).toEqual({
|
|
68
|
+
name: 'AxiosError',
|
|
69
|
+
message: 'Request failed with status code 401',
|
|
70
|
+
stack: expect.any(String),
|
|
71
|
+
code: 'ERR_BAD_REQUEST',
|
|
72
|
+
statusCode: 401,
|
|
73
|
+
});
|
|
74
|
+
expect(JSON.stringify(extra)).not.toMatch(/planted|runtime-token/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('captureSentryError tags the request id and keeps configKey and configLocation in extra', () => {
|
|
78
|
+
const configLocation = { source: 'pages/orders.yaml:12', config: 'root.pages[0]' };
|
|
79
|
+
captureSentryError({
|
|
80
|
+
error: createRequestError(),
|
|
81
|
+
context: { rid: 'req-1', pageId: 'orders' },
|
|
82
|
+
configLocation,
|
|
83
|
+
});
|
|
84
|
+
const [, { tags, extra }] = captureException.mock.calls[0];
|
|
85
|
+
expect(tags).toEqual({ pageId: 'orders', requestId: 'req-1' });
|
|
86
|
+
expect(extra.configKey).toEqual('key-1');
|
|
87
|
+
expect(extra.configLocation).toEqual(configLocation);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('captureSentryError leaves out the requestId tag when the context has no request id', () => {
|
|
91
|
+
captureSentryError({ error: new Error('Failed.'), context: {} });
|
|
92
|
+
const [, { tags }] = captureException.mock.calls[0];
|
|
93
|
+
expect(Object.keys(tags)).not.toContain('requestId');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('captureSentryError does nothing when Sentry was not started', () => {
|
|
97
|
+
sentrySdk.current = null;
|
|
98
|
+
captureSentryError({ error: new Error('Failed.'), context: { rid: 'req-1' } });
|
|
99
|
+
expect(captureException).not.toHaveBeenCalled();
|
|
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,32 +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';
|
|
18
|
+
import scrubEvent from './scrubEvent.js';
|
|
19
|
+
import sentrySdk from './sentrySdk.js';
|
|
20
20
|
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
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 } = {}) {
|
|
25
28
|
if (!process.env.SENTRY_DSN) {
|
|
26
|
-
return
|
|
29
|
+
return null;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
const sentryConfig = loggerConfig.sentry
|
|
32
|
+
const sentryConfig = loggerConfig.sentry ?? {};
|
|
30
33
|
|
|
31
|
-
// No-op if server logging is explicitly disabled
|
|
32
34
|
if (sentryConfig.server === false) {
|
|
33
|
-
return
|
|
35
|
+
return null;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
const tracing = sentryConfig.serverTracing ?? !serverless;
|
|
39
|
+
const options = {
|
|
37
40
|
dsn: process.env.SENTRY_DSN,
|
|
38
41
|
environment: sentryConfig.environment || process.env.NODE_ENV || 'production',
|
|
39
|
-
|
|
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,
|
|
46
|
+
// Every path out to Sentry gets the log's secret scrub: error events with their cause
|
|
47
|
+
// chain, and the spans and breadcrumbs that carry outgoing URLs with their query strings.
|
|
48
|
+
beforeSend: (event) => scrubEvent(event),
|
|
49
|
+
beforeSendTransaction: (event) => scrubEvent(event),
|
|
50
|
+
beforeSendSpan: (span) => scrubEvent(span),
|
|
51
|
+
beforeBreadcrumb: (breadcrumb) => scrubEvent(breadcrumb),
|
|
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
|
+
}
|
|
41
72
|
|
|
42
|
-
|
|
73
|
+
sentrySdk.current = Sentry;
|
|
74
|
+
return Sentry;
|
|
43
75
|
}
|
|
44
76
|
|
|
45
77
|
export default initSentryServer;
|