@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.
Files changed (79) hide show
  1. package/{pages/_app.js → client/App.jsx} +26 -34
  2. package/client/Page.jsx +122 -0
  3. package/client/clientBuildImports.test.mjs +61 -0
  4. package/client/main.jsx +39 -0
  5. package/client/shouldReloadForBuild.js +48 -0
  6. package/client/shouldReloadForBuild.test.mjs +75 -0
  7. package/lib/build/app.js +8 -1
  8. package/lib/build/appMeta.js +8 -1
  9. package/lib/build/auth.js +8 -1
  10. package/lib/build/config.js +8 -1
  11. package/lib/build/i18n.js +8 -1
  12. package/lib/build/logger.js +8 -1
  13. package/lib/build/theme.js +8 -1
  14. package/lib/client/auth/{Auth.js → Auth.jsx} +10 -2
  15. package/lib/client/auth/{AuthConfigured.js → AuthConfigured.jsx} +21 -10
  16. package/lib/client/sentry/captureSentryError.js +1 -1
  17. package/lib/client/sentry/initSentryClient.js +1 -1
  18. package/lib/client/sentry/setSentryUser.js +1 -1
  19. package/lib/server/auth/{getAuthOptions.js → getAuthConfig.js} +7 -4
  20. package/{pages/api/auth/[...nextauth].js → lib/server/auth/session.js} +9 -15
  21. package/lib/server/auth/strategies.js +47 -0
  22. package/lib/server/log/createHandleError.js +10 -5
  23. package/lib/server/log/createHandleError.test.mjs +85 -0
  24. package/lib/server/log/createLogger.js +28 -1
  25. package/lib/server/log/createLogger.test.mjs +102 -0
  26. package/lib/server/log/logRequest.js +47 -45
  27. package/lib/server/log/maskCredentialKeys.js +37 -0
  28. package/lib/server/log/projectErrorForLog.js +55 -0
  29. package/lib/server/log/redactUrlQuery.js +75 -0
  30. package/lib/server/log/redactUrlQuery.test.mjs +73 -0
  31. package/lib/server/{auth/getServerSession.js → log/serializeErrorForLog.js} +5 -8
  32. package/lib/server/log/serializeErrorForLog.test.mjs +325 -0
  33. package/lib/server/scrubSecrets.js +21 -0
  34. package/lib/server/sentry/captureSentryError.js +13 -1
  35. package/lib/server/sentry/captureSentryError.test.mjs +104 -0
  36. package/lib/server/sentry/initSentry.js +17 -4
  37. package/lib/server/sentry/initSentry.test.mjs +237 -0
  38. package/lib/server/sentry/scrubEvent.js +29 -0
  39. package/lib/server/sentry/scrubEvent.test.mjs +88 -0
  40. package/lib/server/sentry/setSentryUser.js +1 -1
  41. package/lowdefy/createCustomPluginTypesMap.mjs +1 -0
  42. package/package.json +54 -42
  43. package/package.original.json +56 -44
  44. package/src/app.js +140 -0
  45. package/src/html/getAssets.js +45 -0
  46. package/src/html/renderPage.js +80 -0
  47. package/src/html/template.js +95 -0
  48. package/src/index.js +95 -0
  49. package/src/lib/getPathSegments.js +25 -0
  50. package/src/lib/safeScriptJson.js +38 -0
  51. package/src/middleware/apiContext.js +138 -0
  52. package/src/middleware/apiContext.test.mjs +118 -0
  53. package/src/middleware/errorHandler.js +54 -0
  54. package/src/middleware/sentry.js +33 -0
  55. package/{pages/api/agent/[...path].js → src/routes/agent.js} +20 -42
  56. package/src/routes/apiPage.js +40 -0
  57. package/src/routes/apiPage.test.mjs +59 -0
  58. package/src/routes/auth.js +38 -0
  59. package/{pages/api/client-error.js → src/routes/clientError.js} +14 -16
  60. package/src/routes/cron.js +44 -0
  61. package/src/routes/cronForward.js +47 -0
  62. package/src/routes/detached.js +47 -0
  63. package/src/routes/endpoints.js +71 -0
  64. package/src/routes/mcp.js +34 -0
  65. package/{pages/api/request/[...path].js → src/routes/request.js} +10 -10
  66. package/src/routes/usage.js +46 -0
  67. package/src/routes/websocket.js +46 -0
  68. package/vite.config.js +61 -0
  69. package/.eslintrc.yaml +0 -1
  70. package/lib/client/Page.js +0 -56
  71. package/lib/server/apiWrapper.js +0 -87
  72. package/lib/server/serverSidePropsWrapper.js +0 -66
  73. package/next.config.js +0 -34
  74. package/pages/404.js +0 -49
  75. package/pages/[[...pageId]].js +0 -85
  76. package/pages/_document.js +0 -108
  77. package/pages/api/endpoints/[...endpointId].js +0 -32
  78. package/pages/api/usage.js +0 -67
  79. /package/{postcss.config.js → postcss.config.cjs} +0 -0
@@ -0,0 +1,75 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ // Query parameters that carry a credential rather than a filter: the
18
+ // magic-link and reset tokens, the OAuth authorization code and signed state,
19
+ // signed-URL signatures, one-time passcodes. Matched case-insensitively.
20
+ const secretParamNames = new Set([
21
+ 'code',
22
+ 'state',
23
+ 'sig',
24
+ 'otp',
25
+ 'nonce',
26
+ 'auth',
27
+ 'apikey',
28
+ 'api_key',
29
+ 'api-key',
30
+ 'session',
31
+ 'sessionid',
32
+ 'session_id',
33
+ ]);
34
+
35
+ // Names that contain one of these are credentials whatever the prefix or
36
+ // suffix - access_token, X-Amz-Signature, client_secret, X-Amz-Credential.
37
+ const secretParamFragments = ['token', 'secret', 'password', 'signature', 'credential'];
38
+
39
+ function isSecretParam(name) {
40
+ const lower = name.toLowerCase();
41
+ if (secretParamNames.has(lower)) {
42
+ return true;
43
+ }
44
+ return secretParamFragments.some((fragment) => lower.includes(fragment));
45
+ }
46
+
47
+ // A URL fit for an access log. The query string stays - it is often what
48
+ // distinguishes one page view from the next - but the value of every
49
+ // credential-like parameter is replaced with "[redacted]". The browser echoes
50
+ // the page's full URL back as the Referer of every fetch that page makes, so
51
+ // the magic-link verify page or the OAuth consent page would otherwise write
52
+ // its token or signed state into the log store on each request. The fragment
53
+ // is dropped: a browser never sends it in a Referer, and implicit-flow tokens
54
+ // travel there. Returns undefined for a missing value. Works on relative URLs
55
+ // too, so a malformed or path-only header is still visible.
56
+ function redactUrlQuery(url) {
57
+ if (typeof url !== 'string' || url === '') {
58
+ return undefined;
59
+ }
60
+ const withoutFragment = url.split('#')[0];
61
+ const queryStart = withoutFragment.indexOf('?');
62
+ if (queryStart === -1) {
63
+ return withoutFragment;
64
+ }
65
+ const base = withoutFragment.slice(0, queryStart);
66
+ const params = new URLSearchParams(withoutFragment.slice(queryStart + 1));
67
+ for (const name of params.keys()) {
68
+ if (isSecretParam(name)) {
69
+ params.set(name, '[redacted]');
70
+ }
71
+ }
72
+ return `${base}?${params.toString()}`;
73
+ }
74
+
75
+ export default redactUrlQuery;
@@ -0,0 +1,73 @@
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 redactUrlQuery from './redactUrlQuery.js';
18
+
19
+ test('redactUrlQuery redacts the magic-link token and keeps the other parameters', () => {
20
+ expect(
21
+ redactUrlQuery(
22
+ 'https://app.example.com/api/auth/magic-link/verify?token=abc123&callbackURL=%2Fhome'
23
+ )
24
+ ).toEqual(
25
+ 'https://app.example.com/api/auth/magic-link/verify?token=%5Bredacted%5D&callbackURL=%2Fhome'
26
+ );
27
+ });
28
+
29
+ test('redactUrlQuery redacts the signed OAuth state, sig and code', () => {
30
+ expect(
31
+ redactUrlQuery('https://app.example.com/oauth-consent?client_id=app&state=xyz&sig=abc&code=q')
32
+ ).toEqual(
33
+ 'https://app.example.com/oauth-consent?client_id=app&state=%5Bredacted%5D&sig=%5Bredacted%5D&code=%5Bredacted%5D'
34
+ );
35
+ });
36
+
37
+ test('redactUrlQuery matches credential names case-insensitively and by fragment', () => {
38
+ expect(
39
+ redactUrlQuery(
40
+ 'https://bucket.s3.amazonaws.com/uploads/file.pdf?X-Amz-Credential=AKIA&X-Amz-Signature=deadbeef&X-Amz-Expires=300&access_token=t&client_secret=s'
41
+ )
42
+ ).toEqual(
43
+ 'https://bucket.s3.amazonaws.com/uploads/file.pdf?X-Amz-Credential=%5Bredacted%5D&X-Amz-Signature=%5Bredacted%5D&X-Amz-Expires=300&access_token=%5Bredacted%5D&client_secret=%5Bredacted%5D'
44
+ );
45
+ });
46
+
47
+ test('redactUrlQuery leaves an ordinary page query untouched', () => {
48
+ expect(redactUrlQuery('https://app.example.com/orders?status=open&page=2')).toEqual(
49
+ 'https://app.example.com/orders?status=open&page=2'
50
+ );
51
+ expect(redactUrlQuery('https://app.example.com/orders')).toEqual(
52
+ 'https://app.example.com/orders'
53
+ );
54
+ });
55
+
56
+ test('redactUrlQuery drops the fragment', () => {
57
+ expect(redactUrlQuery('https://app.example.com/callback?page=1#access_token=abc')).toEqual(
58
+ 'https://app.example.com/callback?page=1'
59
+ );
60
+ });
61
+
62
+ test('redactUrlQuery returns undefined for a missing header', () => {
63
+ expect(redactUrlQuery(undefined)).toBeUndefined();
64
+ expect(redactUrlQuery(null)).toBeUndefined();
65
+ expect(redactUrlQuery('')).toBeUndefined();
66
+ });
67
+
68
+ test('redactUrlQuery redacts a value that is not an absolute URL', () => {
69
+ expect(redactUrlQuery('/oauth-select-organization?state=xyz&sig=abc&org=acme')).toEqual(
70
+ '/oauth-select-organization?state=%5Bredacted%5D&sig=%5Bredacted%5D&org=acme'
71
+ );
72
+ expect(redactUrlQuery('not a url')).toEqual('not a url');
73
+ });
@@ -14,15 +14,12 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { getServerSession as getNextAuthServerSession } from 'next-auth/next';
17
+ import { serializer } from '@lowdefy/helpers';
18
18
 
19
- import authJson from '../../build/auth.js';
19
+ import projectErrorForLog from './projectErrorForLog.js';
20
20
 
21
- function getServerSession({ authOptions, req, res }) {
22
- if (authJson.configured === true) {
23
- return getNextAuthServerSession(req, res, authOptions);
24
- }
25
- return undefined;
21
+ function serializeErrorForLog(error) {
22
+ return serializer.serialize(error, { projectError: projectErrorForLog })?.['~e'] ?? error;
26
23
  }
27
24
 
28
- export default getServerSession;
25
+ export default serializeErrorForLog;
@@ -0,0 +1,325 @@
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, 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
+ 'methodName',
41
+ 'metaData',
42
+ 'blockId',
43
+ 'pageId',
44
+ 'isReject',
45
+ ]);
46
+
47
+ // Every serialized error node carries `stack`; free-form values under
48
+ // `received` in these fixtures never do.
49
+ function collectErrorNodes(value, nodes = []) {
50
+ if (value === null || typeof value !== 'object') return nodes;
51
+ if (!Array.isArray(value) && Object.hasOwn(value, 'stack')) nodes.push(value);
52
+ Object.values(value).forEach((item) => collectErrorNodes(item, nodes));
53
+ return nodes;
54
+ }
55
+
56
+ function createAxiosError() {
57
+ const error = new Error('Request failed with status code 401');
58
+ error.name = 'AxiosError';
59
+ error.code = 'ERR_BAD_REQUEST';
60
+ error.config = {
61
+ auth: { username: 'svc-user', password: 'planted-config-password' },
62
+ params: { api_key: 'planted-config-param' },
63
+ headers: { Authorization: 'Bearer planted-config-header' },
64
+ baseURL: 'https://api.example.com',
65
+ };
66
+ error.request = { path: '/v1/items' };
67
+ error.response = { status: 401, data: { detail: 'planted-response-body' } };
68
+ return error;
69
+ }
70
+
71
+ test('serializeErrorForLog drops axios config, request and response and keeps code and statusCode', () => {
72
+ const serialized = serializeErrorForLog(createAxiosError());
73
+ expect(serialized).toEqual({
74
+ name: 'AxiosError',
75
+ message: 'Request failed with status code 401',
76
+ stack: expect.any(String),
77
+ code: 'ERR_BAD_REQUEST',
78
+ statusCode: 401,
79
+ });
80
+ expect(JSON.stringify(serialized)).not.toMatch(/planted/);
81
+ });
82
+
83
+ test('serializeErrorForLog drops axios fields on the cause of a RequestError', () => {
84
+ const error = new RequestError(undefined, {
85
+ cause: createAxiosError(),
86
+ typeName: 'AxiosHttp',
87
+ configKey: 'key-1',
88
+ });
89
+ const serialized = serializeErrorForLog(error);
90
+ expect(serialized.code).toEqual('ERR_BAD_REQUEST');
91
+ expect(serialized.statusCode).toEqual(401);
92
+ expect(serialized.typeName).toEqual('AxiosHttp');
93
+ expect(serialized.configKey).toEqual('key-1');
94
+ expect(serialized.cause).toEqual({
95
+ name: 'AxiosError',
96
+ message: 'Request failed with status code 401',
97
+ stack: expect.any(String),
98
+ code: 'ERR_BAD_REQUEST',
99
+ statusCode: 401,
100
+ });
101
+ expect(JSON.stringify(serialized)).not.toMatch(/planted/);
102
+ });
103
+
104
+ test('serializeErrorForLog keeps a pg error code and drops detail, schema, table and constraint', () => {
105
+ const error = new Error('duplicate key value violates unique constraint "users_email_key"');
106
+ error.code = '23505';
107
+ error.detail = 'Key (email)=(planted@example.com) already exists.';
108
+ error.schema = 'public';
109
+ error.table = 'users';
110
+ error.constraint = 'users_email_key';
111
+ const serialized = serializeErrorForLog(error);
112
+ expect(serialized).toEqual({
113
+ name: 'Error',
114
+ message: 'duplicate key value violates unique constraint "users_email_key"',
115
+ stack: expect.any(String),
116
+ code: '23505',
117
+ });
118
+ });
119
+
120
+ // Built by hand in Node's shape: a TypeError thrown by `new URL` comes from
121
+ // the main realm, which is not `instanceof Error` inside Jest's VM context.
122
+ test('serializeErrorForLog drops input from a Node ERR_INVALID_URL error', () => {
123
+ const error = new TypeError('Invalid URL');
124
+ error.code = 'ERR_INVALID_URL';
125
+ error.input = 'https://user:planted@example';
126
+ const serialized = serializeErrorForLog(error);
127
+ expect(serialized.code).toEqual('ERR_INVALID_URL');
128
+ expect(Object.keys(serialized)).not.toContain('input');
129
+ });
130
+
131
+ test('serializeErrorForLog masks credential keys in received and keeps the other received properties', () => {
132
+ const received = {
133
+ url: 'https://api.example.com/items',
134
+ method: 'get',
135
+ headers: { Authorization: 'Bearer runtime-token-1', Accept: 'application/json' },
136
+ params: { access_token: 'runtime-token-2', page: 2 },
137
+ 'x-api-key': 'runtime-token-3',
138
+ };
139
+ const error = new RequestError('Upstream failed.', {
140
+ typeName: 'AxiosHttp',
141
+ received,
142
+ cause: new OperatorError('Inner failed.', { typeName: '_step', received }),
143
+ });
144
+ const expectedReceived = {
145
+ url: 'https://api.example.com/items',
146
+ method: 'get',
147
+ headers: { Authorization: '[REDACTED]', Accept: 'application/json' },
148
+ params: { access_token: '[REDACTED]', page: 2 },
149
+ 'x-api-key': '[REDACTED]',
150
+ };
151
+ const serialized = serializeErrorForLog(error);
152
+ expect(serialized.received).toEqual(expectedReceived);
153
+ expect(serialized.cause.received).toEqual(expectedReceived);
154
+ expect(JSON.stringify(serialized)).not.toMatch(/runtime-token/);
155
+ });
156
+
157
+ test('serializeErrorForLog does not mutate the received it masks', () => {
158
+ const received = { headers: { Authorization: 'Bearer runtime-token' } };
159
+ serializeErrorForLog(new RequestError('Upstream failed.', { received }));
160
+ expect(received).toEqual({ headers: { Authorization: 'Bearer runtime-token' } });
161
+ });
162
+
163
+ test('serializeErrorForLog projects an Error nested seven levels deep inside a cause property', () => {
164
+ let nested = createAxiosError();
165
+ for (let level = 7; level >= 1; level -= 1) {
166
+ nested = new RequestError(`Level ${level}.`, { received: nested });
167
+ }
168
+ // An explicit received, so the top node does not inherit the cause's.
169
+ const error = new RequestError('Top.', {
170
+ received: { page: 1 },
171
+ cause: new OperatorError('Cause.', { received: nested }),
172
+ });
173
+ const serialized = serializeErrorForLog(error);
174
+
175
+ let node = serialized.cause.received;
176
+ for (let level = 1; level <= 7; level += 1) {
177
+ expect(node.message).toEqual(`Level ${level}.`);
178
+ node = node.received;
179
+ }
180
+ expect(node.name).toEqual('AxiosError');
181
+ expect(node.statusCode).toEqual(401);
182
+
183
+ const errorNodes = collectErrorNodes(serialized);
184
+ expect(errorNodes).toHaveLength(10);
185
+ errorNodes.forEach((errorNode) => {
186
+ Object.keys(errorNode).forEach((key) => {
187
+ expect(allowedErrorKeys.has(key)).toBe(true);
188
+ });
189
+ });
190
+ expect(JSON.stringify(serialized)).not.toMatch(/planted/);
191
+ });
192
+
193
+ test('serializeErrorForLog keeps UserError metaData, blockId, pageId and isReject', () => {
194
+ const error = new UserError('Please fill in the form.', {
195
+ blockId: 'submit_button',
196
+ pageId: 'orders',
197
+ isReject: true,
198
+ metaData: { step: 'validate' },
199
+ });
200
+ const serialized = serializeErrorForLog(error);
201
+ expect(serialized).toEqual({
202
+ name: 'UserError',
203
+ message: 'Please fill in the form.',
204
+ stack: expect.any(String),
205
+ isLowdefyError: true,
206
+ blockId: 'submit_button',
207
+ pageId: 'orders',
208
+ isReject: true,
209
+ metaData: { step: 'validate' },
210
+ });
211
+ });
212
+
213
+ test('serializeErrorForLog keeps Lowdefy fields on an error that has a Lowdefy class name but not the class', () => {
214
+ const error = new Error('Not allowed.');
215
+ error.name = 'AuthorizationError';
216
+ error.configKey = 'key-2';
217
+ error.isLowdefyError = true;
218
+ error.internalState = 'dropped';
219
+ const serialized = serializeErrorForLog(error);
220
+ expect(serialized).toEqual({
221
+ name: 'AuthorizationError',
222
+ message: 'Not allowed.',
223
+ stack: expect.any(String),
224
+ configKey: 'key-2',
225
+ isLowdefyError: true,
226
+ });
227
+ });
228
+
229
+ test('projectErrorForLog drops Lowdefy field names from a non-Lowdefy error', () => {
230
+ const error = new Error('Library failure.');
231
+ error.received = { password: 'planted' };
232
+ error.source = 'library-internal';
233
+ error.location = 'somewhere';
234
+ const projected = projectErrorForLog(error);
235
+ expect(projected.received).toBeUndefined();
236
+ expect(projected.source).toBeUndefined();
237
+ expect(projected.location).toBeUndefined();
238
+ });
239
+
240
+ test('projectErrorForLog reads statusCode from the node and passes the cause through for the walk', () => {
241
+ const cause = new Error('Inner.');
242
+ const error = new Error('Outer.', { cause });
243
+ error.status = 503;
244
+ const projected = projectErrorForLog(error);
245
+ expect(projected.statusCode).toEqual(503);
246
+ expect(projected.cause).toBe(cause);
247
+ });
248
+
249
+ test('maskCredentialKeys masks keys containing each credential word after lower-casing and removing - and _', () => {
250
+ expect(
251
+ maskCredentialKeys({
252
+ Authorization: 'a',
253
+ access_token: 'b',
254
+ 'x-api-key': 'c',
255
+ client_secret: 'd',
256
+ Password: 'e',
257
+ Cookie: 'f',
258
+ API_KEY: 'g',
259
+ tokenCount: 3,
260
+ name: 'kept',
261
+ })
262
+ ).toEqual({
263
+ Authorization: '[REDACTED]',
264
+ access_token: '[REDACTED]',
265
+ 'x-api-key': '[REDACTED]',
266
+ client_secret: '[REDACTED]',
267
+ Password: '[REDACTED]',
268
+ Cookie: '[REDACTED]',
269
+ API_KEY: '[REDACTED]',
270
+ tokenCount: '[REDACTED]',
271
+ name: 'kept',
272
+ });
273
+ });
274
+
275
+ test('maskCredentialKeys replaces a whole object value under a credential key', () => {
276
+ expect(maskCredentialKeys({ tokens: { access: 'a', refresh: 'b' } })).toEqual({
277
+ tokens: '[REDACTED]',
278
+ });
279
+ });
280
+
281
+ test('maskCredentialKeys recurses into arrays and nested objects', () => {
282
+ expect(
283
+ maskCredentialKeys({ items: [{ secret: 'a', id: 1 }, [{ cookie: 'b' }], 'plain'] })
284
+ ).toEqual({ items: [{ secret: '[REDACTED]', id: 1 }, [{ cookie: '[REDACTED]' }], 'plain'] });
285
+ });
286
+
287
+ test('maskCredentialKeys returns primitives, Dates, Errors and class instances unchanged', () => {
288
+ class Client {
289
+ constructor() {
290
+ this.password = 'internal';
291
+ }
292
+ }
293
+ const date = new Date(0);
294
+ const error = new Error('x');
295
+ const client = new Client();
296
+ expect(maskCredentialKeys('text')).toEqual('text');
297
+ expect(maskCredentialKeys(undefined)).toBeUndefined();
298
+ expect(maskCredentialKeys(date)).toBe(date);
299
+ expect(maskCredentialKeys(error)).toBe(error);
300
+ expect(maskCredentialKeys(client)).toBe(client);
301
+ });
302
+
303
+ test('serializeErrorForLog marks a class instance in received by type name only', () => {
304
+ class Client {
305
+ constructor() {
306
+ this.password = 'planted';
307
+ }
308
+ }
309
+ const serialized = serializeErrorForLog(
310
+ new RequestError('Failed.', { received: { client: new Client() } })
311
+ );
312
+ expect(serialized.received).toEqual({ client: '[Object: Client]' });
313
+ });
314
+
315
+ test('serializeErrorForLog marks a cycle in received as circular', () => {
316
+ const received = { name: 'loop' };
317
+ received.self = received;
318
+ const serialized = serializeErrorForLog(new RequestError('Failed.', { received }));
319
+ expect(serialized.received).toEqual({ name: 'loop', self: '[Circular]' });
320
+ });
321
+
322
+ test('serializeErrorForLog returns a non-Error value unchanged', () => {
323
+ expect(serializeErrorForLog('plain string')).toEqual('plain string');
324
+ expect(serializeErrorForLog({ message: 'object' })).toEqual({ message: 'object' });
325
+ });
@@ -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,7 +14,10 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import * as Sentry from '@sentry/nextjs';
17
+ import * as Sentry from '@sentry/node';
18
+ import { type } from '@lowdefy/helpers';
19
+
20
+ import serializeErrorForLog from '../log/serializeErrorForLog.js';
18
21
 
19
22
  function captureSentryError({ error, context, configLocation }) {
20
23
  // No-op if Sentry not initialized (DSN not set)
@@ -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
  }
@@ -48,6 +55,11 @@ function captureSentryError({ error, context, configLocation }) {
48
55
  extra.configKey = error.configKey;
49
56
  }
50
57
 
58
+ // Sentry's own serialization of the exception carries only names, messages and stacks, so
59
+ // the log's projection is what gives Sentry the error's other fields - the same ones the log
60
+ // keeps, with the library fields it drops left out.
61
+ extra.error = serializeErrorForLog(error);
62
+
51
63
  Sentry.captureException(error, {
52
64
  tags,
53
65
  extra,
@@ -0,0 +1,104 @@
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 } from '@lowdefy/errors';
19
+
20
+ const captureException = jest.fn();
21
+
22
+ jest.unstable_mockModule('@sentry/node', () => ({ captureException }));
23
+
24
+ process.env.SENTRY_DSN = 'https://public@o0.ingest.sentry.io/1';
25
+
26
+ const { default: captureSentryError } = await import('./captureSentryError.js');
27
+
28
+ function createAxiosError() {
29
+ const error = new Error('Request failed with status code 401');
30
+ error.name = 'AxiosError';
31
+ error.code = 'ERR_BAD_REQUEST';
32
+ error.config = {
33
+ auth: { username: 'svc-user', password: 'planted-config-password' },
34
+ params: { api_key: 'planted-config-param' },
35
+ headers: { Authorization: 'Bearer planted-config-header' },
36
+ baseURL: 'https://api.example.com',
37
+ };
38
+ error.response = { status: 401, data: { detail: 'planted-response-body' } };
39
+ return error;
40
+ }
41
+
42
+ function createRequestError() {
43
+ return new RequestError(undefined, {
44
+ cause: createAxiosError(),
45
+ typeName: 'AxiosHttp',
46
+ configKey: 'key-1',
47
+ received: {
48
+ url: 'https://api.example.com/items',
49
+ headers: { Authorization: 'Bearer runtime-token', Accept: 'application/json' },
50
+ },
51
+ });
52
+ }
53
+
54
+ test('captureSentryError sends the log projection as extra.error, without axios config and with received credential keys masked', () => {
55
+ const error = createRequestError();
56
+ captureSentryError({ error, context: { rid: 'req-1', pageId: 'orders' } });
57
+ expect(captureException).toHaveBeenCalledTimes(1);
58
+ const [captured, { extra }] = captureException.mock.calls[0];
59
+ expect(captured).toBe(error);
60
+ expect(extra.error.typeName).toEqual('AxiosHttp');
61
+ expect(extra.error.statusCode).toEqual(401);
62
+ expect(extra.error.received).toEqual({
63
+ url: 'https://api.example.com/items',
64
+ headers: { Authorization: '[REDACTED]', Accept: 'application/json' },
65
+ });
66
+ expect(extra.error.cause).toEqual({
67
+ name: 'AxiosError',
68
+ message: 'Request failed with status code 401',
69
+ stack: expect.any(String),
70
+ code: 'ERR_BAD_REQUEST',
71
+ statusCode: 401,
72
+ });
73
+ expect(JSON.stringify(extra)).not.toMatch(/planted|runtime-token/);
74
+ });
75
+
76
+ test('captureSentryError tags the request id and keeps configKey and configLocation in extra', () => {
77
+ const configLocation = { source: 'pages/orders.yaml:12', config: 'root.pages[0]' };
78
+ captureSentryError({
79
+ error: createRequestError(),
80
+ context: { rid: 'req-1', pageId: 'orders' },
81
+ configLocation,
82
+ });
83
+ const [, { tags, extra }] = captureException.mock.calls[0];
84
+ expect(tags).toEqual({ pageId: 'orders', requestId: 'req-1' });
85
+ expect(extra.configKey).toEqual('key-1');
86
+ expect(extra.configLocation).toEqual(configLocation);
87
+ });
88
+
89
+ test('captureSentryError leaves out the requestId tag when the context has no request id', () => {
90
+ captureSentryError({ error: new Error('Failed.'), context: {} });
91
+ const [, { tags }] = captureException.mock.calls[0];
92
+ expect(Object.keys(tags)).not.toContain('requestId');
93
+ });
94
+
95
+ test('captureSentryError does nothing when SENTRY_DSN is not set', () => {
96
+ const dsn = process.env.SENTRY_DSN;
97
+ delete process.env.SENTRY_DSN;
98
+ try {
99
+ captureSentryError({ error: new Error('Failed.'), context: { rid: 'req-1' } });
100
+ } finally {
101
+ process.env.SENTRY_DSN = dsn;
102
+ }
103
+ expect(captureException).not.toHaveBeenCalled();
104
+ });