@lowdefy/server 6.1.0 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/client/App.jsx +41 -21
  2. package/client/Page.jsx +90 -26
  3. package/client/clearReloadedForTypes.js +29 -0
  4. package/client/clientBuildImports.test.mjs +6 -7
  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 +222 -36
  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 -9
  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 +7 -0
  30. package/lib/server/log/createHandleError.test.mjs +17 -0
  31. package/lib/server/log/createLogger.test.mjs +15 -0
  32. package/lib/server/log/logRequest.js +5 -3
  33. package/lib/server/log/projectErrorForLog.js +6 -0
  34. package/lib/server/log/serializeErrorForLog.test.mjs +13 -1
  35. package/lib/server/sentry/captureSentryError.js +17 -3
  36. package/lib/server/sentry/captureSentryError.test.mjs +37 -13
  37. package/lib/server/sentry/initSentry.js +39 -18
  38. package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
  39. package/lib/server/sentry/initSentry.test.mjs +132 -4
  40. package/lib/server/sentry/scrubEvent.js +12 -1
  41. package/lib/server/sentry/sentrySdk.js +23 -0
  42. package/lib/server/sentry/setSentryUser.js +3 -3
  43. package/lowdefy/build.mjs +2 -0
  44. package/lowdefy/build.test.mjs +88 -0
  45. package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
  46. package/package.json +42 -36
  47. package/package.original.json +42 -36
  48. package/src/app.js +74 -17
  49. package/src/app.test.mjs +118 -0
  50. package/src/html/collectChunkClosure.js +31 -0
  51. package/src/html/collectLazyImports.js +41 -0
  52. package/src/html/collectPageTypesAssets.js +57 -0
  53. package/src/html/collectPageTypesAssets.test.mjs +130 -0
  54. package/src/html/getAssets.js +20 -1
  55. package/src/html/getHomePath.js +29 -0
  56. package/src/html/getPageAssets.js +32 -0
  57. package/src/html/getPageAssets.test.mjs +47 -0
  58. package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
  59. package/src/html/renderPage.js +80 -19
  60. package/src/html/renderPage.test.mjs +189 -0
  61. package/src/html/template.js +50 -15
  62. package/src/html/template.test.mjs +126 -0
  63. package/src/index.js +28 -29
  64. package/src/initServer.js +61 -0
  65. package/src/initServer.test.mjs +116 -0
  66. package/src/lib/getRequestPath.js +29 -0
  67. package/src/middleware/apiContext.js +65 -13
  68. package/src/middleware/apiContext.test.mjs +66 -6
  69. package/src/middleware/createSameOriginGuard.js +36 -0
  70. package/src/middleware/createSameOriginGuard.test.mjs +102 -0
  71. package/src/middleware/errorHandler.js +56 -4
  72. package/src/middleware/errorHandler.test.mjs +440 -0
  73. package/{lib/server/auth/session.js → src/middleware/noStore.js} +9 -13
  74. package/src/middleware/refuseOtherBuild.js +49 -0
  75. package/src/middleware/refuseOtherBuild.test.mjs +63 -0
  76. package/src/middleware/requestTimeout.js +38 -0
  77. package/src/middleware/sentry.js +13 -5
  78. package/src/middleware/stampBuildId.js +30 -0
  79. package/src/middleware/stampBuildId.test.mjs +41 -0
  80. package/src/middleware/stampCaller.js +38 -0
  81. package/src/middleware/stampCaller.test.mjs +64 -0
  82. package/src/routes/agent.js +15 -3
  83. package/src/routes/apiPage.js +51 -9
  84. package/src/routes/apiPage.test.mjs +99 -15
  85. package/src/routes/auth.js +15 -9
  86. package/src/routes/auth.test.mjs +85 -0
  87. package/src/routes/clientError.js +11 -13
  88. package/src/routes/cron.js +3 -1
  89. package/src/routes/cronForward.js +3 -1
  90. package/src/routes/detached.js +22 -9
  91. package/src/routes/endpoints.js +27 -26
  92. package/src/routes/endpointsTextAnswer.test.mjs +77 -0
  93. package/src/routes/mcp.js +82 -5
  94. package/src/routes/mcp.test.mjs +215 -0
  95. package/src/routes/mcpMemberToken.test.mjs +125 -0
  96. package/src/routes/mcpProtectedResourceMetadata.js +34 -0
  97. package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
  98. package/src/routes/mountOauthDiscovery.js +44 -0
  99. package/src/routes/mountOauthDiscovery.test.mjs +79 -0
  100. package/src/routes/mountPageRoutes.js +29 -0
  101. package/src/routes/mountPageRoutes.test.mjs +56 -0
  102. package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
  103. package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
  104. package/src/routes/request.js +5 -3
  105. package/src/routes/requestBody.test.mjs +146 -0
  106. package/src/routes/usage.js +6 -2
  107. package/src/routes/user.js +27 -0
  108. package/src/routes/websocket.js +26 -3
  109. package/src/routes/websocket.test.mjs +106 -0
  110. package/src/routes/wellKnownFallback.js +30 -0
  111. package/vite.config.js +55 -2
  112. package/client/shouldReloadForBuild.js +0 -48
  113. package/client/shouldReloadForBuild.test.mjs +0 -75
@@ -0,0 +1,440 @@
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 { HTTPException } from 'hono/http-exception';
19
+ import { jest } from '@jest/globals';
20
+
21
+ // The handler is byte-identical to `server-dev`'s and `server-e2e`'s apart from
22
+ // the Sentry capture tested at the bottom of this file. Keep the three in sync
23
+ // when changing any of them.
24
+ const captureException = jest.fn();
25
+
26
+ const { default: sentrySdk } = await import('../../lib/server/sentry/sentrySdk.js');
27
+ const { default: createErrorHandler } = await import('./errorHandler.js');
28
+
29
+ // A string that must never reach a client. It is put on `received` at every
30
+ // level of the cause chain, so a single JSON.stringify search over the body
31
+ // proves the redaction reached that level.
32
+ const SECRET = 'SECRET-RECEIVED-VALUE';
33
+
34
+ function createLogger() {
35
+ return { error: jest.fn(), warn: jest.fn() };
36
+ }
37
+
38
+ // The error handler is only reachable through hono's app-level onError, so the
39
+ // handler is driven the way production drives it: a route that throws, with the
40
+ // lowdefyContext set by upstream middleware when there is one.
41
+ function createApp({ basePath = '', context, error, logger }) {
42
+ const app = new Hono();
43
+ app.use('*', async (c, next) => {
44
+ if (context) c.set('lowdefyContext', context);
45
+ await next();
46
+ });
47
+ app.all('*', () => {
48
+ throw error;
49
+ });
50
+ app.onError(createErrorHandler({ basePath, logger }));
51
+ return app;
52
+ }
53
+
54
+ function createAuthenticationError(message) {
55
+ const error = new Error(message);
56
+ error.name = 'AuthenticationError';
57
+ error.received = SECRET;
58
+ return error;
59
+ }
60
+
61
+ function createAuthorizationError(message) {
62
+ const error = new Error(message);
63
+ error.name = 'AuthorizationError';
64
+ error.received = SECRET;
65
+ return error;
66
+ }
67
+
68
+ function createTwoFactorEnrolmentRequiredError(message) {
69
+ const error = new Error(message);
70
+ error.name = 'TwoFactorEnrolmentRequiredError';
71
+ error.received = SECRET;
72
+ return error;
73
+ }
74
+
75
+ function createUserError(message) {
76
+ const error = new Error(message);
77
+ error.name = 'UserError';
78
+ error.received = SECRET;
79
+ return error;
80
+ }
81
+
82
+ // Two Error levels, each with a stack (every Error has one) and a recognisable
83
+ // `received`, plus a configKey the policy keeps.
84
+ function createErrorWithCause() {
85
+ const cause = new Error('Cause message.');
86
+ cause.received = `${SECRET}-cause`;
87
+ const error = new Error('Top message.', { cause });
88
+ error.received = `${SECRET}-top`;
89
+ error.configKey = 'page:home.blocks.0';
90
+ return error;
91
+ }
92
+
93
+ test('errorHandler returns 401 with only name and message for an AuthenticationError on an api path', async () => {
94
+ const logger = createLogger();
95
+ const context = { handleError: jest.fn() };
96
+ const res = await createApp({
97
+ context,
98
+ error: createAuthenticationError('Unauthenticated.'),
99
+ logger,
100
+ }).request('/api/request/getUsers');
101
+
102
+ expect(res.status).toEqual(401);
103
+ expect(await res.json()).toEqual({ name: 'AuthenticationError', message: 'Unauthenticated.' });
104
+ expect(logger.warn).toHaveBeenCalledTimes(1);
105
+ expect(logger.warn.mock.calls[0][0]).toMatch(/Unauthenticated request/);
106
+ expect(logger.error).not.toHaveBeenCalled();
107
+ expect(context.handleError).not.toHaveBeenCalled();
108
+ });
109
+
110
+ test('errorHandler does not send an AuthenticationError through the redactor', async () => {
111
+ const res = await createApp({
112
+ error: createAuthenticationError('Unauthenticated.'),
113
+ logger: createLogger(),
114
+ }).request('/api/request/getUsers');
115
+
116
+ const body = await res.json();
117
+ expect(body['~e']).toBeUndefined();
118
+ });
119
+
120
+ test('errorHandler returns text Unauthorized at 401 for an AuthenticationError on a page path', async () => {
121
+ const logger = createLogger();
122
+ const res = await createApp({
123
+ error: createAuthenticationError('Unauthenticated.'),
124
+ logger,
125
+ }).request('/home');
126
+
127
+ expect(res.status).toEqual(401);
128
+ expect(await res.text()).toEqual('Unauthorized');
129
+ expect(logger.warn).toHaveBeenCalledTimes(1);
130
+ });
131
+
132
+ test('errorHandler returns 403 with only name and message for an AuthorizationError on an api path', async () => {
133
+ const logger = createLogger();
134
+ const context = { handleError: jest.fn() };
135
+ const res = await createApp({
136
+ context,
137
+ error: createAuthorizationError('Forbidden.'),
138
+ logger,
139
+ }).request('/api/request/getUsers');
140
+
141
+ expect(res.status).toEqual(403);
142
+ expect(await res.json()).toEqual({ name: 'AuthorizationError', message: 'Forbidden.' });
143
+ expect(logger.warn).toHaveBeenCalledTimes(1);
144
+ expect(logger.warn.mock.calls[0][0]).toMatch(/Forbidden: GET \/api\/request\/getUsers/);
145
+ expect(logger.error).not.toHaveBeenCalled();
146
+ expect(context.handleError).not.toHaveBeenCalled();
147
+ });
148
+
149
+ test('errorHandler does not send an AuthorizationError through the redactor', async () => {
150
+ const res = await createApp({
151
+ error: createAuthorizationError('Forbidden.'),
152
+ logger: createLogger(),
153
+ }).request('/api/request/getUsers');
154
+
155
+ const body = await res.json();
156
+ expect(body['~e']).toBeUndefined();
157
+ expect(JSON.stringify(body)).not.toContain(SECRET);
158
+ });
159
+
160
+ test('errorHandler returns text Forbidden at 403 for an AuthorizationError on a page path', async () => {
161
+ const logger = createLogger();
162
+ const res = await createApp({
163
+ error: createAuthorizationError('Forbidden.'),
164
+ logger,
165
+ }).request('/home');
166
+
167
+ expect(res.status).toEqual(403);
168
+ expect(await res.text()).toEqual('Forbidden');
169
+ expect(logger.warn).toHaveBeenCalledTimes(1);
170
+ expect(logger.error).not.toHaveBeenCalled();
171
+ });
172
+
173
+ test('errorHandler returns 403 with only name and message for a TwoFactorEnrolmentRequiredError on an api path', async () => {
174
+ const logger = createLogger();
175
+ const context = { handleError: jest.fn() };
176
+ const res = await createApp({
177
+ context,
178
+ error: createTwoFactorEnrolmentRequiredError('Two-factor enrolment required.'),
179
+ logger,
180
+ }).request('/api/request/getUsers');
181
+
182
+ expect(res.status).toEqual(403);
183
+ expect(await res.json()).toEqual({
184
+ name: 'TwoFactorEnrolmentRequiredError',
185
+ message: 'Two-factor enrolment required.',
186
+ });
187
+ expect(logger.warn).toHaveBeenCalledTimes(1);
188
+ expect(logger.warn.mock.calls[0][0]).toMatch(/Two-factor enrolment required/);
189
+ expect(logger.error).not.toHaveBeenCalled();
190
+ expect(context.handleError).not.toHaveBeenCalled();
191
+ });
192
+
193
+ test('errorHandler does not send a TwoFactorEnrolmentRequiredError through the redactor', async () => {
194
+ const res = await createApp({
195
+ error: createTwoFactorEnrolmentRequiredError('Two-factor enrolment required.'),
196
+ logger: createLogger(),
197
+ }).request('/api/request/getUsers');
198
+
199
+ const body = await res.json();
200
+ expect(body['~e']).toBeUndefined();
201
+ });
202
+
203
+ test('errorHandler returns text at 403 for a TwoFactorEnrolmentRequiredError on a page path', async () => {
204
+ const logger = createLogger();
205
+ const context = { handleError: jest.fn() };
206
+ const res = await createApp({
207
+ context,
208
+ error: createTwoFactorEnrolmentRequiredError('Two-factor enrolment required.'),
209
+ logger,
210
+ }).request('/home');
211
+
212
+ expect(res.status).toEqual(403);
213
+ expect(await res.text()).toEqual('Two-factor enrolment required');
214
+ expect(logger.warn).toHaveBeenCalledTimes(1);
215
+ expect(context.handleError).not.toHaveBeenCalled();
216
+ });
217
+
218
+ test('errorHandler returns 400 with only name and message for a UserError on an api path', async () => {
219
+ const logger = createLogger();
220
+ const context = { handleError: jest.fn() };
221
+ const res = await createApp({
222
+ context,
223
+ error: createUserError('Payload for endpoint "x" does not match its payloadSchema.'),
224
+ logger,
225
+ }).request('/api/endpoints/x', { method: 'POST' });
226
+
227
+ expect(res.status).toEqual(400);
228
+ expect(await res.json()).toEqual({
229
+ name: 'UserError',
230
+ message: 'Payload for endpoint "x" does not match its payloadSchema.',
231
+ });
232
+ expect(logger.warn).toHaveBeenCalledTimes(1);
233
+ expect(logger.warn.mock.calls[0][0]).toMatch(/Bad request: POST \/api\/endpoints\/x/);
234
+ expect(logger.error).not.toHaveBeenCalled();
235
+ expect(context.handleError).not.toHaveBeenCalled();
236
+ expect(captureException).not.toHaveBeenCalled();
237
+ });
238
+
239
+ test('errorHandler does not send a UserError through the redactor', async () => {
240
+ const res = await createApp({
241
+ error: createUserError('Bad payload.'),
242
+ logger: createLogger(),
243
+ }).request('/api/endpoints/x');
244
+
245
+ const body = await res.json();
246
+ expect(body['~e']).toBeUndefined();
247
+ expect(JSON.stringify(body)).not.toContain(SECRET);
248
+ });
249
+
250
+ test('errorHandler returns text Bad Request at 400 for a UserError on a page path', async () => {
251
+ const logger = createLogger();
252
+ const res = await createApp({
253
+ error: createUserError('Bad payload.'),
254
+ logger,
255
+ }).request('/home');
256
+
257
+ expect(res.status).toEqual(400);
258
+ expect(await res.text()).toEqual('Bad Request');
259
+ expect(logger.warn).toHaveBeenCalledTimes(1);
260
+ expect(logger.error).not.toHaveBeenCalled();
261
+ });
262
+
263
+ test('errorHandler sends an HTTPException response as-is with one warning and no error log', async () => {
264
+ const logger = createLogger();
265
+ const error = new HTTPException(404, {
266
+ res: Response.json(
267
+ { jsonrpc: '2.0', error: { code: -32000, message: 'Unsupported protocol version' } },
268
+ { status: 404 }
269
+ ),
270
+ });
271
+ const res = await createApp({ error, logger }).request('/api/mcp', { method: 'POST' });
272
+ expect(res.status).toEqual(404);
273
+ expect(await res.json()).toEqual({
274
+ jsonrpc: '2.0',
275
+ error: { code: -32000, message: 'Unsupported protocol version' },
276
+ });
277
+ expect(logger.warn).toHaveBeenCalledWith('404 answered by the route: POST /api/mcp');
278
+ expect(logger.error).not.toHaveBeenCalled();
279
+ });
280
+
281
+ test('errorHandler returns 500 with the serialized error envelope on an api path', async () => {
282
+ const res = await createApp({
283
+ error: createErrorWithCause(),
284
+ logger: createLogger(),
285
+ }).request('/api/request/getUsers');
286
+
287
+ expect(res.status).toEqual(500);
288
+ const body = await res.json();
289
+ expect(body['~e'].name).toEqual('Error');
290
+ expect(body['~e'].message).toEqual('Something went wrong.');
291
+ expect(body['~e'].cause).toBeUndefined();
292
+ });
293
+
294
+ test('errorHandler omits stack and received from the 500 envelope at the top level', async () => {
295
+ const res = await createApp({
296
+ error: createErrorWithCause(),
297
+ logger: createLogger(),
298
+ }).request('/api/request/getUsers');
299
+
300
+ const body = await res.json();
301
+ expect(body['~e'].stack).toBeUndefined();
302
+ expect(body['~e'].received).toBeUndefined();
303
+ });
304
+
305
+ test('errorHandler omits the cause chain from the 500 envelope', async () => {
306
+ const res = await createApp({
307
+ error: createErrorWithCause(),
308
+ logger: createLogger(),
309
+ }).request('/api/request/getUsers');
310
+
311
+ const body = await res.json();
312
+ expect(body['~e'].cause).toBeUndefined();
313
+ expect(JSON.stringify(body)).not.toContain(SECRET);
314
+ expect(JSON.stringify(body)).not.toContain('Cause message.');
315
+ });
316
+
317
+ test('errorHandler keeps configKey on the 500 envelope so the fault can be located in config', async () => {
318
+ const res = await createApp({
319
+ error: createErrorWithCause(),
320
+ logger: createLogger(),
321
+ }).request('/api/request/getUsers');
322
+
323
+ const body = await res.json();
324
+ expect(body['~e'].configKey).toEqual('page:home.blocks.0');
325
+ });
326
+
327
+ test('errorHandler returns text Internal Server Error at 500 on a page path with no payload', async () => {
328
+ const res = await createApp({
329
+ error: createErrorWithCause(),
330
+ logger: createLogger(),
331
+ }).request('/home');
332
+
333
+ expect(res.status).toEqual(500);
334
+ expect(await res.text()).toEqual('Internal Server Error');
335
+ });
336
+
337
+ test('errorHandler awaits context.handleError before serializing the response', async () => {
338
+ const context = {
339
+ handleError: jest.fn(async (error) => {
340
+ await new Promise((resolve) => setTimeout(resolve, 10));
341
+ error.handled = true;
342
+ }),
343
+ };
344
+ const error = createErrorWithCause();
345
+ const res = await createApp({ context, error, logger: createLogger() }).request(
346
+ '/api/request/getUsers'
347
+ );
348
+
349
+ expect(context.handleError).toHaveBeenCalledTimes(1);
350
+ expect(context.handleError).toHaveBeenCalledWith(error);
351
+ const body = await res.json();
352
+ expect(body['~e'].handled).toBe(true);
353
+ });
354
+
355
+ // createHandleError sets error.handled once it has logged. The client reads it
356
+ // to decide whether to POST the error back for logging, so it has to survive
357
+ // serialization or the error is logged a second time.
358
+ test('errorHandler returns handled true on the 500 envelope after handleError sets it', async () => {
359
+ const context = {
360
+ handleError: jest.fn(async (error) => {
361
+ error.handled = true;
362
+ }),
363
+ };
364
+ const res = await createApp({
365
+ context,
366
+ error: createErrorWithCause(),
367
+ logger: createLogger(),
368
+ }).request('/api/request/getUsers');
369
+
370
+ const body = await res.json();
371
+ expect(body['~e'].handled).toBe(true);
372
+ });
373
+
374
+ test('errorHandler logs with logger.error when the request has no lowdefyContext', async () => {
375
+ const logger = createLogger();
376
+ const error = createErrorWithCause();
377
+ const res = await createApp({ error, logger }).request('/api/request/getUsers');
378
+
379
+ expect(res.status).toEqual(500);
380
+ expect(logger.error).toHaveBeenCalledTimes(1);
381
+ expect(logger.error).toHaveBeenCalledWith(error);
382
+ });
383
+
384
+ test('errorHandler treats a basePath prefixed api path as an api path', async () => {
385
+ const res = await createApp({
386
+ basePath: '/base',
387
+ error: createErrorWithCause(),
388
+ logger: createLogger(),
389
+ }).request('/base/api/request/getUsers');
390
+
391
+ expect(res.status).toEqual(500);
392
+ const body = await res.json();
393
+ expect(body['~e'].message).toEqual('Something went wrong.');
394
+ });
395
+
396
+ test('errorHandler treats a basePath prefixed page path as a page path', async () => {
397
+ const res = await createApp({
398
+ basePath: '/base',
399
+ error: createErrorWithCause(),
400
+ logger: createLogger(),
401
+ }).request('/base/home');
402
+
403
+ expect(res.status).toEqual(500);
404
+ expect(await res.text()).toEqual('Internal Server Error');
405
+ });
406
+
407
+ // context.handleError captures to Sentry itself (createHandleError ->
408
+ // captureSentryError), so the handler must not capture the same fault again.
409
+ test('errorHandler does not capture to Sentry when a lowdefyContext handled the error', async () => {
410
+ sentrySdk.current = { captureException };
411
+ const context = { handleError: jest.fn() };
412
+ await createApp({
413
+ context,
414
+ error: createErrorWithCause(),
415
+ logger: createLogger(),
416
+ }).request('/api/request/getUsers');
417
+
418
+ expect(context.handleError).toHaveBeenCalledTimes(1);
419
+ expect(captureException).not.toHaveBeenCalled();
420
+ sentrySdk.current = null;
421
+ });
422
+
423
+ test('errorHandler captures to Sentry when there is no lowdefyContext to handle the error', async () => {
424
+ sentrySdk.current = { captureException };
425
+ const error = createErrorWithCause();
426
+ await createApp({ error, logger: createLogger() }).request('/api/request/getUsers');
427
+
428
+ expect(captureException).toHaveBeenCalledTimes(1);
429
+ expect(captureException).toHaveBeenCalledWith(error);
430
+ sentrySdk.current = null;
431
+ });
432
+
433
+ test('errorHandler does not capture to Sentry when Sentry was not started', async () => {
434
+ sentrySdk.current = null;
435
+ await createApp({ error: createErrorWithCause(), logger: createLogger() }).request(
436
+ '/api/request/getUsers'
437
+ );
438
+
439
+ expect(captureException).not.toHaveBeenCalled();
440
+ });
@@ -14,18 +14,14 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { getAuthUser } from '@hono/auth-js';
18
-
19
- import authJson from '../../build/auth.js';
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;
17
+ // Pages and API responses carry the caller's data, so neither the browser nor a
18
+ // shared proxy may store them. Set after next() so redirects and error responses
19
+ // carry it too.
20
+ function noStore() {
21
+ return async function noStoreMiddleware(c, next) {
22
+ await next();
23
+ c.header('Cache-Control', 'private, no-store');
24
+ };
29
25
  }
30
26
 
31
- export default getSession;
27
+ export default noStore;
@@ -0,0 +1,49 @@
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 { type } from '@lowdefy/helpers';
18
+
19
+ // Refuses a request or endpoint call from a tab running another build, before
20
+ // anything runs. The old page's payload may be the wrong shape for the new
21
+ // build's request and still be written, so a call that cannot be trusted is
22
+ // stopped here rather than detected on its response. The client reloads once
23
+ // onto the current build (@lowdefy/client request.js); a call that names no
24
+ // build (a third-party webhook, a page from before this check) runs as before.
25
+ // UserError is an expected outcome: if the reload guard has already fired the
26
+ // client shows the message without reporting it as a fault.
27
+ function refuseOtherBuild({ buildId }) {
28
+ return async function refuseOtherBuildMiddleware(c, next) {
29
+ const callerBuildId = c.req.header('x-lowdefy-build');
30
+ if (type.isNone(callerBuildId) || callerBuildId === buildId) {
31
+ return next();
32
+ }
33
+ c.get('lowdefyContext').logger.info({
34
+ event: 'refused_other_build',
35
+ path: c.req.path,
36
+ callerBuildId,
37
+ });
38
+ return c.json(
39
+ {
40
+ name: 'UserError',
41
+ message: 'This page is from an earlier version of the app. Reload the page to continue.',
42
+ buildId,
43
+ },
44
+ 409
45
+ );
46
+ };
47
+ }
48
+
49
+ export default refuseOtherBuild;
@@ -0,0 +1,63 @@
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 refuseOtherBuild from './refuseOtherBuild.js';
21
+
22
+ function createApp() {
23
+ const handler = jest.fn((c) => c.json({ ran: true }));
24
+ const app = new Hono();
25
+ app.use('*', async (c, next) => {
26
+ c.set('lowdefyContext', { logger: { info: jest.fn() } });
27
+ await next();
28
+ });
29
+ app.post('/api/request/home/save', refuseOtherBuild({ buildId: 'build-2' }), handler);
30
+ return { app, handler };
31
+ }
32
+
33
+ test('refuseOtherBuild refuses a call from another build with 409 before the handler runs', async () => {
34
+ const { app, handler } = createApp();
35
+ const res = await app.request('/api/request/home/save', {
36
+ method: 'POST',
37
+ headers: { 'x-lowdefy-build': 'build-1' },
38
+ });
39
+ expect(res.status).toBe(409);
40
+ expect(await res.json()).toEqual({
41
+ name: 'UserError',
42
+ message: 'This page is from an earlier version of the app. Reload the page to continue.',
43
+ buildId: 'build-2',
44
+ });
45
+ expect(handler).not.toHaveBeenCalled();
46
+ });
47
+
48
+ test('refuseOtherBuild runs a call from the same build', async () => {
49
+ const { app, handler } = createApp();
50
+ const res = await app.request('/api/request/home/save', {
51
+ method: 'POST',
52
+ headers: { 'x-lowdefy-build': 'build-2' },
53
+ });
54
+ expect(res.status).toBe(200);
55
+ expect(handler).toHaveBeenCalledTimes(1);
56
+ });
57
+
58
+ test('refuseOtherBuild runs a call that names no build', async () => {
59
+ const { app, handler } = createApp();
60
+ const res = await app.request('/api/request/home/save', { method: 'POST' });
61
+ expect(res.status).toBe(200);
62
+ expect(handler).toHaveBeenCalledTimes(1);
63
+ });
@@ -0,0 +1,38 @@
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 { HTTPException } from 'hono/http-exception';
18
+ import { timeout } from 'hono/timeout';
19
+
20
+ // Caps how long a request may run, and cancels the work it leaves behind. The 504 is
21
+ // answered while the handler is still running; the TimeoutError on the request's
22
+ // timeout signal (which apiContext folds into context.signal) cancels the upstream
23
+ // calls the handler is waiting on, such as an AI provider call, instead of letting
24
+ // them run, and bill, after the response has gone.
25
+ function requestTimeout({ timeoutMs }) {
26
+ return function requestTimeoutMiddleware(c, next) {
27
+ const controller = new AbortController();
28
+ c.set('requestTimeoutSignal', controller.signal);
29
+ return timeout(timeoutMs, () => {
30
+ controller.abort(
31
+ new DOMException(`The request timeout of ${timeoutMs}ms was exceeded.`, 'TimeoutError')
32
+ );
33
+ return new HTTPException(504, { message: 'Gateway Timeout' });
34
+ })(c, next);
35
+ };
36
+ }
37
+
38
+ export default requestTimeout;
@@ -14,16 +14,24 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import * as Sentry from '@sentry/node';
17
+ import { bindCredentialScrub } from '@lowdefy/node-utils';
18
18
 
19
- // Replaces @sentry/nextjs route auto-instrumentation: every request becomes
20
- // an http.server transaction. Handler errors never propagate through
21
- // middleware in Hono — captureException happens in the app error handler.
19
+ import sentrySdk from '../../lib/server/sentry/sentrySdk.js';
20
+
21
+ // Every request becomes an http.server transaction when the server traces requests. Handler
22
+ // errors never propagate through middleware in Hono — captureException happens in the app error
23
+ // handler.
22
24
  function sentryMiddleware() {
23
25
  return async function sentry(c, next) {
24
- if (!process.env.SENTRY_DSN) {
26
+ const Sentry = sentrySdk.current;
27
+ if (!Sentry) {
25
28
  return next();
26
29
  }
30
+ // The request's transaction is sent once the response is written, after its credential scope
31
+ // has ended, so the scope's scrub rides on the request's isolation scope to scrubEvent.
32
+ Sentry.getIsolationScope().setSDKProcessingMetadata({
33
+ scrubRequestCredentials: bindCredentialScrub(),
34
+ });
27
35
  return Sentry.startSpan({ name: `${c.req.method} ${c.req.path}`, op: 'http.server' }, () =>
28
36
  next()
29
37
  );
@@ -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
+ // Stamps the server's build on auth responses. Auth calls are not refused on a
18
+ // mismatch (they are Lowdefy's routes, not app config, and a session refetch on
19
+ // window focus would reload away unsaved input); instead the client reloads
20
+ // when an auth call fails and this header shows a newer build answered
21
+ // (lib/client/auth/createFetchCheckingBuild.js). Set after next() so error responses
22
+ // and the raw responses BetterAuth returns carry it too.
23
+ function stampBuildId({ buildId }) {
24
+ return async function stampBuildIdMiddleware(c, next) {
25
+ await next();
26
+ c.header('x-lowdefy-build', buildId);
27
+ };
28
+ }
29
+
30
+ export default stampBuildId;