@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,146 @@
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 { Hono } from 'hono';
18
+ import { jest } from '@jest/globals';
19
+
20
+ import { isSameOriginRequest, parseRequestBody } from '@lowdefy/api';
21
+
22
+ const mockCallEndpoint = jest.fn();
23
+ const mockGetEndpointConfig = jest.fn().mockRejectedValue(new Error('not found'));
24
+ const mockRunWebhookEndpoint = jest.fn();
25
+ jest.unstable_mockModule('@lowdefy/api', () => ({
26
+ acceptDetachedEndpoint: jest.fn(),
27
+ callAgent: jest.fn(),
28
+ callEndpoint: mockCallEndpoint,
29
+ callRequest: jest.fn(),
30
+ getEndpointConfig: mockGetEndpointConfig,
31
+ logClientError: jest.fn(),
32
+ isSameOriginRequest,
33
+ parseRequestBody,
34
+ redactErrorResponse: jest.fn(),
35
+ runWebhookEndpoint: mockRunWebhookEndpoint,
36
+ }));
37
+
38
+ const { default: createErrorHandler } = await import('../middleware/errorHandler.js');
39
+ const routes = {
40
+ '/api/agent/*': (await import('./agent.js')).default,
41
+ '/api/client-error': (await import('./clientError.js')).default,
42
+ '/api/detached/*': (await import('./detached.js')).default,
43
+ '/api/endpoints/*': (await import('./endpoints.js')).default,
44
+ '/api/request/*': (await import('./request.js')).default,
45
+ '/api/usage': (await import('./usage.js')).default,
46
+ };
47
+
48
+ const logger = { debug: jest.fn(), error: jest.fn(), info: jest.fn(), warn: jest.fn() };
49
+
50
+ function createApp() {
51
+ const app = new Hono();
52
+ app.use('*', async (c, next) => {
53
+ c.set('lowdefyContext', { logger, handleError: jest.fn() });
54
+ await next();
55
+ });
56
+ Object.entries(routes).forEach(([path, handler]) => app.all(path, handler));
57
+ app.onError(createErrorHandler({ logger }));
58
+ return app;
59
+ }
60
+
61
+ const originalCronSecret = process.env.CRON_SECRET;
62
+ beforeAll(() => {
63
+ process.env.CRON_SECRET = 'secret';
64
+ });
65
+ afterAll(() => {
66
+ if (originalCronSecret === undefined) {
67
+ delete process.env.CRON_SECRET;
68
+ } else {
69
+ process.env.CRON_SECRET = originalCronSecret;
70
+ }
71
+ });
72
+
73
+ test.each([
74
+ ['/api/endpoints/save', 'nope', {}, 'Request body is not valid JSON.'],
75
+ ['/api/endpoints/save', 'null', {}, 'Request body must be a JSON object.'],
76
+ ['/api/request/orders/load', 'nope', {}, 'Request body is not valid JSON.'],
77
+ ['/api/agent/orders/assistant', 'nope', {}, 'Request body is not valid JSON.'],
78
+ [
79
+ '/api/detached/save',
80
+ 'nope',
81
+ { authorization: 'Bearer secret' },
82
+ 'Request body is not valid JSON.',
83
+ ],
84
+ ['/api/usage', 'nope', {}, 'Request body is not valid JSON.'],
85
+ [
86
+ '/api/client-error',
87
+ 'nope',
88
+ { host: 'localhost', origin: 'http://localhost' },
89
+ 'Request body is not valid JSON.',
90
+ ],
91
+ ])('POST %s with body %s answers 400 and logs a warning', async (path, body, headers, message) => {
92
+ const res = await createApp().request(path, { method: 'POST', body, headers });
93
+ expect(res.status).toBe(400);
94
+ expect(await res.json()).toEqual({ name: 'UserError', message });
95
+ expect(logger.warn).toHaveBeenCalledTimes(1);
96
+ expect(logger.error).not.toHaveBeenCalled();
97
+ });
98
+
99
+ test('POST /api/endpoints with a JSON body passes its fields to callEndpoint', async () => {
100
+ mockCallEndpoint.mockResolvedValue({ success: true });
101
+ const res = await createApp().request('/api/endpoints/save', {
102
+ method: 'POST',
103
+ body: JSON.stringify({ blockId: 'b', pageId: 'p', payload: { a: 1 } }),
104
+ });
105
+ expect(res.status).toBe(200);
106
+ expect(mockCallEndpoint).toHaveBeenCalledWith(expect.anything(), {
107
+ blockId: 'b',
108
+ endpointId: 'save',
109
+ pageId: 'p',
110
+ payload: { a: 1 },
111
+ });
112
+ });
113
+
114
+ test('POST /api/endpoints to a webhook endpoint passes the body text exactly as sent as rawBody', async () => {
115
+ mockGetEndpointConfig.mockResolvedValueOnce({ webhook: { verify: {} } });
116
+ mockRunWebhookEndpoint.mockResolvedValue({ status: 200, body: { ok: true } });
117
+ const body = '{ "zeta": 1, "alpha": "caf\\u00e9", "beta": "café" }';
118
+ const res = await createApp().request('/api/endpoints/signed_hook?t=1', {
119
+ method: 'POST',
120
+ body,
121
+ headers: { 'x-hub-signature-256': 'sha256=abc' },
122
+ });
123
+ expect(res.status).toBe(200);
124
+ expect(mockRunWebhookEndpoint).toHaveBeenCalledWith(expect.anything(), {
125
+ endpointId: 'signed_hook',
126
+ rawBody: body,
127
+ query: { t: '1' },
128
+ headers: expect.objectContaining({ 'x-hub-signature-256': 'sha256=abc' }),
129
+ });
130
+ });
131
+
132
+ test.each([
133
+ [200, { received: true }],
134
+ [401, { error: { code: 'unauthorized', message: 'Webhook verification failed.' } }],
135
+ [404, { error: 'not_found', id: 'ticket-1' }],
136
+ [500, { error: { code: 'internal_error', message: 'Webhook failed.' } }],
137
+ ])(
138
+ 'POST /api/endpoints to a webhook endpoint answers status %s with its body',
139
+ async (status, body) => {
140
+ mockGetEndpointConfig.mockResolvedValueOnce({ webhook: true });
141
+ mockRunWebhookEndpoint.mockResolvedValue({ status, body });
142
+ const res = await createApp().request('/api/endpoints/hook', { method: 'POST', body: '{}' });
143
+ expect(res.status).toBe(status);
144
+ expect(await res.json()).toEqual(body);
145
+ }
146
+ );
@@ -17,14 +17,18 @@
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
 
20
+ import { parseRequestBody } from '@lowdefy/api';
21
+
20
22
  const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
21
23
 
22
24
  async function usageHandler(c) {
23
25
  if (c.req.method !== 'POST') {
24
- throw new Error('Only POST requests are supported.');
26
+ // A wrong-method request is client-caused: answer 405 rather than raising a
27
+ // fault that would be logged at error level and answered with a 500.
28
+ return c.json({ error: 'Method not allowed.' }, 405);
25
29
  }
26
30
  const context = c.get('lowdefyContext');
27
- const { user, machine } = await c.req.json();
31
+ const { user, machine } = parseRequestBody({ text: await c.req.text() });
28
32
  const host = c.req.header('host');
29
33
  context.logger.info({ event: 'log_usage', user, machine });
30
34
 
@@ -0,0 +1,27 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ // The resolved caller for the client - the same resolveAuthentication output
18
+ // the first page load embeds. The base session carries neither roles (read
19
+ // live from the active member row) nor the merged attributes bag, so the
20
+ // client re-syncs from here (UpdateSession) after a session change such as
21
+ // SetActiveOrganization.
22
+ async function userHandler(c) {
23
+ const context = c.get('lowdefyContext');
24
+ return c.json({ user: context.user ?? null });
25
+ }
26
+
27
+ export default userHandler;
@@ -15,20 +15,31 @@
15
15
  */
16
16
 
17
17
  import { upgradeWebSocket } from '@hono/node-server';
18
- import { createChannelRegistry, createWebSocketConnection } from '@lowdefy/api';
18
+ import {
19
+ createChannelRegistry,
20
+ createWebSocketConnection,
21
+ isWebSocketOriginAllowed,
22
+ } from '@lowdefy/api';
23
+
24
+ import getStrategies from '../../lib/server/auth/getStrategies.js';
19
25
 
20
26
  // One registry per server process — channels and their running source
21
27
  // resolvers are shared across all websocket connections on this instance.
22
28
  const registry = createChannelRegistry();
23
29
 
24
- const websocketHandler = upgradeWebSocket((c) => {
30
+ const upgrade = upgradeWebSocket((c) => {
25
31
  const context = c.get('lowdefyContext');
26
32
  let connection;
27
33
  return {
28
34
  onOpen(event, ws) {
35
+ // The upgrade's headers stay with the connection: its caller is resolved
36
+ // from them again while the socket is open.
29
37
  connection = createWebSocketConnection(context, {
38
+ closeSocket: ({ code, reason }) => ws.close(code, reason),
39
+ headers: c.req.raw.headers,
30
40
  registry,
31
41
  send: (message) => ws.send(message),
42
+ strategies: getStrategies({ logger: context.logger }),
32
43
  });
33
44
  },
34
45
  onMessage(event) {
@@ -38,9 +49,21 @@ const websocketHandler = upgradeWebSocket((c) => {
38
49
  connection?.close();
39
50
  },
40
51
  onError(event) {
41
- context.logger.debug({ event: 'ws_error' }, String(event?.error ?? 'WebSocket error'));
52
+ // A transport fault, not routine traffic: visible at the default level with
53
+ // the error object attached so the cause is not reduced to a string.
54
+ context.logger.warn({ event: 'ws_error', err: event?.error }, 'WebSocket error.');
42
55
  },
43
56
  };
44
57
  });
45
58
 
59
+ // Upgrades are accepted from the app's own pages and from clients that send no
60
+ // Origin; isWebSocketOriginAllowed holds the rule and logs a refusal.
61
+ function websocketHandler(c, next) {
62
+ const context = c.get('lowdefyContext');
63
+ if (!isWebSocketOriginAllowed({ context, getHeader: (name) => c.req.header(name) })) {
64
+ return c.json({ error: 'Forbidden' }, 403);
65
+ }
66
+ return upgrade(c, next);
67
+ }
68
+
46
69
  export default websocketHandler;
@@ -0,0 +1,106 @@
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 { Hono } from 'hono';
18
+ import { jest } from '@jest/globals';
19
+ import { serve } from '@hono/node-server';
20
+ import WebSocket, { WebSocketServer } from 'ws';
21
+
22
+ jest.unstable_mockModule('../../lib/server/auth/getStrategies.js', () => ({
23
+ default: () => [],
24
+ }));
25
+
26
+ const { default: websocketHandler } = await import('./websocket.js');
27
+
28
+ let server;
29
+ let port;
30
+
31
+ // The production wiring (src/index.js): a Hono app under the app's basePath,
32
+ // served by @hono/node-server with a ws server for upgrades. The Vercel
33
+ // function runs the same app through the same upgrade support.
34
+ beforeAll(async () => {
35
+ const app = new Hono().basePath('/app');
36
+ app.use('*', async (c, next) => {
37
+ // The caller the upgrade resolved: signed in when the test says so.
38
+ const signedIn = c.req.header('x-test-signed-in') === 'true';
39
+ c.set('lowdefyContext', {
40
+ // The auth engine finds no session for the socket's cookie on a re-check.
41
+ auth: { api: { getSession: async () => null } },
42
+ config: {},
43
+ i18n: { active: 'en-US', messages: {} },
44
+ rid: 'request',
45
+ logger: { debug: () => {}, info: () => {}, warn: () => {} },
46
+ handleError: () => {},
47
+ user: signedIn ? { id: 'user-1', organization_id: 'org-1', roles: [] } : null,
48
+ });
49
+ await next();
50
+ });
51
+ app.get('/api/websocket', websocketHandler);
52
+ const wss = new WebSocketServer({ noServer: true });
53
+ await new Promise((resolve) => {
54
+ server = serve({ fetch: app.fetch, port: 0, websocket: { server: wss } }, (info) => {
55
+ port = info.port;
56
+ resolve();
57
+ });
58
+ });
59
+ });
60
+
61
+ afterAll(() => {
62
+ server.close();
63
+ });
64
+
65
+ function open({ headers }) {
66
+ return new Promise((resolve) => {
67
+ const socket = new WebSocket(`ws://127.0.0.1:${port}/app/api/websocket`, { headers });
68
+ socket.once('open', () => {
69
+ socket.close();
70
+ resolve('open');
71
+ });
72
+ socket.once('unexpected-response', (_, response) => {
73
+ socket.terminate();
74
+ resolve(response.statusCode);
75
+ });
76
+ socket.once('error', () => {});
77
+ });
78
+ }
79
+
80
+ test.each([
81
+ ['opens', 'the page is served from the same host', { origin: 'https://app.test' }, 'open'],
82
+ ['opens', 'a client sends no origin', {}, 'open'],
83
+ ['is refused', 'the origin is another site', { origin: 'https://other.test' }, 403],
84
+ [
85
+ 'opens',
86
+ 'a proxy rewrote Host and passed X-Forwarded-Host',
87
+ { host: 'localhost:3000', 'x-forwarded-host': 'app.test', origin: 'https://app.test' },
88
+ 'open',
89
+ ],
90
+ ])('a websocket upgrade %s when %s', async (_, __, headers, expected) => {
91
+ expect(await open({ headers: { host: 'app.test', ...headers } })).toEqual(expected);
92
+ });
93
+
94
+ test('a websocket whose session ended closes with 4401 on its next subscribe', async () => {
95
+ const socket = new WebSocket(`ws://127.0.0.1:${port}/app/api/websocket`, {
96
+ headers: { host: 'app.test', 'x-test-signed-in': 'true' },
97
+ });
98
+ await new Promise((resolve) => socket.once('open', resolve));
99
+ const closed = new Promise((resolve) => {
100
+ socket.once('close', (code, reason) => resolve({ code, reason: reason.toString() }));
101
+ });
102
+
103
+ socket.send(JSON.stringify({ type: 'subscribe', websocketId: 'ticker', requestId: 's1' }));
104
+
105
+ expect(await closed).toEqual({ code: 4401, reason: 'caller-changed' });
106
+ });
@@ -0,0 +1,30 @@
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
+ // RFC 8615 well-known URIs are machine-facing: a path this deployment does not
18
+ // serve must answer 404, never the app's HTML shell. Serving 200 HTML breaks
19
+ // clients that probe discovery documents - an MCP client's OAuth probe, for
20
+ // example, fails with "Failed to parse JSON" instead of learning there is no
21
+ // auth to discover. Mounted after the real discovery documents, before the
22
+ // page catch-all.
23
+ function wellKnownFallbackHandler(c) {
24
+ return c.json(
25
+ { name: 'NotFoundError', message: `No well-known document at ${c.req.path}.` },
26
+ 404
27
+ );
28
+ }
29
+
30
+ export default wellKnownFallbackHandler;
package/vite.config.js CHANGED
@@ -17,6 +17,7 @@
17
17
  import fs from 'node:fs';
18
18
  import { defineConfig } from 'vite';
19
19
  import react from '@vitejs/plugin-react';
20
+ import { Features } from 'lightningcss';
20
21
  import { sentryVitePlugin } from '@sentry/vite-plugin';
21
22
 
22
23
  // basePath from the Lowdefy build — assets are served under it.
@@ -28,12 +29,41 @@ try {
28
29
  // No build yet (e.g. editor tooling) — default base.
29
30
  }
30
31
 
32
+ // Vite 8 tree-shakes a `.module.css` imported only for its side effects
33
+ // (`import './style.module.css'`), and its CSS goes with it: `:global(...)` rules in block and
34
+ // plugin stylesheets never reach the built client. Marking CSS modules as side-effectful keeps
35
+ // them, whatever the import style.
36
+ function keepCssModuleSideEffects() {
37
+ return {
38
+ name: 'lowdefy-keep-css-module-side-effects',
39
+ enforce: 'post',
40
+ transform(code, id) {
41
+ if (/\.module\.css($|\?)/.test(id)) {
42
+ return { code, moduleSideEffects: 'no-treeshake' };
43
+ }
44
+ },
45
+ };
46
+ }
47
+
31
48
  const uploadSourceMaps = Boolean(process.env.SENTRY_AUTH_TOKEN);
32
49
 
50
+ // The @lowdefy packages this server depends on. A linked plugin pinned to
51
+ // another release brings its own copies, and the client would then mix
52
+ // versions: the dev server pre-bundles one copy per package for every
53
+ // importer (whichever its scan meets first, so the server's own code can get
54
+ // a plugin's older @lowdefy/helpers without an export it imports), and a
55
+ // production build bundles both, so blocks read module state the client never
56
+ // filled (the HTML enhancements it registers in @lowdefy/block-utils). The
57
+ // server's copies are the ones its client and blocks were released with.
58
+ const lowdefyDependencies = Object.keys(
59
+ JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).dependencies ?? {}
60
+ ).filter((name) => name.startsWith('@lowdefy/'));
61
+
33
62
  export default defineConfig(({ mode }) => ({
34
63
  base: `${basePath}/`,
35
64
  plugins: [
36
65
  react(),
66
+ keepCssModuleSideEffects(),
37
67
  uploadSourceMaps &&
38
68
  sentryVitePlugin({
39
69
  org: process.env.SENTRY_ORG,
@@ -46,6 +76,17 @@ export default defineConfig(({ mode }) => ({
46
76
  // plugin and client code branch on it.
47
77
  'process.env.NODE_ENV': JSON.stringify(mode === 'production' ? 'production' : 'development'),
48
78
  },
79
+ css: {
80
+ // Vite minifies client CSS with Lightning CSS, which by default lowers
81
+ // light-dark() into --lightningcss-light/--lightningcss-dark variables that
82
+ // only a CSS color-scheme declaration defines. The client sets color-scheme
83
+ // from JavaScript (useDarkMode), so the lowered values never resolve and
84
+ // every light-dark() colour is dropped. Tailwind leaves light-dark()
85
+ // unlowered in its own Lightning CSS pass for the same reason.
86
+ lightningcss: {
87
+ exclude: Features.LightDark,
88
+ },
89
+ },
49
90
  build: {
50
91
  outDir: 'dist/client',
51
92
  manifest: true,
@@ -55,7 +96,19 @@ export default defineConfig(({ mode }) => ({
55
96
  },
56
97
  },
57
98
  resolve: {
58
- // linked plugin packages (pnpm link: / workspace) must share one React.
59
- dedupe: ['react', 'react-dom'],
99
+ // Linked plugin packages (pnpm link: / workspace) resolve their own copies of
100
+ // shared libraries. They must share one React, the antd/X/cssinjs copies
101
+ // whose context providers (App, ConfigProvider, StyleProvider) the client
102
+ // renders, the dayjs whose global locale the client sets, and the server's
103
+ // @lowdefy packages (see lowdefyDependencies).
104
+ dedupe: [
105
+ 'react',
106
+ 'react-dom',
107
+ 'antd',
108
+ '@ant-design/x',
109
+ '@ant-design/cssinjs',
110
+ 'dayjs',
111
+ ...lowdefyDependencies,
112
+ ],
60
113
  },
61
114
  }));
@@ -1,87 +0,0 @@
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 path from 'path';
18
- import { createApiContext, redactErrorResponse } from '@lowdefy/api';
19
- import { getSecretsFromEnv } from '@lowdefy/node-utils';
20
- import { v4 as uuid } from 'uuid';
21
-
22
- import agents from '../../build/plugins/agents.js';
23
- import appMeta from '../build/appMeta.js';
24
- import config from '../build/config.js';
25
- import i18nConfig from '../build/i18n.js';
26
- import connections from '../../build/plugins/connections.js';
27
- import createLogger from './log/createLogger.js';
28
- import fileCache from './fileCache.js';
29
- import getServerSession from './auth/getServerSession.js';
30
- import createHandleError from './log/createHandleError.js';
31
- import logRequest from './log/logRequest.js';
32
- import operators from '../../build/plugins/operators/server.js';
33
- import jsMap from '../../build/plugins/operators/serverJsMap.js';
34
- import getAuthOptions from './auth/getAuthOptions.js';
35
- import loggerConfig from '../build/logger.js';
36
- import setSentryUser from './sentry/setSentryUser.js';
37
-
38
- const secrets = getSecretsFromEnv();
39
-
40
- function apiWrapper(handler) {
41
- return async function wrappedHandler(req, res) {
42
- const context = {
43
- // Important to give absolute path so Next can trace build files
44
- rid: uuid(),
45
- agents,
46
- appMeta,
47
- buildDirectory: path.join(process.cwd(), 'build'),
48
- config,
49
- connections,
50
- fileCache,
51
- headers: req?.headers,
52
- i18n: i18nConfig,
53
- jsMap,
54
- handleError: async (err) => {
55
- console.error(err);
56
- },
57
- logger: console,
58
- operators,
59
- req,
60
- res,
61
- secrets,
62
- };
63
- try {
64
- context.logger = createLogger({ rid: context.rid });
65
- context.handleError = createHandleError({ context });
66
- context.authOptions = getAuthOptions(context);
67
- if (!req.url.startsWith('/api/auth')) {
68
- context.session = await getServerSession(context);
69
- // Set Sentry user context for authenticated requests
70
- setSentryUser({
71
- user: context.session?.user,
72
- sentryConfig: loggerConfig.sentry,
73
- });
74
- }
75
- createApiContext(context);
76
- logRequest({ context });
77
- // Await here so that if handler throws it is caught.
78
- const response = await handler({ context, req, res });
79
- return response;
80
- } catch (error) {
81
- await context.handleError(error);
82
- res.status(500).json(redactErrorResponse(context, error));
83
- }
84
- };
85
- }
86
-
87
- export default apiWrapper;