@lowdefy/server-e2e 0.0.0-experimental-20260909201552 → 0.0.0-experimental-20260911113654

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/App.jsx CHANGED
@@ -39,7 +39,7 @@ function ThemeTokenResolver({ lowdefyRef, children }) {
39
39
  }
40
40
 
41
41
  function App({ config }) {
42
- const { rootConfig, user } = config;
42
+ const { rootConfig, session } = config;
43
43
 
44
44
  const usageDataRef = useRef({});
45
45
  const lowdefyRef = useRef({ eventCallback: createLogUsage({ usageDataRef }) });
@@ -111,9 +111,9 @@ function App({ config }) {
111
111
  <AntdApp>
112
112
  <ThemeTokenResolver lowdefyRef={lowdefyRef}>
113
113
  <ErrorBoundary fullPage onError={handleError}>
114
- <Auth user={user}>
114
+ <Auth session={session}>
115
115
  {(auth) => {
116
- usageDataRef.current.user = auth.user?.id;
116
+ usageDataRef.current.user = auth.session?.hashed_id;
117
117
  return <Page auth={auth} config={config} lowdefy={lowdefyRef.current} />;
118
118
  }}
119
119
  </Auth>
@@ -24,46 +24,19 @@ function e2eNotSupported() {
24
24
  throw new Error('Sign-in and sign-out are not supported in e2e testing.');
25
25
  }
26
26
 
27
- function Auth({ children, user }) {
27
+ function Auth({ children, session }) {
28
28
  const auth = {
29
29
  authConfig,
30
- user,
31
- // Matches the BetterAuth client contract: { data, error }.
30
+ session,
32
31
  getSession: async () => {
33
32
  const res = await fetch('/api/auth/session');
34
- if (res.ok) {
35
- return { data: await res.json(), error: null };
36
- }
37
- return { data: null, error: null };
38
- },
39
- getResolvedUser: async () => {
40
- const res = await fetch('/api/user');
41
33
  if (res.ok) {
42
34
  return res.json();
43
35
  }
44
- return { user: null };
36
+ return null;
45
37
  },
46
- acceptInvitation: e2eNotSupported,
47
- addPasskey: e2eNotSupported,
48
- changePassword: e2eNotSupported,
49
- deletePasskey: e2eNotSupported,
50
- updatePasskey: e2eNotSupported,
51
- requestPasswordReset: e2eNotSupported,
52
- resetPassword: e2eNotSupported,
53
- revokeOtherSessions: e2eNotSupported,
54
- sendVerificationEmail: e2eNotSupported,
55
- setActiveOrganization: e2eNotSupported,
56
- signInEmail: e2eNotSupported,
57
- signInMagicLink: e2eNotSupported,
58
- signInOauth2: e2eNotSupported,
59
- signInSocial: e2eNotSupported,
38
+ signIn: e2eNotSupported,
60
39
  signOut: e2eNotSupported,
61
- signUpEmail: e2eNotSupported,
62
- twoFactorDisable: e2eNotSupported,
63
- twoFactorEnable: e2eNotSupported,
64
- twoFactorGenerateBackupCodes: e2eNotSupported,
65
- twoFactorVerifyBackupCode: e2eNotSupported,
66
- twoFactorVerifyTotp: e2eNotSupported,
67
40
  };
68
41
  return children(auth);
69
42
  }
@@ -14,23 +14,22 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- // E2E test-user injection: the caller is read from the lowdefy_e2e_user
17
+ // E2E test-user injection: the session is read from the lowdefy_e2e_user
18
18
  // cookie (base64-encoded JSON user object) instead of a real auth engine.
19
- // The cookie user is pre-resolved - it substitutes for resolveAuthentication
20
- // and its roles are authoritative.
21
- function getUser(c) {
19
+ function getSession(c) {
22
20
  const cookieHeader = c.req.header('cookie') ?? '';
23
21
  const match = cookieHeader.match(/lowdefy_e2e_user=([^;]+)/);
24
22
  if (!match) {
25
- return null;
23
+ return undefined;
26
24
  }
27
25
 
28
26
  try {
29
27
  const decoded = Buffer.from(decodeURIComponent(match[1]), 'base64').toString();
30
- return JSON.parse(decoded);
28
+ const user = JSON.parse(decoded);
29
+ return { user };
31
30
  } catch {
32
- return null;
31
+ return undefined;
33
32
  }
34
33
  }
35
34
 
36
- export default getUser;
35
+ export default getSession;
@@ -15,11 +15,13 @@
15
15
  */
16
16
 
17
17
  function logRequest({ context }) {
18
- const user = context.user ?? {};
18
+ const { user = {} } = context.session ?? {};
19
19
  context.logger.debug({
20
20
  user: {
21
21
  id: user.id,
22
22
  roles: user.roles,
23
+ sub: user.sub,
24
+ session_id: user.session_id,
23
25
  },
24
26
  url: context.req.url,
25
27
  method: context.req.method,
@@ -42,11 +42,11 @@ async function createCustomPluginTypesMap({ directories }) {
42
42
  agents: {},
43
43
  auth: {
44
44
  adapters: {},
45
+ callbacks: {},
46
+ events: {},
45
47
  providers: {},
46
- strategies: {},
47
48
  },
48
49
  blockMetas: {},
49
- connectionMetas: {},
50
50
  blocks: {},
51
51
  connections: {},
52
52
  icons: {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/server-e2e",
3
- "version": "0.0.0-experimental-20260909201552",
3
+ "version": "0.0.0-experimental-20260911113654",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Lowdefy e2e testing server with cookie-based user injection",
6
6
  "homepage": "https://lowdefy.com",
@@ -43,30 +43,29 @@
43
43
  "@ant-design/cssinjs": "2.1.2",
44
44
  "@ant-design/x": "2.7.0",
45
45
  "@hono/node-server": "2.0.4",
46
- "@lowdefy/actions-core": "0.0.0-experimental-20260909201552",
47
- "@lowdefy/api": "0.0.0-experimental-20260909201552",
48
- "@lowdefy/block-utils": "0.0.0-experimental-20260909201552",
49
- "@lowdefy/blocks-aggrid": "0.0.0-experimental-20260909201552",
50
- "@lowdefy/blocks-antd": "0.0.0-experimental-20260909201552",
51
- "@lowdefy/blocks-antd-x": "0.0.0-experimental-20260909201552",
52
- "@lowdefy/blocks-basic": "0.0.0-experimental-20260909201552",
53
- "@lowdefy/blocks-captcha": "0.0.0-experimental-20260909201552",
54
- "@lowdefy/blocks-echarts": "0.0.0-experimental-20260909201552",
55
- "@lowdefy/blocks-loaders": "0.0.0-experimental-20260909201552",
56
- "@lowdefy/blocks-markdown": "0.0.0-experimental-20260909201552",
57
- "@lowdefy/blocks-tiptap": "0.0.0-experimental-20260909201552",
58
- "@lowdefy/client": "0.0.0-experimental-20260909201552",
59
- "@lowdefy/connection-axios-http": "0.0.0-experimental-20260909201552",
60
- "@lowdefy/connection-mongodb": "0.0.0-experimental-20260909201552",
61
- "@lowdefy/errors": "0.0.0-experimental-20260909201552",
62
- "@lowdefy/helpers": "0.0.0-experimental-20260909201552",
63
- "@lowdefy/layout": "0.0.0-experimental-20260909201552",
64
- "@lowdefy/logger": "0.0.0-experimental-20260909201552",
65
- "@lowdefy/node-utils": "0.0.0-experimental-20260909201552",
66
- "@lowdefy/operators-js": "0.0.0-experimental-20260909201552",
67
- "@lowdefy/operators-nunjucks": "0.0.0-experimental-20260909201552",
68
- "@lowdefy/operators-uuid": "0.0.0-experimental-20260909201552",
69
- "@lowdefy/websockets-core": "0.0.0-experimental-20260909201552",
46
+ "@lowdefy/actions-core": "0.0.0-experimental-20260911113654",
47
+ "@lowdefy/api": "0.0.0-experimental-20260911113654",
48
+ "@lowdefy/block-utils": "0.0.0-experimental-20260911113654",
49
+ "@lowdefy/blocks-aggrid": "0.0.0-experimental-20260911113654",
50
+ "@lowdefy/blocks-antd": "0.0.0-experimental-20260911113654",
51
+ "@lowdefy/blocks-antd-x": "0.0.0-experimental-20260911113654",
52
+ "@lowdefy/blocks-basic": "0.0.0-experimental-20260911113654",
53
+ "@lowdefy/blocks-echarts": "0.0.0-experimental-20260911113654",
54
+ "@lowdefy/blocks-loaders": "0.0.0-experimental-20260911113654",
55
+ "@lowdefy/blocks-markdown": "0.0.0-experimental-20260911113654",
56
+ "@lowdefy/blocks-tiptap": "0.0.0-experimental-20260911113654",
57
+ "@lowdefy/client": "0.0.0-experimental-20260911113654",
58
+ "@lowdefy/connection-axios-http": "0.0.0-experimental-20260911113654",
59
+ "@lowdefy/connection-mongodb": "0.0.0-experimental-20260911113654",
60
+ "@lowdefy/errors": "0.0.0-experimental-20260911113654",
61
+ "@lowdefy/helpers": "0.0.0-experimental-20260911113654",
62
+ "@lowdefy/layout": "0.0.0-experimental-20260911113654",
63
+ "@lowdefy/logger": "0.0.0-experimental-20260911113654",
64
+ "@lowdefy/node-utils": "0.0.0-experimental-20260911113654",
65
+ "@lowdefy/operators-js": "0.0.0-experimental-20260911113654",
66
+ "@lowdefy/operators-nunjucks": "0.0.0-experimental-20260911113654",
67
+ "@lowdefy/operators-uuid": "0.0.0-experimental-20260911113654",
68
+ "@lowdefy/websockets-core": "0.0.0-experimental-20260911113654",
70
69
  "@tailwindcss/postcss": "4.3.1",
71
70
  "@vitejs/plugin-react": "6.0.2",
72
71
  "antd": "6.3.1",
@@ -83,7 +82,7 @@
83
82
  },
84
83
  "devDependencies": {
85
84
  "@jest/globals": "28.1.3",
86
- "@lowdefy/build": "0.0.0-experimental-20260909201552",
85
+ "@lowdefy/build": "0.0.0-experimental-20260911113654",
87
86
  "@next/eslint-plugin-next": "16.1.6",
88
87
  "@tailwindcss/postcss": "4.3.1",
89
88
  "jest": "28.1.3",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/server-e2e",
3
- "version": "0.0.0-experimental-20260909201552",
3
+ "version": "0.0.0-experimental-20260911113654",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Lowdefy e2e testing server with cookie-based user injection",
6
6
  "homepage": "https://lowdefy.com",
@@ -51,30 +51,29 @@
51
51
  "@ant-design/cssinjs": "2.1.2",
52
52
  "@ant-design/x": "2.7.0",
53
53
  "@hono/node-server": "2.0.4",
54
- "@lowdefy/actions-core": "0.0.0-experimental-20260909201552",
55
- "@lowdefy/api": "0.0.0-experimental-20260909201552",
56
- "@lowdefy/block-utils": "0.0.0-experimental-20260909201552",
57
- "@lowdefy/blocks-aggrid": "0.0.0-experimental-20260909201552",
58
- "@lowdefy/blocks-antd": "0.0.0-experimental-20260909201552",
59
- "@lowdefy/blocks-antd-x": "0.0.0-experimental-20260909201552",
60
- "@lowdefy/blocks-basic": "0.0.0-experimental-20260909201552",
61
- "@lowdefy/blocks-captcha": "0.0.0-experimental-20260909201552",
62
- "@lowdefy/blocks-echarts": "0.0.0-experimental-20260909201552",
63
- "@lowdefy/blocks-loaders": "0.0.0-experimental-20260909201552",
64
- "@lowdefy/blocks-markdown": "0.0.0-experimental-20260909201552",
65
- "@lowdefy/blocks-tiptap": "0.0.0-experimental-20260909201552",
66
- "@lowdefy/client": "0.0.0-experimental-20260909201552",
67
- "@lowdefy/connection-axios-http": "0.0.0-experimental-20260909201552",
68
- "@lowdefy/connection-mongodb": "0.0.0-experimental-20260909201552",
69
- "@lowdefy/errors": "0.0.0-experimental-20260909201552",
70
- "@lowdefy/helpers": "0.0.0-experimental-20260909201552",
71
- "@lowdefy/layout": "0.0.0-experimental-20260909201552",
72
- "@lowdefy/logger": "0.0.0-experimental-20260909201552",
73
- "@lowdefy/node-utils": "0.0.0-experimental-20260909201552",
74
- "@lowdefy/operators-js": "0.0.0-experimental-20260909201552",
75
- "@lowdefy/operators-nunjucks": "0.0.0-experimental-20260909201552",
76
- "@lowdefy/operators-uuid": "0.0.0-experimental-20260909201552",
77
- "@lowdefy/websockets-core": "0.0.0-experimental-20260909201552",
54
+ "@lowdefy/actions-core": "0.0.0-experimental-20260911113654",
55
+ "@lowdefy/api": "0.0.0-experimental-20260911113654",
56
+ "@lowdefy/block-utils": "0.0.0-experimental-20260911113654",
57
+ "@lowdefy/blocks-aggrid": "0.0.0-experimental-20260911113654",
58
+ "@lowdefy/blocks-antd": "0.0.0-experimental-20260911113654",
59
+ "@lowdefy/blocks-antd-x": "0.0.0-experimental-20260911113654",
60
+ "@lowdefy/blocks-basic": "0.0.0-experimental-20260911113654",
61
+ "@lowdefy/blocks-echarts": "0.0.0-experimental-20260911113654",
62
+ "@lowdefy/blocks-loaders": "0.0.0-experimental-20260911113654",
63
+ "@lowdefy/blocks-markdown": "0.0.0-experimental-20260911113654",
64
+ "@lowdefy/blocks-tiptap": "0.0.0-experimental-20260911113654",
65
+ "@lowdefy/client": "0.0.0-experimental-20260911113654",
66
+ "@lowdefy/connection-axios-http": "0.0.0-experimental-20260911113654",
67
+ "@lowdefy/connection-mongodb": "0.0.0-experimental-20260911113654",
68
+ "@lowdefy/errors": "0.0.0-experimental-20260911113654",
69
+ "@lowdefy/helpers": "0.0.0-experimental-20260911113654",
70
+ "@lowdefy/layout": "0.0.0-experimental-20260911113654",
71
+ "@lowdefy/logger": "0.0.0-experimental-20260911113654",
72
+ "@lowdefy/node-utils": "0.0.0-experimental-20260911113654",
73
+ "@lowdefy/operators-js": "0.0.0-experimental-20260911113654",
74
+ "@lowdefy/operators-nunjucks": "0.0.0-experimental-20260911113654",
75
+ "@lowdefy/operators-uuid": "0.0.0-experimental-20260911113654",
76
+ "@lowdefy/websockets-core": "0.0.0-experimental-20260911113654",
78
77
  "@tailwindcss/postcss": "4.3.1",
79
78
  "@vitejs/plugin-react": "6.0.2",
80
79
  "antd": "6.3.1",
@@ -91,7 +90,7 @@
91
90
  },
92
91
  "devDependencies": {
93
92
  "@jest/globals": "28.1.3",
94
- "@lowdefy/build": "0.0.0-experimental-20260909201552",
93
+ "@lowdefy/build": "0.0.0-experimental-20260911113654",
95
94
  "@next/eslint-plugin-next": "16.1.6",
96
95
  "@tailwindcss/postcss": "4.3.1",
97
96
  "jest": "28.1.3",
package/src/app.js CHANGED
@@ -29,7 +29,6 @@ import renderPage from './html/renderPage.js';
29
29
  import requestHandler from './routes/request.js';
30
30
  import sessionMockHandler from './routes/sessionMock.js';
31
31
  import usageHandler from './routes/usage.js';
32
- import userHandler from './routes/user.js';
33
32
  import websocketHandler from './routes/websocket.js';
34
33
 
35
34
  const basePath = lowdefyConfig.basePath ?? '';
@@ -55,7 +54,6 @@ function createApp() {
55
54
  app.all('/api/usage', usageHandler);
56
55
  app.get('/api/websocket', websocketHandler);
57
56
  app.get('/api/page/*', apiPageHandler);
58
- app.get('/api/user', userHandler);
59
57
 
60
58
  // Vite build output (includes public/ via Vite's publicDir copy). Falls
61
59
  // through to the page routes when no file matches.
@@ -17,8 +17,6 @@
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
 
20
- import { LowdefyInternalError } from '@lowdefy/errors';
21
-
22
20
  // The Vite manifest is read once at startup — deploys start a new server
23
21
  // process after `vite build` completes, so assets never change at runtime.
24
22
  // In-place deploys must build before restarting (documented).
@@ -32,7 +30,7 @@ function getAssets() {
32
30
  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
33
31
  const entry = manifest['client/main.jsx'];
34
32
  if (!entry) {
35
- throw new LowdefyInternalError(
33
+ throw new Error(
36
34
  'Vite manifest has no "client/main.jsx" entry. Run the client build before starting the server.'
37
35
  );
38
36
  }
@@ -28,7 +28,7 @@ const basePath = lowdefyConfig.basePath ?? '';
28
28
  // getStaticProps. The home redirect logic lives here, not at the route level.
29
29
  async function renderPage(c, { pageId, status = 200 }) {
30
30
  const context = c.get('lowdefyContext');
31
- const { logger, user } = context;
31
+ const { logger, session } = context;
32
32
 
33
33
  let resolvedPageId = pageId;
34
34
  const rootConfig = await getRootConfig(context);
@@ -42,12 +42,12 @@ async function renderPage(c, { pageId, status = 200 }) {
42
42
  resolvedPageId = home.pageId;
43
43
  }
44
44
 
45
- const result = await getPageConfig(context, {
45
+ const pageConfig = await getPageConfig(context, {
46
46
  pageId: resolvedPageId,
47
47
  urlQuery: c.req.query(),
48
48
  });
49
49
 
50
- if (result.status !== 'ok') {
50
+ if (!pageConfig) {
51
51
  if (resolvedPageId === '404') {
52
52
  // No 404 page in the build — return a plain 404 rather than redirecting in a loop.
53
53
  return c.text('Page not found.', 404);
@@ -56,8 +56,6 @@ async function renderPage(c, { pageId, status = 200 }) {
56
56
  return c.redirect(`${basePath}/404`, 302);
57
57
  }
58
58
 
59
- const { pageConfig } = result;
60
-
61
59
  logger.info({ event: 'page_view', pageId: resolvedPageId });
62
60
 
63
61
  const html = template({
@@ -69,7 +67,7 @@ async function renderPage(c, { pageId, status = 200 }) {
69
67
  basePath,
70
68
  pageConfig,
71
69
  rootConfig,
72
- user: user ?? null,
70
+ session: session ?? null,
73
71
  },
74
72
  themeConfig,
75
73
  title: pageConfig.properties?.title ?? resolvedPageId,
@@ -15,24 +15,23 @@
15
15
  */
16
16
 
17
17
  import path from 'node:path';
18
- import { createApiContext, normalizeInjectedCaller } from '@lowdefy/api';
18
+ import { createApiContext } from '@lowdefy/api';
19
19
  import { v4 as uuid } from 'uuid';
20
20
 
21
21
  import agents from '../../build/plugins/agents.js';
22
22
  import appMeta from '../../lib/build/appMeta.js';
23
- import authJson from '../../lib/build/auth.js';
24
23
  import config from '../../lib/build/config.js';
25
24
  import connections from '../../build/plugins/connections.js';
26
25
  import createHandleError from '../../lib/server/log/createHandleError.js';
27
26
  import createLogger from '../../lib/server/log/createLogger.js';
28
27
  import fileCache from '../../lib/server/fileCache.js';
29
28
  import getE2eSecrets from '../../lib/server/getE2eSecrets.js';
30
- import getUser from '../../lib/server/auth/getUser.js';
29
+ import getSession from '../../lib/server/auth/session.js';
31
30
  import i18nConfig from '../../lib/build/i18n.js';
32
31
  import jsMap from '../../build/plugins/operators/serverJsMap.js';
33
32
  import logRequest from '../../lib/server/log/logRequest.js';
34
33
  import operators from '../../build/plugins/operators/server.js';
35
- import steps from '../../build/plugins/steps.js';
34
+ import reportsRuntime from '../../build/plugins/reportsRuntime.js';
36
35
  import websockets from '../../build/plugins/websockets.js';
37
36
 
38
37
  const secrets = getE2eSecrets();
@@ -53,6 +52,8 @@ function apiContext() {
53
52
  rid,
54
53
  agents,
55
54
  appMeta,
55
+ reportsRuntime,
56
+ publicDirectory: path.join(process.cwd(), 'public'),
56
57
  buildDirectory: path.join(process.cwd(), 'build'),
57
58
  config,
58
59
  configDirectory: process.env.LOWDEFY_DIRECTORY_CONFIG || process.cwd(),
@@ -69,23 +70,11 @@ function apiContext() {
69
70
  hostname: c.req.header('host'),
70
71
  },
71
72
  secrets,
72
- steps,
73
73
  websockets,
74
74
  };
75
75
  context.handleError = createHandleError({ context });
76
- // The cookie user is a pre-resolved caller substituting for
77
- // resolveAuthentication - normalizeInjectedCaller floors it to the
78
- // resolved-caller shape. An absent cookie stays a logged-out (null) caller.
79
- const user = getUser(c);
80
- context.user = user ? normalizeInjectedCaller(user) : null;
76
+ context.session = getSession(c);
81
77
  createApiContext(context);
82
- // No auth engine runs in the e2e server, so createApiContext retains no
83
- // organization binding. Derive the policy from the built auth config so
84
- // the tenant wall's policy gate still engages for a tenant app; pinned
85
- // stays null (no engine means no seeded organization row).
86
- if (authJson.organizations) {
87
- context.organization = { policy: authJson.organizations.policy, pinned: null };
88
- }
89
78
  logRequest({ context });
90
79
  c.set('lowdefyContext', context);
91
80
  return next();
@@ -15,64 +15,20 @@
15
15
  */
16
16
 
17
17
  import { redactErrorResponse } from '@lowdefy/api';
18
- import { HTTPException } from 'hono/http-exception';
19
18
 
20
19
  // Hono routes every handler error to the app-level error handler — upstream
21
- // middleware try/catch never sees them. API routes get the serialized error
22
- // JSON the old apiWrapper returned; page routes get a plain 500.
20
+ // middleware try/catch never sees them (compose catches per dispatch level).
21
+ // API routes get the serialized error JSON the old apiWrapper returned;
22
+ // page routes get a plain 500.
23
23
  function createErrorHandler({ basePath = '', logger }) {
24
24
  return async function errorHandler(error, c) {
25
- const path = basePath ? c.req.path.replace(basePath, '') : c.req.path;
26
- // Unauthenticated requests to protected endpoints are expected traffic -
27
- // one warning line and a 401, skipping the structured error log so
28
- // probing cannot flood it. Keys strictly on the error name; the 500 path
29
- // below is untouched.
30
- if (error.name === 'AuthenticationError') {
31
- logger.warn(`Unauthenticated request: ${c.req.method} ${c.req.path}`);
32
- if (path.startsWith('/api/')) {
33
- return c.json({ name: error.name, message: error.message }, 401);
34
- }
35
- return c.text('Unauthorized', 401);
36
- }
37
- // An authenticated caller whose roles do not permit the resource. Expected
38
- // traffic, not a fault: one warning line, no structured error log and no
39
- // Sentry capture.
40
- if (error.name === 'AuthorizationError') {
41
- logger.warn(`Forbidden: ${c.req.method} ${c.req.path}`);
42
- if (path.startsWith('/api/')) {
43
- return c.json({ name: error.name, message: error.message }, 403);
44
- }
45
- return c.text('Forbidden', 403);
46
- }
47
- // An authorized caller who has not enrolled a second factor under
48
- // auth.twoFactor.required. 403, not 401 - a 401 reads to the client as a dead
49
- // session and bounces the user to sign-in, which is the loop the gate exists to
50
- // avoid. Expected traffic under required: true, so one warning line and no
51
- // Sentry capture, exactly as for AuthenticationError.
52
- if (error.name === 'TwoFactorEnrolmentRequiredError') {
53
- logger.warn(`Two-factor enrolment required: ${c.req.method} ${c.req.path}`);
54
- if (path.startsWith('/api/')) {
55
- return c.json({ name: error.name, message: error.message }, 403);
56
- }
57
- return c.text('Two-factor enrolment required', 403);
58
- }
59
- // A route or transport that has already decided its own HTTP answer - hono's
60
- // HTTPException carries the response to send. The MCP transport refuses an
61
- // unsupported protocol-version header this way (a 404 with a JSON-RPC body
62
- // that the client SDK falls back on to negotiate a version both sides
63
- // know). Not a fault: send its response and log one warning line, no
64
- // structured error log and no Sentry capture. Turning it into a 500 would
65
- // hide the status the client acts on.
66
- if (error instanceof HTTPException) {
67
- logger.warn(`${error.status} answered by the route: ${c.req.method} ${c.req.path}`);
68
- return error.getResponse();
69
- }
70
25
  const context = c.get('lowdefyContext');
71
26
  if (context) {
72
27
  await context.handleError(error);
73
28
  } else {
74
29
  logger.error(error);
75
30
  }
31
+ const path = basePath ? c.req.path.replace(basePath, '') : c.req.path;
76
32
  if (path.startsWith('/api/')) {
77
33
  return c.json(redactErrorResponse(context, error), 500);
78
34
  }
@@ -25,13 +25,13 @@ async function apiPageHandler(c) {
25
25
  const pageId = getPathSegments(c, '/api/page/').join('/');
26
26
  // The client forwards its current query string on the fetch so Dynamic block
27
27
  // resolution sees the same urlQuery as an initial HTML load.
28
- const result = await getPageConfig(context, { pageId, urlQuery: c.req.query() });
29
- if (result.status !== 'ok') {
28
+ const pageConfig = await getPageConfig(context, { pageId, urlQuery: c.req.query() });
29
+ if (!pageConfig) {
30
30
  context.logger.info({ event: 'api_page_not_found', pageId });
31
31
  return c.json({ pageConfig: null }, 404);
32
32
  }
33
33
  context.logger.info({ event: 'api_page_view', pageId });
34
- return c.json({ pageConfig: result.pageConfig });
34
+ return c.json({ pageConfig });
35
35
  }
36
36
 
37
37
  export default apiPageHandler;
@@ -18,9 +18,7 @@ import { logClientError } from '@lowdefy/api';
18
18
 
19
19
  async function clientErrorHandler(c) {
20
20
  if (c.req.method !== 'POST') {
21
- // A wrong-method request is client-caused: answer 405 rather than raising a
22
- // fault that would be logged at error level and answered with a 500.
23
- return c.json({ error: 'Method not allowed.' }, 405);
21
+ throw new Error('Only POST requests are supported.');
24
22
  }
25
23
  const context = c.get('lowdefyContext');
26
24
 
@@ -20,9 +20,7 @@ import getPathSegments from '../lib/getPathSegments.js';
20
20
 
21
21
  async function endpointsHandler(c) {
22
22
  if (c.req.method !== 'POST') {
23
- // A wrong-method request is client-caused: answer 405 rather than raising a
24
- // fault that would be logged at error level and answered with a 500.
25
- return c.json({ error: 'Method not allowed.' }, 405);
23
+ throw new Error('Only POST requests are supported.');
26
24
  }
27
25
  const context = c.get('lowdefyContext');
28
26
  const endpointId = getPathSegments(c, '/api/endpoints/').join('/');
@@ -20,9 +20,7 @@ import getPathSegments from '../lib/getPathSegments.js';
20
20
 
21
21
  async function requestHandler(c) {
22
22
  if (c.req.method !== 'POST') {
23
- // A wrong-method request is client-caused: answer 405 rather than raising a
24
- // fault that would be logged at error level and answered with a 500.
25
- return c.json({ error: 'Method not allowed.' }, 405);
23
+ throw new Error('Only POST requests are supported.');
26
24
  }
27
25
  const context = c.get('lowdefyContext');
28
26
  const segments = getPathSegments(c, '/api/request/');
@@ -16,10 +16,7 @@
16
16
 
17
17
  async function sessionMockHandler(c) {
18
18
  const context = c.get('lowdefyContext');
19
- if (!context.user) {
20
- return c.json(null);
21
- }
22
- return c.json({ session: { id: 'e2e' }, user: context.user });
19
+ return c.json(context.session ?? {});
23
20
  }
24
21
 
25
22
  export default sessionMockHandler;
@@ -21,9 +21,7 @@ const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package
21
21
 
22
22
  async function usageHandler(c) {
23
23
  if (c.req.method !== 'POST') {
24
- // A wrong-method request is client-caused: answer 405 rather than raising a
25
- // fault that would be logged at error level and answered with a 500.
26
- return c.json({ error: 'Method not allowed.' }, 405);
24
+ throw new Error('Only POST requests are supported.');
27
25
  }
28
26
  const context = c.get('lowdefyContext');
29
27
  const { user, machine } = await c.req.json();
@@ -38,9 +38,7 @@ const websocketHandler = upgradeWebSocket((c) => {
38
38
  connection?.close();
39
39
  },
40
40
  onError(event) {
41
- // A transport fault, not routine traffic: visible at the default level with
42
- // the error object attached so the cause is not reduced to a string.
43
- context.logger.warn({ event: 'ws_error', err: event?.error }, 'WebSocket error.');
41
+ context.logger.debug({ event: 'ws_error' }, String(event?.error ?? 'WebSocket error'));
44
42
  },
45
43
  };
46
44
  });
@@ -1,354 +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 { Hono } from 'hono';
18
- import { HTTPException } from 'hono/http-exception';
19
- import { jest } from '@jest/globals';
20
-
21
- import createErrorHandler from './errorHandler.js';
22
-
23
- // The handler here is byte-identical to its `server-e2e` counterpart (and to
24
- // `server`'s apart from the Sentry capture, which `server` tests separately).
25
- // Keep the three in sync when changing any of them.
26
-
27
- // A string that must never reach a client. It is put on `received` at every
28
- // level of the cause chain, so a single JSON.stringify search over the body
29
- // proves the redaction reached that level.
30
- const SECRET = 'SECRET-RECEIVED-VALUE';
31
-
32
- function createLogger() {
33
- return { error: jest.fn(), warn: jest.fn() };
34
- }
35
-
36
- // The error handler is only reachable through hono's app-level onError, so the
37
- // handler is driven the way production drives it: a route that throws, with the
38
- // lowdefyContext set by upstream middleware when there is one.
39
- function createApp({ basePath = '', context, error, logger }) {
40
- const app = new Hono();
41
- app.use('*', async (c, next) => {
42
- if (context) c.set('lowdefyContext', context);
43
- await next();
44
- });
45
- app.all('*', () => {
46
- throw error;
47
- });
48
- app.onError(createErrorHandler({ basePath, logger }));
49
- return app;
50
- }
51
-
52
- function createAuthenticationError(message) {
53
- const error = new Error(message);
54
- error.name = 'AuthenticationError';
55
- error.received = SECRET;
56
- return error;
57
- }
58
-
59
- function createAuthorizationError(message) {
60
- const error = new Error(message);
61
- error.name = 'AuthorizationError';
62
- error.received = SECRET;
63
- return error;
64
- }
65
-
66
- function createTwoFactorEnrolmentRequiredError(message) {
67
- const error = new Error(message);
68
- error.name = 'TwoFactorEnrolmentRequiredError';
69
- error.received = SECRET;
70
- return error;
71
- }
72
-
73
- // Two Error levels, each with a stack (every Error has one) and a recognisable
74
- // `received`, plus a configKey the policy keeps.
75
- function createErrorWithCause() {
76
- const cause = new Error('Cause message.');
77
- cause.received = `${SECRET}-cause`;
78
- const error = new Error('Top message.', { cause });
79
- error.received = `${SECRET}-top`;
80
- error.configKey = 'page:home.blocks.0';
81
- return error;
82
- }
83
-
84
- test('errorHandler returns 401 with only name and message for an AuthenticationError on an api path', async () => {
85
- const logger = createLogger();
86
- const context = { handleError: jest.fn() };
87
- const res = await createApp({
88
- context,
89
- error: createAuthenticationError('Unauthenticated.'),
90
- logger,
91
- }).request('/api/request/getUsers');
92
-
93
- expect(res.status).toEqual(401);
94
- expect(await res.json()).toEqual({ name: 'AuthenticationError', message: 'Unauthenticated.' });
95
- expect(logger.warn).toHaveBeenCalledTimes(1);
96
- expect(logger.warn.mock.calls[0][0]).toMatch(/Unauthenticated request/);
97
- expect(logger.error).not.toHaveBeenCalled();
98
- expect(context.handleError).not.toHaveBeenCalled();
99
- });
100
-
101
- test('errorHandler does not send an AuthenticationError through the redactor', async () => {
102
- const res = await createApp({
103
- error: createAuthenticationError('Unauthenticated.'),
104
- logger: createLogger(),
105
- }).request('/api/request/getUsers');
106
-
107
- const body = await res.json();
108
- expect(body['~e']).toBeUndefined();
109
- });
110
-
111
- test('errorHandler returns text Unauthorized at 401 for an AuthenticationError on a page path', async () => {
112
- const logger = createLogger();
113
- const res = await createApp({
114
- error: createAuthenticationError('Unauthenticated.'),
115
- logger,
116
- }).request('/home');
117
-
118
- expect(res.status).toEqual(401);
119
- expect(await res.text()).toEqual('Unauthorized');
120
- expect(logger.warn).toHaveBeenCalledTimes(1);
121
- });
122
-
123
- test('errorHandler returns 403 with only name and message for an AuthorizationError on an api path', async () => {
124
- const logger = createLogger();
125
- const context = { handleError: jest.fn() };
126
- const res = await createApp({
127
- context,
128
- error: createAuthorizationError('Forbidden.'),
129
- logger,
130
- }).request('/api/request/getUsers');
131
-
132
- expect(res.status).toEqual(403);
133
- expect(await res.json()).toEqual({ name: 'AuthorizationError', message: 'Forbidden.' });
134
- expect(logger.warn).toHaveBeenCalledTimes(1);
135
- expect(logger.warn.mock.calls[0][0]).toMatch(/Forbidden: GET \/api\/request\/getUsers/);
136
- expect(logger.error).not.toHaveBeenCalled();
137
- expect(context.handleError).not.toHaveBeenCalled();
138
- });
139
-
140
- test('errorHandler does not send an AuthorizationError through the redactor', async () => {
141
- const res = await createApp({
142
- error: createAuthorizationError('Forbidden.'),
143
- logger: createLogger(),
144
- }).request('/api/request/getUsers');
145
-
146
- const body = await res.json();
147
- expect(body['~e']).toBeUndefined();
148
- expect(JSON.stringify(body)).not.toContain(SECRET);
149
- });
150
-
151
- test('errorHandler returns text Forbidden at 403 for an AuthorizationError on a page path', async () => {
152
- const logger = createLogger();
153
- const res = await createApp({
154
- error: createAuthorizationError('Forbidden.'),
155
- logger,
156
- }).request('/home');
157
-
158
- expect(res.status).toEqual(403);
159
- expect(await res.text()).toEqual('Forbidden');
160
- expect(logger.warn).toHaveBeenCalledTimes(1);
161
- expect(logger.error).not.toHaveBeenCalled();
162
- });
163
-
164
- test('errorHandler returns 403 with only name and message for a TwoFactorEnrolmentRequiredError on an api path', async () => {
165
- const logger = createLogger();
166
- const context = { handleError: jest.fn() };
167
- const res = await createApp({
168
- context,
169
- error: createTwoFactorEnrolmentRequiredError('Two-factor enrolment required.'),
170
- logger,
171
- }).request('/api/request/getUsers');
172
-
173
- expect(res.status).toEqual(403);
174
- expect(await res.json()).toEqual({
175
- name: 'TwoFactorEnrolmentRequiredError',
176
- message: 'Two-factor enrolment required.',
177
- });
178
- expect(logger.warn).toHaveBeenCalledTimes(1);
179
- expect(logger.warn.mock.calls[0][0]).toMatch(/Two-factor enrolment required/);
180
- expect(logger.error).not.toHaveBeenCalled();
181
- expect(context.handleError).not.toHaveBeenCalled();
182
- });
183
-
184
- test('errorHandler does not send a TwoFactorEnrolmentRequiredError through the redactor', async () => {
185
- const res = await createApp({
186
- error: createTwoFactorEnrolmentRequiredError('Two-factor enrolment required.'),
187
- logger: createLogger(),
188
- }).request('/api/request/getUsers');
189
-
190
- const body = await res.json();
191
- expect(body['~e']).toBeUndefined();
192
- });
193
-
194
- test('errorHandler returns text at 403 for a TwoFactorEnrolmentRequiredError on a page path', async () => {
195
- const logger = createLogger();
196
- const context = { handleError: jest.fn() };
197
- const res = await createApp({
198
- context,
199
- error: createTwoFactorEnrolmentRequiredError('Two-factor enrolment required.'),
200
- logger,
201
- }).request('/home');
202
-
203
- expect(res.status).toEqual(403);
204
- expect(await res.text()).toEqual('Two-factor enrolment required');
205
- expect(logger.warn).toHaveBeenCalledTimes(1);
206
- expect(context.handleError).not.toHaveBeenCalled();
207
- });
208
-
209
- test('errorHandler sends an HTTPException response as-is with one warning and no error log', async () => {
210
- const logger = createLogger();
211
- const error = new HTTPException(404, {
212
- res: Response.json(
213
- { jsonrpc: '2.0', error: { code: -32000, message: 'Unsupported protocol version' } },
214
- { status: 404 }
215
- ),
216
- });
217
- const res = await createApp({ error, logger }).request('/api/mcp', { method: 'POST' });
218
- expect(res.status).toEqual(404);
219
- expect(await res.json()).toEqual({
220
- jsonrpc: '2.0',
221
- error: { code: -32000, message: 'Unsupported protocol version' },
222
- });
223
- expect(logger.warn).toHaveBeenCalledWith('404 answered by the route: POST /api/mcp');
224
- expect(logger.error).not.toHaveBeenCalled();
225
- });
226
-
227
- test('errorHandler returns 500 with the serialized error envelope on an api path', async () => {
228
- const res = await createApp({
229
- error: createErrorWithCause(),
230
- logger: createLogger(),
231
- }).request('/api/request/getUsers');
232
-
233
- expect(res.status).toEqual(500);
234
- const body = await res.json();
235
- expect(body['~e'].name).toEqual('Error');
236
- expect(body['~e'].message).toEqual('Top message.');
237
- expect(body['~e'].cause.message).toEqual('Cause message.');
238
- });
239
-
240
- test('errorHandler omits stack and received from the 500 envelope at the top level', async () => {
241
- const res = await createApp({
242
- error: createErrorWithCause(),
243
- logger: createLogger(),
244
- }).request('/api/request/getUsers');
245
-
246
- const body = await res.json();
247
- expect(body['~e'].stack).toBeUndefined();
248
- expect(body['~e'].received).toBeUndefined();
249
- });
250
-
251
- // The regression test for the real bug: the four inline `delete` statements this
252
- // replaces only ever reached depth 0, so a `received` or `stack` on the cause
253
- // still crossed the wire. The policy is now applied at every error node.
254
- test('errorHandler omits stack and received from the 500 envelope on the cause at depth 1', async () => {
255
- const res = await createApp({
256
- error: createErrorWithCause(),
257
- logger: createLogger(),
258
- }).request('/api/request/getUsers');
259
-
260
- const body = await res.json();
261
- expect(body['~e'].cause.stack).toBeUndefined();
262
- expect(body['~e'].cause.received).toBeUndefined();
263
- expect(JSON.stringify(body)).not.toContain(SECRET);
264
- });
265
-
266
- test('errorHandler keeps configKey on the 500 envelope so the fault can be located in config', async () => {
267
- const res = await createApp({
268
- error: createErrorWithCause(),
269
- logger: createLogger(),
270
- }).request('/api/request/getUsers');
271
-
272
- const body = await res.json();
273
- expect(body['~e'].configKey).toEqual('page:home.blocks.0');
274
- });
275
-
276
- test('errorHandler returns text Internal Server Error at 500 on a page path with no payload', async () => {
277
- const res = await createApp({
278
- error: createErrorWithCause(),
279
- logger: createLogger(),
280
- }).request('/home');
281
-
282
- expect(res.status).toEqual(500);
283
- expect(await res.text()).toEqual('Internal Server Error');
284
- });
285
-
286
- test('errorHandler awaits context.handleError before serializing the response', async () => {
287
- const context = {
288
- handleError: jest.fn(async (error) => {
289
- await new Promise((resolve) => setTimeout(resolve, 10));
290
- error.awaited = true;
291
- }),
292
- };
293
- const error = createErrorWithCause();
294
- const res = await createApp({ context, error, logger: createLogger() }).request(
295
- '/api/request/getUsers'
296
- );
297
-
298
- expect(context.handleError).toHaveBeenCalledTimes(1);
299
- expect(context.handleError).toHaveBeenCalledWith(error);
300
- const body = await res.json();
301
- expect(body['~e'].awaited).toBe(true);
302
- });
303
-
304
- // createHandleError sets error.handled once it has logged. The client reads it
305
- // to decide whether to POST the error back for logging, so it has to survive
306
- // serialization or the error is logged a second time.
307
- test('errorHandler returns handled true on the 500 envelope after handleError sets it', async () => {
308
- const context = {
309
- handleError: jest.fn(async (error) => {
310
- error.handled = true;
311
- }),
312
- };
313
- const res = await createApp({
314
- context,
315
- error: createErrorWithCause(),
316
- logger: createLogger(),
317
- }).request('/api/request/getUsers');
318
-
319
- const body = await res.json();
320
- expect(body['~e'].handled).toBe(true);
321
- });
322
-
323
- test('errorHandler logs with logger.error when the request has no lowdefyContext', async () => {
324
- const logger = createLogger();
325
- const error = createErrorWithCause();
326
- const res = await createApp({ error, logger }).request('/api/request/getUsers');
327
-
328
- expect(res.status).toEqual(500);
329
- expect(logger.error).toHaveBeenCalledTimes(1);
330
- expect(logger.error).toHaveBeenCalledWith(error);
331
- });
332
-
333
- test('errorHandler treats a basePath prefixed api path as an api path', async () => {
334
- const res = await createApp({
335
- basePath: '/base',
336
- error: createErrorWithCause(),
337
- logger: createLogger(),
338
- }).request('/base/api/request/getUsers');
339
-
340
- expect(res.status).toEqual(500);
341
- const body = await res.json();
342
- expect(body['~e'].message).toEqual('Top message.');
343
- });
344
-
345
- test('errorHandler treats a basePath prefixed page path as a page path', async () => {
346
- const res = await createApp({
347
- basePath: '/base',
348
- error: createErrorWithCause(),
349
- logger: createLogger(),
350
- }).request('/base/home');
351
-
352
- expect(res.status).toEqual(500);
353
- expect(await res.text()).toEqual('Internal Server Error');
354
- });
@@ -1,27 +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
- // 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;