@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.
Files changed (119) hide show
  1. package/client/App.jsx +41 -21
  2. package/client/Page.jsx +101 -24
  3. package/client/clearReloadedForTypes.js +29 -0
  4. package/client/clientBuildImports.test.mjs +60 -0
  5. package/client/getShownPage.js +28 -0
  6. package/client/getShownPage.test.mjs +52 -0
  7. package/client/loadAllIcons.js +45 -0
  8. package/client/loadPageTypes.js +44 -0
  9. package/client/main.jsx +22 -1
  10. package/client/reloadedForTypesKey.js +21 -0
  11. package/client/shouldReloadForTypes.js +38 -0
  12. package/client/shouldReloadForTypes.test.mjs +63 -0
  13. package/client/types.js +28 -0
  14. package/lib/build/security.js +26 -0
  15. package/lib/client/auth/Auth.jsx +11 -6
  16. package/lib/client/auth/AuthConfigured.jsx +224 -39
  17. package/lib/client/auth/AuthNotConfigured.js +44 -4
  18. package/lib/client/auth/createFetchCheckingBuild.js +45 -0
  19. package/lib/client/auth/createFetchCheckingBuild.test.mjs +100 -0
  20. package/lib/client/bundleBuildId.js +23 -0
  21. package/lib/client/createLogUsage.js +46 -25
  22. package/lib/client/createLogUsage.test.mjs +90 -0
  23. package/lib/client/getCspNonce.js +25 -0
  24. package/lib/server/auth/createSystemContext.js +70 -0
  25. package/lib/server/auth/{getAuthConfig.js → getAuth.js} +15 -7
  26. package/lib/server/auth/getStrategies.js +40 -0
  27. package/lib/server/getClientAddress.js +56 -0
  28. package/lib/server/getClientAddress.test.mjs +108 -0
  29. package/lib/server/log/createHandleError.js +16 -4
  30. package/lib/server/log/createHandleError.test.mjs +102 -0
  31. package/lib/server/log/createLogger.js +10 -0
  32. package/lib/server/log/createLogger.test.mjs +117 -0
  33. package/lib/server/log/logRequest.js +12 -4
  34. package/lib/server/log/maskCredentialKeys.js +37 -0
  35. package/lib/server/log/projectErrorForLog.js +61 -0
  36. package/lib/server/log/redactUrlQuery.js +75 -0
  37. package/lib/server/log/redactUrlQuery.test.mjs +73 -0
  38. package/lib/server/{auth/session.js → log/serializeErrorForLog.js} +5 -11
  39. package/lib/server/log/serializeErrorForLog.test.mjs +337 -0
  40. package/lib/server/scrubSecrets.js +21 -0
  41. package/lib/server/sentry/captureSentryError.js +29 -3
  42. package/lib/server/sentry/captureSentryError.test.mjs +128 -0
  43. package/lib/server/sentry/initSentry.js +46 -14
  44. package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
  45. package/lib/server/sentry/initSentry.test.mjs +365 -0
  46. package/lib/server/sentry/scrubEvent.js +40 -0
  47. package/lib/server/sentry/scrubEvent.test.mjs +88 -0
  48. package/lib/server/sentry/sentrySdk.js +23 -0
  49. package/lib/server/sentry/setSentryUser.js +3 -3
  50. package/lowdefy/build.mjs +1 -0
  51. package/lowdefy/build.test.mjs +68 -0
  52. package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
  53. package/package.json +47 -42
  54. package/package.original.json +47 -42
  55. package/src/app.js +74 -17
  56. package/src/app.test.mjs +118 -0
  57. package/src/html/collectChunkClosure.js +31 -0
  58. package/src/html/collectLazyImports.js +41 -0
  59. package/src/html/collectPageTypesAssets.js +57 -0
  60. package/src/html/collectPageTypesAssets.test.mjs +130 -0
  61. package/src/html/getAssets.js +20 -1
  62. package/src/html/getHomePath.js +29 -0
  63. package/src/html/getPageAssets.js +32 -0
  64. package/src/html/getPageAssets.test.mjs +47 -0
  65. package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
  66. package/src/html/renderPage.js +80 -19
  67. package/src/html/renderPage.test.mjs +189 -0
  68. package/src/html/template.js +50 -15
  69. package/src/html/template.test.mjs +126 -0
  70. package/src/index.js +28 -19
  71. package/src/initServer.js +61 -0
  72. package/src/initServer.test.mjs +116 -0
  73. package/src/lib/getRequestPath.js +29 -0
  74. package/src/middleware/apiContext.js +68 -13
  75. package/src/middleware/apiContext.test.mjs +178 -0
  76. package/src/middleware/createSameOriginGuard.js +36 -0
  77. package/src/middleware/createSameOriginGuard.test.mjs +102 -0
  78. package/src/middleware/errorHandler.js +56 -4
  79. package/src/middleware/errorHandler.test.mjs +440 -0
  80. package/src/middleware/noStore.js +27 -0
  81. package/src/middleware/refuseOtherBuild.js +49 -0
  82. package/src/middleware/refuseOtherBuild.test.mjs +63 -0
  83. package/src/middleware/requestTimeout.js +38 -0
  84. package/src/middleware/sentry.js +13 -5
  85. package/src/middleware/stampBuildId.js +30 -0
  86. package/src/middleware/stampBuildId.test.mjs +41 -0
  87. package/src/middleware/stampCaller.js +38 -0
  88. package/src/middleware/stampCaller.test.mjs +64 -0
  89. package/src/routes/agent.js +15 -3
  90. package/src/routes/apiPage.js +54 -9
  91. package/src/routes/apiPage.test.mjs +143 -0
  92. package/src/routes/auth.js +15 -9
  93. package/src/routes/auth.test.mjs +85 -0
  94. package/src/routes/clientError.js +11 -13
  95. package/src/routes/cron.js +3 -1
  96. package/src/routes/cronForward.js +4 -2
  97. package/src/routes/detached.js +22 -9
  98. package/src/routes/endpoints.js +27 -26
  99. package/src/routes/endpointsTextAnswer.test.mjs +77 -0
  100. package/src/routes/mcp.js +82 -5
  101. package/src/routes/mcp.test.mjs +215 -0
  102. package/src/routes/mcpMemberToken.test.mjs +125 -0
  103. package/src/routes/mcpProtectedResourceMetadata.js +34 -0
  104. package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
  105. package/src/routes/mountOauthDiscovery.js +44 -0
  106. package/src/routes/mountOauthDiscovery.test.mjs +79 -0
  107. package/src/routes/mountPageRoutes.js +29 -0
  108. package/src/routes/mountPageRoutes.test.mjs +56 -0
  109. package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
  110. package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
  111. package/src/routes/request.js +5 -3
  112. package/src/routes/requestBody.test.mjs +146 -0
  113. package/src/routes/usage.js +6 -2
  114. package/src/routes/user.js +27 -0
  115. package/src/routes/websocket.js +26 -3
  116. package/src/routes/websocket.test.mjs +106 -0
  117. package/src/routes/wellKnownFallback.js +30 -0
  118. package/vite.config.js +55 -2
  119. package/lib/server/apiWrapper.js +0 -87
@@ -0,0 +1,102 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { jest } from '@jest/globals';
18
+ import { TenantIntegrityError } from '@lowdefy/errors';
19
+
20
+ const secret = 'planted-handle-error-secret';
21
+
22
+ process.env.LOWDEFY_SECRET_API_KEY = secret;
23
+
24
+ const { default: createHandleError } = await import('./createHandleError.js');
25
+
26
+ function createAxiosError() {
27
+ const error = new Error(`Request failed for key ${secret}`);
28
+ error.name = 'AxiosError';
29
+ error.code = 'ERR_BAD_REQUEST';
30
+ error.config = {
31
+ auth: { username: 'svc-user', password: 'planted-config-password' },
32
+ params: { api_key: 'planted-config-param' },
33
+ headers: { Authorization: 'Bearer planted-config-header' },
34
+ };
35
+ return error;
36
+ }
37
+
38
+ function testContext({ logger, headers } = {}) {
39
+ return {
40
+ headers,
41
+ logger: logger ?? { error: jest.fn() },
42
+ readConfigFile: jest.fn(async () => null),
43
+ req: { url: '/api/request/page/req', method: 'POST', hostname: 'app.example.com' },
44
+ };
45
+ }
46
+
47
+ beforeEach(() => {
48
+ console.error = jest.fn();
49
+ });
50
+
51
+ test('handleError logs the referer header with credential query parameters redacted', async () => {
52
+ const context = testContext({
53
+ headers: { referer: 'https://app.example.com/verify?token=planted-token&page=2' },
54
+ });
55
+ const handleError = createHandleError({ context });
56
+
57
+ await handleError(new Error('Failed.'));
58
+
59
+ const [fields] = context.logger.error.mock.calls[0];
60
+ expect(fields.headers.referer).toBe('https://app.example.com/verify?token=%5Bredacted%5D&page=2');
61
+ });
62
+
63
+ test('handleError fallback output carries no axios config auth and no planted secret when the log call throws', async () => {
64
+ const logError = new Error(`Log sink rejected ${secret}`);
65
+ const context = testContext({
66
+ logger: {
67
+ error: jest.fn(() => {
68
+ throw logError;
69
+ }),
70
+ },
71
+ });
72
+ const handleError = createHandleError({ context });
73
+
74
+ await handleError(createAxiosError());
75
+
76
+ expect(console.error).toHaveBeenCalledTimes(3);
77
+ const output = console.error.mock.calls.map(([line]) => line).join('\n');
78
+ expect(output).not.toContain('planted-config-password');
79
+ expect(output).not.toContain('planted-config-param');
80
+ expect(output).not.toContain('planted-config-header');
81
+ expect(output).not.toContain(secret);
82
+ const logged = JSON.parse(console.error.mock.calls[0][0]);
83
+ expect(logged.message).toBe('Request failed for key [REDACTED]');
84
+ expect(logged.code).toBe('ERR_BAD_REQUEST');
85
+ expect(JSON.parse(console.error.mock.calls[2][0]).message).toBe('Log sink rejected [REDACTED]');
86
+ });
87
+
88
+ test('handleError logs a TenantIntegrityError as tenant_integrity_error with endpoint and organization', async () => {
89
+ const context = testContext();
90
+ context.endpointId = 'save-contact';
91
+ context.user = { id: 'u1', organization_id: 'org_a' };
92
+ const handleError = createHandleError({ context });
93
+
94
+ await handleError(
95
+ new TenantIntegrityError('bad row', { collection: 'contacts', organizationId: 'org_a' })
96
+ );
97
+
98
+ const [fields] = context.logger.error.mock.calls[0];
99
+ expect(fields.event).toBe('tenant_integrity_error');
100
+ expect(fields.endpointId).toBe('save-contact');
101
+ expect(fields.organization_id).toBe('org_a');
102
+ });
@@ -18,6 +18,8 @@ import { createNodeLogger } from '@lowdefy/logger/node';
18
18
  import { type } from '@lowdefy/helpers';
19
19
 
20
20
  import appMeta from '../../build/appMeta.js';
21
+ import scrubSecrets from '../scrubSecrets.js';
22
+ import serializeErrorForLog from './serializeErrorForLog.js';
21
23
 
22
24
  // Deploy identity on every log line — pid/hostname are stripped, so these
23
25
  // fields are what correlates a line to an app build across replicas.
@@ -31,11 +33,19 @@ if (!type.isNone(appMeta.version)) {
31
33
  if (!type.isNone(appMeta.gitSha)) {
32
34
  base.git_sha = appMeta.gitSha;
33
35
  }
36
+ // The deployment environment (LOWDEFY_ENVIRONMENT), so one log drain can hold every environment.
37
+ if (!type.isNone(appMeta.environment)) {
38
+ base.environment = appMeta.environment;
39
+ }
34
40
 
35
41
  const logger = createNodeLogger({
36
42
  name: 'lowdefy_server',
37
43
  level: process.env.LOWDEFY_LOG_LEVEL ?? 'info',
38
44
  base,
45
+ serializers: { err: serializeErrorForLog },
46
+ // Scrubbing the finished line rather than the logged object covers every field of every
47
+ // call - debug payloads and request results as well as `err` - with no walker to miss one.
48
+ hooks: { streamWrite: scrubSecrets },
39
49
  });
40
50
 
41
51
  function createLogger(metadata = {}) {
@@ -0,0 +1,117 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { jest } from '@jest/globals';
18
+ import { createNodeLogger } from '@lowdefy/logger/node';
19
+ import { markCredential, runInCredentialScope } from '@lowdefy/node-utils';
20
+
21
+ const secret = 'planted/secret+value=1';
22
+ const shortSecret = 'short77';
23
+
24
+ const lines = [];
25
+ const destination = {
26
+ write(line) {
27
+ lines.push(line);
28
+ },
29
+ };
30
+
31
+ // The prod logger writes to stdout; route the real logger to memory so each test reads the
32
+ // exact line pino would emit, after the streamWrite hook.
33
+ jest.unstable_mockModule('@lowdefy/logger/node', () => ({
34
+ createNodeLogger: (options) => createNodeLogger({ ...options, destination }),
35
+ }));
36
+ jest.unstable_mockModule('../../build/appMeta.js', () => ({ default: {} }));
37
+
38
+ process.env.LOWDEFY_SECRET_API_KEY = secret;
39
+ process.env.LOWDEFY_SECRET_SHORT = shortSecret;
40
+ process.env.LOWDEFY_LOG_LEVEL = 'debug';
41
+
42
+ const { default: createLogger } = await import('./createLogger.js');
43
+
44
+ function createAxiosError() {
45
+ const error = new Error('Request failed with status code 401');
46
+ error.name = 'AxiosError';
47
+ error.code = 'ERR_BAD_REQUEST';
48
+ error.config = {
49
+ auth: { username: 'svc-user', password: 'planted-config-password' },
50
+ params: { api_key: 'planted-config-param' },
51
+ headers: { Authorization: 'Bearer planted-config-header' },
52
+ baseURL: 'https://api.example.com',
53
+ };
54
+ error.response = { status: 401, data: { detail: 'planted-response-body' } };
55
+ return error;
56
+ }
57
+
58
+ beforeEach(() => {
59
+ lines.length = 0;
60
+ });
61
+
62
+ test('prod logger drops an axios error config auth, params and headers', () => {
63
+ createLogger().error({ err: createAxiosError() }, 'Request failed');
64
+ expect(lines).toHaveLength(1);
65
+ const line = lines[0];
66
+ expect(line).not.toContain('planted-config-password');
67
+ expect(line).not.toContain('planted-config-param');
68
+ expect(line).not.toContain('planted-config-header');
69
+ expect(line).not.toContain('planted-response-body');
70
+ const { err } = JSON.parse(line);
71
+ expect(err.config).toBeUndefined();
72
+ expect(err.code).toBe('ERR_BAD_REQUEST');
73
+ expect(err.statusCode).toBe(401);
74
+ });
75
+
76
+ test('prod logger redacts a planted secret in a message raw, URL-encoded and base64', () => {
77
+ const basic = Buffer.from(`svc-user:${secret}`).toString('base64');
78
+ const error = new Error(`raw ${secret} encoded ${encodeURIComponent(secret)} basic ${basic}`);
79
+ createLogger().error({ err: error }, error.message);
80
+ expect(lines).toHaveLength(1);
81
+ const line = lines[0];
82
+ expect(line).not.toContain(secret);
83
+ expect(line).not.toContain(encodeURIComponent(secret));
84
+ expect(line).not.toContain(Buffer.from(secret).toString('base64').slice(0, 20));
85
+ const { msg, err } = JSON.parse(line);
86
+ expect(msg).toMatch(/^raw \[REDACTED\] encoded \[REDACTED\] basic \S*\[REDACTED\]/);
87
+ expect(err.message).toBe(msg);
88
+ });
89
+
90
+ test('prod logger redacts a planted secret in a debug payload that is not an error', () => {
91
+ createLogger({ rid: 'req-1' }).debug({ response: { token: secret } }, 'Request result');
92
+ expect(lines).toHaveLength(1);
93
+ expect(lines[0]).not.toContain(secret);
94
+ expect(JSON.parse(lines[0]).response.token).toBe('[REDACTED]');
95
+ });
96
+
97
+ test('prod logger leaves a secret shorter than 8 characters alone', () => {
98
+ createLogger().info({ detail: shortSecret }, `value ${shortSecret}`);
99
+ expect(lines).toHaveLength(1);
100
+ const { msg, detail } = JSON.parse(lines[0]);
101
+ expect(msg).toBe(`value ${shortSecret}`);
102
+ expect(detail).toBe(shortSecret);
103
+ });
104
+
105
+ test('prod logger redacts a value the request marked as a credential', () => {
106
+ runInCredentialScope(() => {
107
+ markCredential('runtime-made-app-key-0001');
108
+ createLogger({ rid: 'req-1' }).debug(
109
+ { event: 'debug_control_return', response: { key: 'runtime-made-app-key-0001' } },
110
+ 'Returned'
111
+ );
112
+ });
113
+ createLogger({ rid: 'req-2' }).debug({ response: { key: 'runtime-made-app-key-0001' } });
114
+ expect(lines).toHaveLength(2);
115
+ expect(JSON.parse(lines[0]).response.key).toBe('[REDACTED]');
116
+ expect(JSON.parse(lines[1]).response.key).toBe('runtime-made-app-key-0001');
117
+ });
@@ -14,20 +14,28 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
+ import redactUrlQuery from './redactUrlQuery.js';
18
+
17
19
  // Emits one access-log line per request, after the response is finalized.
20
+ // `url` is the route path only (c.req.path). The referer keeps its query but
21
+ // with credential parameters redacted, because the browser sends the page's
22
+ // full URL there and that URL can carry a sign-in token or a signed OAuth
23
+ // query (see redactUrlQuery).
18
24
  function logRequest({ context, status, durationMs }) {
19
- const { headers = {}, user = {} } = context;
25
+ const { headers = {} } = context;
26
+ const user = context.user ?? {};
20
27
  context.logger.info(
21
28
  {
22
29
  user: {
23
30
  id: user.id,
24
31
  roles: user.roles,
25
- sub: user.sub,
26
- session_id: user.session_id, // TODO: Implement session id
27
32
  },
28
33
  url: context.req.url,
29
34
  method: context.req.method,
30
35
  hostname: context.req.hostname,
36
+ // The address the server resolved (see getClientAddress). The forwarding
37
+ // headers below are logged as the client sent them, and are not it.
38
+ client_address: context.clientAddress,
31
39
  status,
32
40
  duration_ms: durationMs,
33
41
  headers: {
@@ -37,7 +45,7 @@ function logRequest({ context, status, durationMs }) {
37
45
  'sec-ch-ua': headers['sec-ch-ua'],
38
46
  'user-agent': headers['user-agent'],
39
47
  host: headers.host,
40
- referer: headers.referer,
48
+ referer: redactUrlQuery(headers.referer),
41
49
  'x-forwarded-for': headers['x-forwarded-for'],
42
50
  // Vercel headers
43
51
  'x-vercel-id': headers['x-vercel-id'],
@@ -0,0 +1,37 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { mapPlainValues } from '@lowdefy/helpers';
18
+
19
+ // `received` is the one kept log field holding values fetched at runtime (a token
20
+ // from an earlier step sent as a header), which the by-value secret scrub
21
+ // cannot recognise, so credential-looking keys are masked by name instead.
22
+ const credentialKeyParts = ['authorization', 'token', 'secret', 'password', 'apikey', 'cookie'];
23
+
24
+ function isCredentialKey(key) {
25
+ const normalized = key.toLowerCase().replace(/[-_]/g, '');
26
+ return credentialKeyParts.some((part) => normalized.includes(part));
27
+ }
28
+
29
+ // Shared references and cycles are copied as such, so the serializer walk still
30
+ // marks them '[Circular]'.
31
+ function maskCredentialKeys(value) {
32
+ return mapPlainValues(value, (item, key) =>
33
+ key !== undefined && isCredentialKey(key) ? '[REDACTED]' : item
34
+ );
35
+ }
36
+
37
+ export default maskCredentialKeys;
@@ -0,0 +1,61 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { lowdefyErrorNames, readErrorCodes } from '@lowdefy/errors';
18
+
19
+ import maskCredentialKeys from './maskCredentialKeys.js';
20
+
21
+ // Kept only on nodes named as a Lowdefy error class: libraries reuse some of
22
+ // these names for their own data - axios's `config` carries auth, params and
23
+ // headers - so keying on the field alone would let a library's copy through.
24
+ const lowdefyErrorFields = [
25
+ 'configKey',
26
+ 'source',
27
+ 'config',
28
+ 'handled',
29
+ 'isLowdefyError',
30
+ 'received',
31
+ 'typeName',
32
+ 'location',
33
+ 'service',
34
+ 'hint',
35
+ 'retryAfter',
36
+ 'methodName',
37
+ 'metaData',
38
+ 'blockId',
39
+ 'pageId',
40
+ 'isReject',
41
+ 'collection',
42
+ 'connectionId',
43
+ 'organizationId',
44
+ 'field',
45
+ 'endpointId',
46
+ ];
47
+
48
+ function projectErrorForLog(err) {
49
+ const { code, statusCode } = readErrorCodes(err);
50
+ const props = { name: err.name, message: err.message, stack: err.stack, code, statusCode };
51
+ if (lowdefyErrorNames.has(err.name)) {
52
+ lowdefyErrorFields.forEach((field) => {
53
+ props[field] = err[field];
54
+ });
55
+ props.received = maskCredentialKeys(err.received);
56
+ }
57
+ props.cause = err.cause;
58
+ return props;
59
+ }
60
+
61
+ export default projectErrorForLog;
@@ -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,18 +14,12 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { getAuthUser } from '@hono/auth-js';
17
+ import { serializer } from '@lowdefy/helpers';
18
18
 
19
- import authJson from '../../build/auth.js';
19
+ import projectErrorForLog from './projectErrorForLog.js';
20
20
 
21
- // Replaces getServerSession.js — reads the session from the Hono context
22
- // populated by the initAuthConfig middleware.
23
- async function getSession(c) {
24
- if (authJson.configured !== true) {
25
- return undefined;
26
- }
27
- const authUser = await getAuthUser(c);
28
- return authUser?.session ?? undefined;
21
+ function serializeErrorForLog(error) {
22
+ return serializer.serialize(error, { projectError: projectErrorForLog })?.['~e'] ?? error;
29
23
  }
30
24
 
31
- export default getSession;
25
+ export default serializeErrorForLog;