@lowdefy/server-dev 0.0.0-experimental-20260807075508 → 0.0.0-experimental-20260814133003

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 (69) hide show
  1. package/client/Page.jsx +4 -13
  2. package/client/Reload.jsx +0 -8
  3. package/client/Routing.jsx +23 -31
  4. package/lib/client/auth/Auth.jsx +5 -3
  5. package/lib/client/auth/AuthConfigured.jsx +36 -170
  6. package/lib/client/auth/AuthNotConfigured.js +2 -22
  7. package/lib/client/utils/usePageConfig.js +7 -38
  8. package/lib/docs/checkpointStore.js +2 -8
  9. package/lib/docs/createDocsMcpServer.js +9 -34
  10. package/lib/docs/createDocsMcpServer.test.mjs +0 -18
  11. package/lib/docs/evalOperator.js +10 -18
  12. package/lib/docs/evalOperator.test.mjs +2 -47
  13. package/lib/docs/evalOperatorHeadless.js +2 -2
  14. package/lib/docs/getBrowser.js +3 -15
  15. package/lib/docs/inspectState.js +9 -15
  16. package/lib/docs/inspectState.test.mjs +4 -53
  17. package/lib/docs/inspectStateHeadless.js +2 -2
  18. package/lib/docs/loadState.js +2 -15
  19. package/lib/docs/screenshotPage.js +1 -2
  20. package/lib/docs/stateCheckpoints.test.mjs +0 -18
  21. package/lib/server/auth/{getStrategies.js → getAuthConfig.js} +10 -12
  22. package/lib/server/auth/getDevSession.js +70 -0
  23. package/lib/server/auth/getDevSession.test.mjs +138 -0
  24. package/lib/server/auth/getHeadlessUser.js +8 -7
  25. package/lib/server/auth/getMockUser.js +7 -11
  26. package/lib/server/auth/headlessUser.js +2 -3
  27. package/lib/server/auth/session.js +39 -0
  28. package/lib/server/createLowdefyContext.js +41 -70
  29. package/lib/server/log/createHandleError.js +0 -9
  30. package/lib/server/log/logRequest.js +3 -1
  31. package/manager/processes/startServer.mjs +2 -0
  32. package/manager/processes/warnAuthUrlPortMismatch.mjs +47 -0
  33. package/manager/processes/warnAuthUrlPortMismatch.test.mjs +93 -0
  34. package/manager/utils/createCustomPluginTypesMap.mjs +2 -2
  35. package/manager/watchers/serverArtifactWatcher.mjs +2 -0
  36. package/package.json +34 -36
  37. package/package.original.json +34 -36
  38. package/src/app.js +14 -27
  39. package/src/html/renderDevPage.js +1 -1
  40. package/src/middleware/errorHandler.js +9 -26
  41. package/src/routes/apiPage.js +37 -0
  42. package/src/routes/auth.js +21 -15
  43. package/src/routes/auth.test.mjs +84 -0
  44. package/src/routes/detached.js +6 -10
  45. package/src/routes/docs/evalOperator.js +2 -12
  46. package/src/routes/docs/inspectState.js +2 -12
  47. package/src/routes/docs/loadState.js +3 -14
  48. package/src/routes/docs/runRequest.js +4 -2
  49. package/src/routes/docs/screenshot.js +1 -7
  50. package/src/routes/endpoints.js +9 -14
  51. package/src/routes/jitPage.js +2 -42
  52. package/src/routes/request.js +8 -2
  53. package/lib/client/RedirectingPage.jsx +0 -56
  54. package/lib/client/auth/authTemplateSync.test.mjs +0 -40
  55. package/lib/client/auth/betterAuthSessionContract.test.mjs +0 -175
  56. package/lib/docs/getBrowser.test.mjs +0 -120
  57. package/lib/docs/resolveSource.js +0 -48
  58. package/lib/docs/resolveSource.test.mjs +0 -100
  59. package/lib/server/auth/createSystemContext.js +0 -70
  60. package/lib/server/auth/getAuth.js +0 -46
  61. package/lib/server/auth/resolveHeadlessUser.js +0 -38
  62. package/lib/server/auth/resolveHeadlessUser.test.mjs +0 -74
  63. package/lib/server/loadDynamicJsMap.js +0 -47
  64. package/lib/server/log/createHandleError.test.mjs +0 -126
  65. package/src/middleware/errorHandler.test.mjs +0 -287
  66. package/src/routes/docs/parseUserParam.js +0 -52
  67. package/src/routes/docs/parseUserParam.test.mjs +0 -45
  68. package/src/routes/jitPage.test.mjs +0 -89
  69. package/src/routes/user.js +0 -27
package/client/Page.jsx CHANGED
@@ -20,7 +20,7 @@ import Client from '@lowdefy/client';
20
20
 
21
21
  import BuildErrorPage from '../lib/client/BuildErrorPage.jsx';
22
22
  import InstallingPluginsPage from '../lib/client/InstallingPluginsPage.jsx';
23
- import RedirectingPage from '../lib/client/RedirectingPage.jsx';
23
+ import RestartingPage from '../lib/client/RestartingPage.jsx';
24
24
  import usePageConfig from '../lib/client/utils/usePageConfig.js';
25
25
 
26
26
  const Page = ({
@@ -47,22 +47,10 @@ const Page = ({
47
47
  }
48
48
  }, [pageConfig?._warnings, lowdefy]);
49
49
 
50
- // Full load to the sign-in page so it can return here after sign-in — an
51
- // effect, not a fetcher side effect, so the redirect re-fires if the same
52
- // cached result renders again.
53
- useEffect(() => {
54
- if (pageConfig?.authRedirect) {
55
- window.location.assign(pageConfig.authRedirect);
56
- }
57
- }, [pageConfig?.authRedirect]);
58
-
59
50
  if (!pageConfig) {
60
51
  router.replace({ pathname: '/404' });
61
52
  return '';
62
53
  }
63
- if (pageConfig.authRedirect) {
64
- return <RedirectingPage redirect={pageConfig.authRedirect} />;
65
- }
66
54
  if (pageConfig.buildError) {
67
55
  return (
68
56
  <BuildErrorPage
@@ -75,6 +63,9 @@ const Page = ({
75
63
  if (pageConfig.installing) {
76
64
  return <InstallingPluginsPage packages={pageConfig.packages} />;
77
65
  }
66
+ if (resetContext.restarting) {
67
+ return <RestartingPage />;
68
+ }
78
69
 
79
70
  // Merge dynamic JS entries fetched after JIT build with the static jsMap
80
71
  const mergedJsMap = pageConfig._jsEntries ? { ...jsMap, ...pageConfig._jsEntries } : jsMap;
package/client/Reload.jsx CHANGED
@@ -25,13 +25,6 @@ import waitForRestartedServer from '../lib/client/utils/waitForRestartedServer.j
25
25
  const Reload = ({ children, basePath, lowdefy }) => {
26
26
  const [reset, setReset] = useState(false);
27
27
  const [restarting, setRestarting] = useState(false);
28
- // reset is a one-shot boolean the engine lowers only after a page mounts
29
- // (getContext.js) — while a page is suspended it stays true and setReset(true)
30
- // bails out of re-rendering. The tick always changes, so every reload event
31
- // re-invokes Routing's render prop, which re-reads reloadVersion and mints a
32
- // fresh Suspense/SWR key — a suspended tab recovers instead of stranding on
33
- // the building screen.
34
- const [, setReloadTick] = useState(0);
35
28
  const mutateCache = useMutateCache(basePath);
36
29
  useEffect(() => {
37
30
  const sse = new EventSource(`${basePath}/api/reload`);
@@ -45,7 +38,6 @@ const Reload = ({ children, basePath, lowdefy }) => {
45
38
  lowdefy._internal.initialised = false;
46
39
  }
47
40
  setReset(true);
48
- setReloadTick((tick) => tick + 1);
49
41
  console.log('Reloaded config.');
50
42
  }, 600);
51
43
  });
@@ -20,7 +20,6 @@ import Head from '@lowdefy/client/adapters/Head.js';
20
20
  import createLinkComponent from '@lowdefy/client/adapters/Link.js';
21
21
 
22
22
  import BuildingPage from '../lib/client/BuildingPage.jsx';
23
- import RestartingPage from '../lib/client/RestartingPage.jsx';
24
23
  import FeedbackMount from './feedback/FeedbackMount.jsx';
25
24
  import Inspector from './Inspector.jsx';
26
25
  import OpenInEditorListener from './openInEditor/OpenInEditorListener.jsx';
@@ -76,36 +75,29 @@ function Routing({ auth, lowdefy, router }) {
76
75
  <FeedbackMount basePath={router.basePath} lowdefy={lowdefy} pageId={pageId} />
77
76
  <OpenInEditorListener basePath={router.basePath} pageId={pageId} />
78
77
  <Reload basePath={router.basePath} lowdefy={lowdefy}>
79
- {(resetContext) =>
80
- // Rendered here, not in Page — Page sits below the Suspense boundary
81
- // and cannot render anything while its config fetch is suspended, so
82
- // a restarting server would present as "Building page..." forever.
83
- resetContext.restarting ? (
84
- <RestartingPage />
85
- ) : (
86
- <Suspense key={`${pageId}_${getReloadVersion()}`} fallback={<BuildingPage />}>
87
- <Page
88
- auth={auth}
89
- Components={{ Head, Link }}
90
- config={{
91
- rootConfig,
92
- }}
93
- jsMap={staticJsMap}
94
- lowdefy={lowdefy}
95
- pageId={pageId}
96
- resetContext={resetContext}
97
- router={router}
98
- types={{
99
- actions,
100
- blockMetas,
101
- blocks,
102
- icons,
103
- operators,
104
- }}
105
- />
106
- </Suspense>
107
- )
108
- }
78
+ {(resetContext) => (
79
+ <Suspense key={`${pageId}_${getReloadVersion()}`} fallback={<BuildingPage />}>
80
+ <Page
81
+ auth={auth}
82
+ Components={{ Head, Link }}
83
+ config={{
84
+ rootConfig,
85
+ }}
86
+ jsMap={staticJsMap}
87
+ lowdefy={lowdefy}
88
+ pageId={pageId}
89
+ resetContext={resetContext}
90
+ router={router}
91
+ types={{
92
+ actions,
93
+ blockMetas,
94
+ blocks,
95
+ icons,
96
+ operators,
97
+ }}
98
+ />
99
+ </Suspense>
100
+ )}
109
101
  </Reload>
110
102
  </>
111
103
  );
@@ -14,6 +14,8 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
+ /* eslint-disable react/jsx-props-no-spreading */
18
+
17
19
  import React from 'react';
18
20
  import AuthConfigured from './AuthConfigured.jsx';
19
21
  import AuthNotConfigured from './AuthNotConfigured.js';
@@ -23,15 +25,15 @@ import { serializer } from '@lowdefy/helpers';
23
25
  // Client code imports the build JSON directly — Vite handles JSON imports;
24
26
  // the lib/build/*.js wrappers are server-only (they read from disk).
25
27
  // Deserialize to restore arrays from their ~arr build markers (providers
26
- // must be a real array for provider lookups in createAuthMethods).
28
+ // must be a real array for single-provider inference in createAuthMethods).
27
29
  import rawAuthConfig from '../../../build/auth.json';
28
30
 
29
31
  const authConfig = serializer.deserialize(rawAuthConfig);
30
32
 
31
- function Auth({ children, user }) {
33
+ function Auth({ children, session }) {
32
34
  if (authConfig.configured === true) {
33
35
  return (
34
- <AuthConfigured serverUser={user} authConfig={authConfig}>
36
+ <AuthConfigured serverSession={session} authConfig={authConfig}>
35
37
  {(auth) => children(auth)}
36
38
  </AuthConfigured>
37
39
  );
@@ -14,194 +14,60 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
+ /* eslint-disable react/jsx-props-no-spreading */
18
+
17
19
  import React, { useEffect, useRef } from 'react';
18
- import { createAuthClient } from 'better-auth/react';
19
20
  import {
20
- adminClient,
21
- genericOAuthClient,
22
- magicLinkClient,
23
- organizationClient,
24
- phoneNumberClient,
25
- twoFactorClient,
26
- } from 'better-auth/client/plugins';
27
- import { passkeyClient } from '@better-auth/passkey/client';
21
+ authConfigManager,
22
+ getSession,
23
+ SessionProvider,
24
+ signIn,
25
+ signOut,
26
+ useSession,
27
+ } from '@hono/auth-js/react';
28
28
 
29
- import { normalizeCaller, serializer } from '@lowdefy/helpers';
29
+ import { serializer } from '@lowdefy/helpers';
30
30
 
31
31
  import rawLowdefyConfig from '../../../build/config.json';
32
32
 
33
33
  const lowdefyConfig = serializer.deserialize(rawLowdefyConfig);
34
34
 
35
- const authClient = createAuthClient({
36
- baseURL: `${window.location.origin}${lowdefyConfig.basePath ?? ''}/api/auth`,
37
- plugins: [
38
- adminClient(),
39
- genericOAuthClient(),
40
- magicLinkClient(),
41
- organizationClient(),
42
- passkeyClient(),
43
- phoneNumberClient(),
44
- twoFactorClient(),
45
- ],
46
- });
47
-
48
- // UpdateSession owns session freshness - suppress BetterAuth's own
49
- // signal-driven refetch on the calls that would otherwise fire one, so
50
- // nothing competes with (and aborts) the refetch UpdateSession awaits.
51
- // Only calls that mutate an existing session are suppressed - calls that
52
- // can establish one (the sign-ins, and the two-factor, phone-number and
53
- // passkey verifies) keep their signal: its refetch is what makes the store
54
- // notice a login when no navigation follows.
55
- // disableSignal silences all atomListeners for the call, including the
56
- // organization plugin's org atoms - none are exposed to app config, so a
57
- // future design exposing one must refresh it through its own action.
58
- const sessionScoped = (params) => ({
59
- ...params,
60
- fetchOptions: { ...params?.fetchOptions, disableSignal: true },
61
- });
35
+ // @hono/auth-js/react configures its fetch paths through a module-level
36
+ // manager instead of SessionProvider props.
37
+ if (lowdefyConfig.basePath) {
38
+ authConfigManager.setConfig({ basePath: `${lowdefyConfig.basePath}/api/auth` });
39
+ }
62
40
 
63
- // The server resolves the caller per request and embeds it in the page
64
- // config, so the first render never flashes unauthenticated. The BetterAuth
65
- // client store takes over once its session fetch settles.
66
- //
67
- // Roles and merged attributes resolve server-side from the active member row
68
- // - the base session carries neither. The last server-resolved caller is
69
- // kept in a ref: while the session user is unchanged it stays authoritative
70
- // for the fields the session does not carry, and UpdateSession refreshes the
71
- // ref from /api/user after a change (e.g. SetActiveOrganization).
72
- //
73
- // Only the session half is passed through normalizeCaller. BetterAuth's store
74
- // returns camelCase keys whatever the columns are named, so untransformed it
75
- // would sit emailVerified beside the resolved caller's email_verified on one
76
- // object; anything arriving from the server is already snake_case.
77
- function Session({ children, reloadSuppressedRef, serverUser }) {
78
- const { data: session, isPending } = authClient.useSession();
79
- const resolvedUserRef = useRef(serverUser);
80
- const wasAuthenticated = useRef(Boolean(serverUser));
41
+ function Session({ children }) {
42
+ const wasAuthenticated = useRef(false);
43
+ const { data: session, status } = useSession();
44
+ wasAuthenticated.current = wasAuthenticated.current || status === 'authenticated';
81
45
 
82
46
  useEffect(() => {
83
- if (session) {
84
- wasAuthenticated.current = true;
85
- }
86
- // Reload after sign-out (or session revocation) so the server can apply
87
- // the page auth fork - a protected page redirects to the login page.
88
- // A logout with a callbackUrl suppresses the reload - its own navigation
89
- // would otherwise race this one.
90
- if (wasAuthenticated.current && !isPending && !session && !reloadSuppressedRef.current) {
47
+ if (wasAuthenticated.current && status === 'unauthenticated') {
91
48
  window.location.reload();
92
49
  }
93
- }, [session, isPending]);
50
+ }, [status]);
94
51
 
95
- if (isPending) {
96
- return children(resolvedUserRef.current, resolvedUserRef);
97
- }
98
- if (!session?.user) {
99
- return children(null, resolvedUserRef);
100
- }
101
- const resolved = resolvedUserRef.current;
102
- if (!resolved) {
103
- return children({ roles: [], ...normalizeCaller(session.user) }, resolvedUserRef);
104
- }
105
- if (resolved.id !== session.user.id) {
106
- // The ref has not caught up with a changed session user - a sign-in as a
107
- // different person lands between the session refetch and the /api/user
108
- // result. Render the last resolved caller whole rather than mixing the new
109
- // session user with the previous person's resolved fields, or falling back
110
- // to a caller with no roles - blocks re-evaluate operators on render, so a
111
- // role-guarded page would act on an empty role list. UpdateSession closes
112
- // the window when it lands the new caller; a flow that changes the session
113
- // user without chaining it leaves the previous caller rendered until the
114
- // next page load.
115
- return children(resolved, resolvedUserRef);
52
+ // If session is passed to SessionProvider from the server-rendered config
53
+ // we won't have a loading state here, but unauthenticated first loads do.
54
+ if (status === 'loading') {
55
+ return '';
116
56
  }
117
- // _user is one object with one meaning on both sides: the resolved caller is
118
- // spread whole, so every field resolveAuthentication emits is readable in app
119
- // config without this file naming it. session.user is the floor - the client
120
- // store may hold a fresher one than the ref, and the resolved object wins
121
- // wherever both carry a key.
122
- const user = { ...normalizeCaller(session.user), ...resolved };
123
- return children(user, resolvedUserRef);
57
+ return children(session);
124
58
  }
125
59
 
126
- function AuthConfigured({ authConfig, children, serverUser }) {
127
- const reloadSuppressedRef = useRef(false);
128
- const auth = {
129
- authConfig,
130
- getSession: ({ disableCookieCache } = {}) =>
131
- authClient.getSession(disableCookieCache ? { query: { disableCookieCache: true } } : {}),
132
- // Refetches the session through the session atom - the store's only
133
- // writer - and resolves only after the store is written. fetchSession
134
- // returns undefined and never rejects (failures are written into the
135
- // atom), so the atom's own { data, error } is read back and returned,
136
- // keeping unwrap working and surfacing a failed refresh to the action.
137
- refreshSession: async ({ disableCookieCache } = {}) => {
138
- const sessionAtom = authClient.$store.atoms.session;
139
- await sessionAtom
140
- .get()
141
- .refetch(disableCookieCache ? { query: { disableCookieCache: true } } : undefined);
142
- const { data, error } = sessionAtom.get();
143
- return { data, error };
144
- },
145
- // The server-resolved caller - roles from the active member row and the
146
- // merged attributes bag - for re-syncing after session changes.
147
- getResolvedUser: async () => {
148
- const response = await fetch(`${lowdefyConfig.basePath ?? ''}/api/user`, {
149
- credentials: 'same-origin',
150
- });
151
- if (!response.ok) {
152
- // /api/user itself always answers 200 { user } - user: null when no
153
- // caller is admitted, which updateSession branches on. A non-OK
154
- // response is a transport failure (gateway error page, 5xx) with no
155
- // parsable body.
156
- throw new Error(`Failed to fetch the resolved user (HTTP ${response.status}).`);
157
- }
158
- return response.json();
159
- },
160
- suppressSignOutReload: () => {
161
- reloadSuppressedRef.current = true;
162
- },
163
- acceptInvitation: (params) => authClient.organization.acceptInvitation(sessionScoped(params)),
164
- // addPasskey runs the WebAuthn browser ceremony itself - options fetch,
165
- // authenticator prompt, verification.
166
- addPasskey: (params) => authClient.passkey.addPasskey(params),
167
- changePassword: (params) => authClient.changePassword(sessionScoped(params)),
168
- deletePasskey: (params) => authClient.passkey.deletePasskey(params),
169
- leaveOrganization: (params) => authClient.organization.leave(sessionScoped(params)),
170
- phoneNumberRequestPasswordReset: (params) =>
171
- authClient.phoneNumber.requestPasswordReset(params),
172
- phoneNumberResetPassword: (params) => authClient.phoneNumber.resetPassword(params),
173
- phoneNumberSendOtp: (params) => authClient.phoneNumber.sendOtp(params),
174
- phoneNumberVerify: (params) => authClient.phoneNumber.verify(params),
175
- requestPasswordReset: (params) => authClient.requestPasswordReset(params),
176
- resetPassword: (params) => authClient.resetPassword(params),
177
- revokeOtherSessions: () => authClient.revokeOtherSessions(sessionScoped()),
178
- sendVerificationEmail: (params) => authClient.sendVerificationEmail(params),
179
- setActiveOrganization: (params) => authClient.organization.setActive(sessionScoped(params)),
180
- signInEmail: (params) => authClient.signIn.email(params),
181
- signInMagicLink: (params) => authClient.signIn.magicLink(params),
182
- signInOauth2: (params) => authClient.signIn.oauth2(params),
183
- signInPasskey: (params) => authClient.signIn.passkey(params),
184
- signInPhoneNumber: (params) => authClient.signIn.phoneNumber(params),
185
- signInSocial: (params) => authClient.signIn.social(params),
186
- signOut: () => authClient.signOut(),
187
- signUpEmail: (params) => authClient.signUp.email(params),
188
- twoFactorDisable: (params) => authClient.twoFactor.disable(sessionScoped(params)),
189
- twoFactorEnable: (params) => authClient.twoFactor.enable(sessionScoped(params)),
190
- twoFactorGenerateBackupCodes: (params) =>
191
- authClient.twoFactor.generateBackupCodes(sessionScoped(params)),
192
- twoFactorVerifyBackupCode: (params) => authClient.twoFactor.verifyBackupCode(params),
193
- twoFactorVerifyTotp: (params) => authClient.twoFactor.verifyTotp(params),
194
- };
60
+ function AuthConfigured({ authConfig, children, serverSession }) {
61
+ const auth = { authConfig, getSession, signIn, signOut };
195
62
  return (
196
- <Session reloadSuppressedRef={reloadSuppressedRef} serverUser={serverUser}>
197
- {(user, resolvedUserRef) => {
198
- auth.user = user;
199
- auth.updateResolvedUser = (resolved) => {
200
- resolvedUserRef.current = resolved;
201
- };
202
- return children(auth);
203
- }}
204
- </Session>
63
+ <SessionProvider session={serverSession}>
64
+ <Session>
65
+ {(session) => {
66
+ auth.session = session;
67
+ return children(auth);
68
+ }}
69
+ </Session>
70
+ </SessionProvider>
205
71
  );
206
72
  }
207
73
 
@@ -13,6 +13,7 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */
16
+ /* eslint-disable react/jsx-props-no-spreading */
16
17
 
17
18
  function authNotConfigured() {
18
19
  throw new Error('Auth not configured.');
@@ -21,30 +22,9 @@ function authNotConfigured() {
21
22
  function AuthNotConfigured({ authConfig, children }) {
22
23
  const auth = {
23
24
  authConfig,
24
- user: null,
25
- acceptInvitation: authNotConfigured,
26
- addPasskey: authNotConfigured,
27
- changePassword: authNotConfigured,
28
- deletePasskey: authNotConfigured,
29
- getResolvedUser: authNotConfigured,
30
25
  getSession: authNotConfigured,
31
- leaveOrganization: authNotConfigured,
32
- requestPasswordReset: authNotConfigured,
33
- resetPassword: authNotConfigured,
34
- revokeOtherSessions: authNotConfigured,
35
- sendVerificationEmail: authNotConfigured,
36
- setActiveOrganization: authNotConfigured,
37
- signInEmail: authNotConfigured,
38
- signInMagicLink: authNotConfigured,
39
- signInOauth2: authNotConfigured,
40
- signInSocial: authNotConfigured,
26
+ signIn: authNotConfigured,
41
27
  signOut: authNotConfigured,
42
- signUpEmail: authNotConfigured,
43
- twoFactorDisable: authNotConfigured,
44
- twoFactorEnable: authNotConfigured,
45
- twoFactorGenerateBackupCodes: authNotConfigured,
46
- twoFactorVerifyBackupCode: authNotConfigured,
47
- twoFactorVerifyTotp: authNotConfigured,
48
28
  };
49
29
 
50
30
  return children(auth);
@@ -29,9 +29,7 @@ function parseJsModule(text) {
29
29
 
30
30
  async function fetchJsEntries(basePath) {
31
31
  try {
32
- const res = await fetch(`${basePath}/api/js/client`, {
33
- signal: AbortSignal.timeout(10_000),
34
- });
32
+ const res = await fetch(`${basePath}/api/js/client`);
35
33
  if (!res.ok) return {};
36
34
  return parseJsModule(await res.text());
37
35
  } catch {
@@ -41,9 +39,7 @@ async function fetchJsEntries(basePath) {
41
39
 
42
40
  async function fetchDynamicIcons(basePath) {
43
41
  try {
44
- const res = await fetch(`${basePath}/api/icons/dynamic`, {
45
- signal: AbortSignal.timeout(10_000),
46
- });
42
+ const res = await fetch(`${basePath}/api/icons/dynamic`);
47
43
  if (!res.ok) return {};
48
44
  return parseJsModule(await res.text());
49
45
  } catch {
@@ -52,41 +48,12 @@ async function fetchDynamicIcons(basePath) {
52
48
  }
53
49
 
54
50
  async function fetchPageConfig(url) {
55
- const basePath = url.replace(/\/api\/page\/.*$/, '');
56
- // A stalled request (server restart mid-request, exhausted sockets) must
57
- // become a visible error, never an eternal Suspense fallback — the reload
58
- // recovery path cannot fire while the page tree is suspended.
59
- let res;
60
- try {
61
- res = await fetch(url, {
62
- headers: { 'Content-Type': 'application/json' },
63
- signal: AbortSignal.timeout(30_000),
64
- });
65
- } catch (error) {
66
- if (error.name === 'TimeoutError' || error.name === 'AbortError') {
67
- throw new Error(
68
- `Page config request "${url}" timed out - the dev server may be restarting. Reload the page.`
69
- );
70
- }
71
- throw error;
72
- }
51
+ const res = await fetch(url, {
52
+ headers: { 'Content-Type': 'application/json' },
53
+ });
73
54
  if (res.status === 404) {
74
55
  return null;
75
56
  }
76
- if (res.status === 401 || res.status === 403) {
77
- // 401: logged-out navigation to a protected page. 403: authorised but second
78
- // factor not yet enrolled. Page renders a redirect screen and full-loads to
79
- // the destination so it can return here afterwards. Returning a settled value
80
- // (never a parked promise) keeps the SWR key healthy: if the navigation is
81
- // dropped, the tab still recovers on the next reload event or via the manual
82
- // link on the redirect screen.
83
- const { redirect } = await res.json();
84
- const authRedirect = redirect ?? `${basePath}/404`;
85
- console.warn(
86
- `Lowdefy dev: "${url}" returned ${res.status} - redirecting to "${authRedirect}".`
87
- );
88
- return { authRedirect };
89
- }
90
57
  const data = await res.json();
91
58
  if (data?.buildError) {
92
59
  return data;
@@ -100,6 +67,8 @@ async function fetchPageConfig(url) {
100
67
 
101
68
  // Fetch jsMap and dynamic icons after page build completes
102
69
  // (JIT build may have added new entries).
70
+ // Extract basePath from the URL to construct the endpoints.
71
+ const basePath = url.replace(/\/api\/page\/.*$/, '');
103
72
  const [jsEntries, dynamicIcons] = await Promise.all([
104
73
  fetchJsEntries(basePath),
105
74
  fetchDynamicIcons(basePath),
@@ -16,7 +16,7 @@
16
16
 
17
17
  import fs from 'fs';
18
18
  import path from 'path';
19
- import { isReserved, serializer, setKey, type } from '@lowdefy/helpers';
19
+ import { serializer, type } from '@lowdefy/helpers';
20
20
 
21
21
  import { getConfigDirectory } from './checkpointPaths.js';
22
22
 
@@ -90,13 +90,7 @@ function readEntries({ dir, subdir }) {
90
90
  .filter((fileName) => fileName.endsWith('.json'))
91
91
  .forEach((fileName) => {
92
92
  const id = fileName.slice(0, -'.json'.length);
93
- // A checkpoint directory is on-disk state a developer can edit by hand.
94
- // A reserved file name can't round-trip through the entries map, so skip
95
- // it rather than fail the whole checkpoint load.
96
- if (isReserved(id)) {
97
- return;
98
- }
99
- setKey(entries, id, readJsonFile(path.join(entriesDir, fileName), null));
93
+ entries[id] = readJsonFile(path.join(entriesDir, fileName), null);
100
94
  });
101
95
  return entries;
102
96
  }
@@ -53,8 +53,6 @@ Feedback loop: after EVERY config edit, call lowdefy_build_status — the dev se
53
53
 
54
54
  Live state: lowdefy_inspect_state reads the ACTUAL state, request results, and event log of a running page — when the developer has the page open in their browser it reads THEIR live tab (ask them to interact, then inspect), otherwise it runs the page headless. lowdefy_eval_operator evaluates any operator expression against that live state — use it to debug _state/_request bindings. lowdefy_run_request executes a request with a test payload to verify data shape (read-only unless the app opts into writes).
55
55
 
56
- Role-gated pages: the headless renderer signs in as a roleless user, so a page or request gated on a role renders empty or refused. Pass user to lowdefy_screenshot_page, lowdefy_inspect_state, lowdefy_eval_operator or lowdefy_load_state to act as a specific caller — e.g. user {"roles":["admin"]} — and vary it per call to compare what different roles see.
57
-
58
56
  Safety: lowdefy_checkpoint snapshots the config files before risky multi-file changes; lowdefy_revert_checkpoint restores them.
59
57
 
60
58
  Visual feedback: developers can press Cmd/Ctrl+/ in the running app to point at elements, draw, and copy annotated feedback to their clipboard, then paste it to you. Pasted annotation blocks start with "Feedback:" and carry the blockId, the resolved config file:line, drawn shapes, and usually an "Annotated screenshot:" file path — READ that image to see exactly what the developer drew. Treat them as precise UI feedback and use lowdefy_inspect_state for the page's live state.
@@ -70,17 +68,6 @@ function notFoundResult(message) {
70
68
  return { content: [{ type: 'text', text: message }], isError: true };
71
69
  }
72
70
 
73
- // Shared by every tool that renders a page headless, so one call can act as an
74
- // admin and the next as a plain member — each headless call gets its own browser
75
- // context, so they never share an identity.
76
- const userSchema = z
77
- .object({})
78
- .passthrough()
79
- .optional()
80
- .describe(
81
- 'Act as this caller instead of the default roleless headless user, e.g. {"roles":["user-admin"]} to render a role-gated page. Merged over the default, so include email/profile/attributes fields too if the page reads them — no auth engine runs for an injected caller, so nothing derives them. Headless only: it is never applied to a page the developer opens in their own browser, so combining it with source "tab" or load_state mode "registry-only" is an error rather than a silently dropped role.'
82
- );
83
-
84
71
  function createDocsMcpServer({ origin, honoContext } = {}) {
85
72
  const server = new McpServer(
86
73
  { name: 'lowdefy-docs', version: '1.0.0' },
@@ -112,11 +99,10 @@ function createDocsMcpServer({ origin, honoContext } = {}) {
112
99
  .enum(['tab', 'headless'])
113
100
  .optional()
114
101
  .describe('Force a source. Default: live tab if connected, else headless.'),
115
- user: userSchema,
116
102
  },
117
103
  },
118
- async ({ pageId, source, user }) => {
119
- const result = await inspectState({ origin, pageId, source, user });
104
+ async ({ pageId, source }) => {
105
+ const result = await inspectState({ origin, pageId, source });
120
106
  if (result.error) {
121
107
  return notFoundResult(result.error);
122
108
  }
@@ -135,11 +121,10 @@ function createDocsMcpServer({ origin, honoContext } = {}) {
135
121
  .any()
136
122
  .describe('The operator expression — any JSON value, e.g. {"_state": "key"}.'),
137
123
  source: z.enum(['tab', 'headless']).optional(),
138
- user: userSchema,
139
124
  },
140
125
  },
141
- async ({ pageId, expression, source, user }) => {
142
- const result = await evalOperator({ origin, pageId, expression, source, user });
126
+ async ({ pageId, expression, source }) => {
127
+ const result = await evalOperator({ origin, pageId, expression, source });
143
128
  if (result.error) {
144
129
  return notFoundResult(result.error);
145
130
  }
@@ -202,11 +187,10 @@ function createDocsMcpServer({ origin, honoContext } = {}) {
202
187
  inputSchema: {
203
188
  name: z.string().describe('The checkpoint name.'),
204
189
  mode: z.enum(['headless', 'registry-only']).optional(),
205
- user: userSchema,
206
190
  },
207
191
  },
208
- async ({ name, mode, user }) => {
209
- const result = await loadState({ origin, name, mode, user });
192
+ async ({ name, mode }) => {
193
+ const result = await loadState({ origin, name, mode });
210
194
  if (result.error) {
211
195
  return notFoundResult(result.error);
212
196
  }
@@ -335,26 +319,17 @@ function createDocsMcpServer({ origin, honoContext } = {}) {
335
319
  })
336
320
  .optional()
337
321
  .describe(
338
- "Crop to a viewport-relative region — pass an annotation's geometry from feedback."
322
+ 'Crop to a viewport-relative region — pass an annotation\'s geometry from feedback.'
339
323
  ),
340
324
  scrollX: z.number().optional().describe('Scroll offset the clip was recorded at.'),
341
325
  scrollY: z.number().optional().describe('Scroll offset the clip was recorded at.'),
342
- user: userSchema,
343
326
  },
344
327
  },
345
- async ({ pageId, fullPage, clip, scrollX, scrollY, user }) => {
328
+ async ({ pageId, fullPage, clip, scrollX, scrollY }) => {
346
329
  if (!origin) {
347
330
  return notFoundResult('Screenshot unavailable: server origin unknown for this transport.');
348
331
  }
349
- const result = await screenshotPage({
350
- origin,
351
- pageId,
352
- fullPage,
353
- clip,
354
- scrollX,
355
- scrollY,
356
- user,
357
- });
332
+ const result = await screenshotPage({ origin, pageId, fullPage, clip, scrollX, scrollY });
358
333
  if (result.error) {
359
334
  return notFoundResult(result.error);
360
335
  }
@@ -77,24 +77,6 @@ test('MCP tools/list returns all lowdefy tools', async () => {
77
77
  await client.close();
78
78
  });
79
79
 
80
- test('MCP tools that render a page headless advertise an optional user parameter', async () => {
81
- const client = await connectClient();
82
- const { tools } = await client.listTools();
83
-
84
- [
85
- 'lowdefy_screenshot_page',
86
- 'lowdefy_inspect_state',
87
- 'lowdefy_eval_operator',
88
- 'lowdefy_load_state',
89
- ].forEach((name) => {
90
- const tool = tools.find((candidate) => candidate.name === name);
91
- expect(tool.inputSchema.properties.user).toBeDefined();
92
- expect(tool.inputSchema.required ?? []).not.toContain('user');
93
- });
94
-
95
- await client.close();
96
- });
97
-
98
80
  test('MCP tools/call lowdefy_get_schema returns a block schema', async () => {
99
81
  const client = await connectClient();
100
82
  const result = await client.callTool({