@lowdefy/server-dev 0.0.0-experimental-20260827092002 → 0.0.0-experimental-20260827092153
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 +2 -2
- package/client/Page.jsx +13 -4
- package/client/Reload.jsx +8 -0
- package/client/Routing.jsx +31 -23
- package/lib/client/RedirectingPage.jsx +56 -0
- package/lib/client/auth/Auth.jsx +3 -5
- package/lib/client/auth/AuthConfigured.jsx +190 -37
- package/lib/client/auth/AuthNotConfigured.js +23 -2
- package/lib/client/auth/authTemplateSync.test.mjs +40 -0
- package/lib/client/auth/betterAuthSessionContract.test.mjs +181 -0
- package/lib/client/utils/usePageConfig.js +36 -32
- package/lib/client/utils/usePageConfig.test.mjs +62 -0
- package/lib/docs/checkpointStore.js +8 -2
- package/lib/docs/createDocsMcpServer.js +34 -9
- package/lib/docs/createDocsMcpServer.test.mjs +18 -0
- package/lib/docs/evalOperator.js +18 -10
- package/lib/docs/evalOperator.test.mjs +47 -2
- package/lib/docs/evalOperatorHeadless.js +2 -2
- package/lib/docs/getBrowser.js +15 -3
- package/lib/docs/getBrowser.test.mjs +120 -0
- package/lib/docs/inspectState.js +15 -9
- package/lib/docs/inspectState.test.mjs +53 -4
- package/lib/docs/inspectStateHeadless.js +2 -2
- package/lib/docs/loadState.js +15 -2
- package/lib/docs/resolveSource.js +48 -0
- package/lib/docs/resolveSource.test.mjs +100 -0
- package/lib/docs/screenshotPage.js +2 -1
- package/lib/docs/stateCheckpoints.test.mjs +18 -0
- package/lib/server/auth/createSystemContext.js +70 -0
- package/lib/server/auth/getAuth.js +46 -0
- package/lib/server/auth/getHeadlessUser.js +7 -8
- package/lib/server/auth/getMockUser.js +11 -7
- package/lib/server/auth/{getAuthConfig.js → getStrategies.js} +12 -10
- package/lib/server/auth/headlessUser.js +3 -2
- package/lib/server/auth/resolveHeadlessUser.js +38 -0
- package/lib/server/auth/resolveHeadlessUser.test.mjs +74 -0
- package/lib/server/auth/strategies.js +47 -0
- package/lib/server/createLowdefyContext.js +93 -38
- package/lib/server/getPageJitEnrichment.test.mjs +105 -0
- package/lib/server/jitPageBuilder.js +90 -2
- package/lib/server/loadDynamicJsMap.js +47 -0
- package/lib/server/log/createHandleError.js +9 -0
- package/lib/server/log/createHandleError.test.mjs +126 -0
- package/lib/server/log/logRequest.js +1 -3
- package/manager/processes/startServer.mjs +0 -2
- package/manager/utils/createCustomPluginTypesMap.mjs +2 -2
- package/manager/watchers/serverArtifactWatcher.mjs +0 -2
- package/package.json +37 -34
- package/package.original.json +37 -34
- package/src/app.js +42 -19
- package/src/html/renderDevPage.js +5 -2
- package/src/middleware/errorHandler.js +26 -9
- package/src/middleware/errorHandler.test.mjs +287 -0
- package/src/{lib/serveBuildJs.js → middleware/mcpOrgGuard.js} +9 -10
- package/src/middleware/mcpOrgGuard.test.mjs +44 -0
- package/src/routes/auth.js +15 -21
- package/src/routes/detached.js +10 -6
- package/src/routes/docs/evalOperator.js +12 -2
- package/src/routes/docs/inspectState.js +12 -2
- package/src/routes/docs/loadState.js +14 -3
- package/src/routes/docs/parseUserParam.js +52 -0
- package/src/routes/docs/parseUserParam.test.mjs +45 -0
- package/src/routes/docs/runRequest.js +2 -4
- package/src/routes/docs/screenshot.js +7 -1
- package/src/routes/endpoints.js +14 -9
- package/src/routes/jitPage.js +50 -4
- package/src/routes/jitPage.test.mjs +107 -0
- package/src/routes/mcp.js +94 -0
- package/src/routes/mcp.test.mjs +184 -0
- package/src/routes/mcpProtectedResourceMetadata.js +39 -0
- package/src/routes/mcpProtectedResourceMetadata.test.mjs +79 -0
- package/src/routes/mountOauthDiscovery.js +47 -0
- package/src/routes/mountOauthDiscovery.test.mjs +79 -0
- package/src/routes/request.js +16 -8
- package/src/routes/request.test.mjs +124 -0
- package/src/routes/{iconsDynamic.js → user.js} +9 -7
- package/lib/server/auth/getDevSession.js +0 -70
- package/lib/server/auth/getDevSession.test.mjs +0 -138
- package/lib/server/auth/session.js +0 -39
- package/manager/processes/warnAuthUrlPortMismatch.mjs +0 -47
- package/manager/processes/warnAuthUrlPortMismatch.test.mjs +0 -93
- package/src/routes/apiPage.js +0 -37
- package/src/routes/auth.test.mjs +0 -84
- package/src/routes/jsEnv.js +0 -30
package/client/App.jsx
CHANGED
|
@@ -40,7 +40,7 @@ function ThemeTokenResolver({ lowdefyRef, children }) {
|
|
|
40
40
|
return children;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function App({ router }) {
|
|
43
|
+
function App({ config, router }) {
|
|
44
44
|
const lowdefyRef = useRef({});
|
|
45
45
|
const [runtimeErrors, setRuntimeErrors] = useState([]);
|
|
46
46
|
// Subscribe to rootConfig SWR cache — deduplicates with Routing's fetch.
|
|
@@ -143,7 +143,7 @@ function App({ router }) {
|
|
|
143
143
|
/>
|
|
144
144
|
}
|
|
145
145
|
>
|
|
146
|
-
<Auth>
|
|
146
|
+
<Auth user={config?.user}>
|
|
147
147
|
{(auth) => {
|
|
148
148
|
return <Routing auth={auth} lowdefy={lowdefyRef.current} router={router} />;
|
|
149
149
|
}}
|
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
|
|
23
|
+
import RedirectingPage from '../lib/client/RedirectingPage.jsx';
|
|
24
24
|
import usePageConfig from '../lib/client/utils/usePageConfig.js';
|
|
25
25
|
|
|
26
26
|
const Page = ({
|
|
@@ -47,10 +47,22 @@ 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
|
+
|
|
50
59
|
if (!pageConfig) {
|
|
51
60
|
router.replace({ pathname: '/404' });
|
|
52
61
|
return '';
|
|
53
62
|
}
|
|
63
|
+
if (pageConfig.authRedirect) {
|
|
64
|
+
return <RedirectingPage redirect={pageConfig.authRedirect} />;
|
|
65
|
+
}
|
|
54
66
|
if (pageConfig.buildError) {
|
|
55
67
|
return (
|
|
56
68
|
<BuildErrorPage
|
|
@@ -63,9 +75,6 @@ const Page = ({
|
|
|
63
75
|
if (pageConfig.installing) {
|
|
64
76
|
return <InstallingPluginsPage packages={pageConfig.packages} />;
|
|
65
77
|
}
|
|
66
|
-
if (resetContext.restarting) {
|
|
67
|
-
return <RestartingPage />;
|
|
68
|
-
}
|
|
69
78
|
|
|
70
79
|
// Merge dynamic JS entries fetched after JIT build with the static jsMap
|
|
71
80
|
const mergedJsMap = pageConfig._jsEntries ? { ...jsMap, ...pageConfig._jsEntries } : jsMap;
|
package/client/Reload.jsx
CHANGED
|
@@ -25,6 +25,13 @@ 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);
|
|
28
35
|
const mutateCache = useMutateCache(basePath);
|
|
29
36
|
useEffect(() => {
|
|
30
37
|
const sse = new EventSource(`${basePath}/api/reload`);
|
|
@@ -38,6 +45,7 @@ const Reload = ({ children, basePath, lowdefy }) => {
|
|
|
38
45
|
lowdefy._internal.initialised = false;
|
|
39
46
|
}
|
|
40
47
|
setReset(true);
|
|
48
|
+
setReloadTick((tick) => tick + 1);
|
|
41
49
|
console.log('Reloaded config.');
|
|
42
50
|
}, 600);
|
|
43
51
|
});
|
package/client/Routing.jsx
CHANGED
|
@@ -20,6 +20,7 @@ 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';
|
|
23
24
|
import FeedbackMount from './feedback/FeedbackMount.jsx';
|
|
24
25
|
import Inspector from './Inspector.jsx';
|
|
25
26
|
import OpenInEditorListener from './openInEditor/OpenInEditorListener.jsx';
|
|
@@ -75,29 +76,36 @@ function Routing({ auth, lowdefy, router }) {
|
|
|
75
76
|
<FeedbackMount basePath={router.basePath} lowdefy={lowdefy} pageId={pageId} />
|
|
76
77
|
<OpenInEditorListener basePath={router.basePath} pageId={pageId} />
|
|
77
78
|
<Reload basePath={router.basePath} lowdefy={lowdefy}>
|
|
78
|
-
{(resetContext) =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
}
|
|
101
109
|
</Reload>
|
|
102
110
|
</>
|
|
103
111
|
);
|
|
@@ -0,0 +1,56 @@
|
|
|
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 React from 'react';
|
|
18
|
+
|
|
19
|
+
// Shown while the 401 sign-in redirect navigates. The link is the escape
|
|
20
|
+
// hatch for a dropped navigation - the tab must never depend on a single
|
|
21
|
+
// window.location.assign to leave this screen.
|
|
22
|
+
const RedirectingPage = ({ redirect }) => {
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
style={{
|
|
26
|
+
height: '100vh',
|
|
27
|
+
textAlign: 'center',
|
|
28
|
+
display: 'flex',
|
|
29
|
+
flexDirection: 'column',
|
|
30
|
+
alignItems: 'center',
|
|
31
|
+
justifyContent: 'center',
|
|
32
|
+
backgroundColor: 'var(--ant-color-bg-layout)',
|
|
33
|
+
color: 'var(--ant-color-text)',
|
|
34
|
+
fontFamily:
|
|
35
|
+
"system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
<div
|
|
39
|
+
style={{
|
|
40
|
+
verticalAlign: 'middle',
|
|
41
|
+
display: 'inline-block',
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
<h3>Redirecting to sign-in...</h3>
|
|
45
|
+
<p>
|
|
46
|
+
<a href={redirect} style={{ color: 'var(--ant-color-primary)' }}>
|
|
47
|
+
Continue to sign-in
|
|
48
|
+
</a>{' '}
|
|
49
|
+
if you are not redirected automatically.
|
|
50
|
+
</p>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export default RedirectingPage;
|
package/lib/client/auth/Auth.jsx
CHANGED
|
@@ -14,8 +14,6 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
/* eslint-disable react/jsx-props-no-spreading */
|
|
18
|
-
|
|
19
17
|
import React from 'react';
|
|
20
18
|
import AuthConfigured from './AuthConfigured.jsx';
|
|
21
19
|
import AuthNotConfigured from './AuthNotConfigured.js';
|
|
@@ -25,15 +23,15 @@ import { serializer } from '@lowdefy/helpers';
|
|
|
25
23
|
// Client code imports the build JSON directly — Vite handles JSON imports;
|
|
26
24
|
// the lib/build/*.js wrappers are server-only (they read from disk).
|
|
27
25
|
// Deserialize to restore arrays from their ~arr build markers (providers
|
|
28
|
-
// must be a real array for
|
|
26
|
+
// must be a real array for provider lookups in createAuthMethods).
|
|
29
27
|
import rawAuthConfig from '../../../build/auth.json';
|
|
30
28
|
|
|
31
29
|
const authConfig = serializer.deserialize(rawAuthConfig);
|
|
32
30
|
|
|
33
|
-
function Auth({ children,
|
|
31
|
+
function Auth({ children, user }) {
|
|
34
32
|
if (authConfig.configured === true) {
|
|
35
33
|
return (
|
|
36
|
-
<AuthConfigured
|
|
34
|
+
<AuthConfigured serverUser={user} authConfig={authConfig}>
|
|
37
35
|
{(auth) => children(auth)}
|
|
38
36
|
</AuthConfigured>
|
|
39
37
|
);
|
|
@@ -14,60 +14,213 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
import React, { useEffect, useRef } from 'react';
|
|
17
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
18
|
+
import { createAuthClient } from 'better-auth/react';
|
|
20
19
|
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
} from '@
|
|
20
|
+
adminClient,
|
|
21
|
+
magicLinkClient,
|
|
22
|
+
organizationClient,
|
|
23
|
+
phoneNumberClient,
|
|
24
|
+
twoFactorClient,
|
|
25
|
+
} from 'better-auth/client/plugins';
|
|
26
|
+
import { oauthProviderClient } from '@better-auth/oauth-provider/client';
|
|
27
|
+
import { passkeyClient } from '@better-auth/passkey/client';
|
|
28
28
|
|
|
29
|
-
import { serializer } from '@lowdefy/helpers';
|
|
29
|
+
import { normalizeCaller, 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
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
// GenericOAuth providers have no client plugin here. BetterAuth 1.7.0 dropped
|
|
36
|
+
// genericOAuthClient from better-auth/client/plugins, and nothing replaces it:
|
|
37
|
+
// through 1.6.x it was a marker plugin carrying only an id, a version, an empty
|
|
38
|
+
// $InferServerPlugin for TypeScript inference, and the generic-oauth error
|
|
39
|
+
// codes - no actions, atoms, fetch plugins or path methods. signIn.oauth2 is
|
|
40
|
+
// resolved by the client's dynamic path proxy from the call itself
|
|
41
|
+
// (signIn.oauth2 -> POST /sign-in/oauth2, POST because providerId is always
|
|
42
|
+
// sent), so the sign-in a Lowdefy GenericOAuth provider makes is unaffected.
|
|
43
|
+
const authClient = createAuthClient({
|
|
44
|
+
baseURL: `${window.location.origin}${lowdefyConfig.basePath ?? ''}/api/auth`,
|
|
45
|
+
plugins: [
|
|
46
|
+
adminClient(),
|
|
47
|
+
magicLinkClient(),
|
|
48
|
+
oauthProviderClient(),
|
|
49
|
+
organizationClient(),
|
|
50
|
+
passkeyClient(),
|
|
51
|
+
phoneNumberClient(),
|
|
52
|
+
twoFactorClient(),
|
|
53
|
+
],
|
|
54
|
+
});
|
|
40
55
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
// UpdateSession owns session freshness - suppress BetterAuth's own
|
|
57
|
+
// signal-driven refetch on the calls that would otherwise fire one, so
|
|
58
|
+
// nothing competes with (and aborts) the refetch UpdateSession awaits.
|
|
59
|
+
// Only calls that mutate an existing session are suppressed - calls that
|
|
60
|
+
// can establish one (the sign-ins, and the two-factor, phone-number and
|
|
61
|
+
// passkey verifies) keep their signal: its refetch is what makes the store
|
|
62
|
+
// notice a login when no navigation follows.
|
|
63
|
+
// disableSignal silences all atomListeners for the call, including the
|
|
64
|
+
// organization plugin's org atoms - none are exposed to app config, so a
|
|
65
|
+
// future design exposing one must refresh it through its own action.
|
|
66
|
+
const sessionScoped = (params) => ({
|
|
67
|
+
...params,
|
|
68
|
+
fetchOptions: { ...params?.fetchOptions, disableSignal: true },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// The server resolves the caller per request and embeds it in the page
|
|
72
|
+
// config, so the first render never flashes unauthenticated. The BetterAuth
|
|
73
|
+
// client store takes over once its session fetch settles.
|
|
74
|
+
//
|
|
75
|
+
// Roles and merged attributes resolve server-side from the active member row
|
|
76
|
+
// - the base session carries neither. The last server-resolved caller is
|
|
77
|
+
// held in state: while the session user is unchanged it stays authoritative
|
|
78
|
+
// for the fields the session does not carry, and UpdateSession refreshes it
|
|
79
|
+
// from /api/user after a change (e.g. SetActiveOrganization). Holding it in
|
|
80
|
+
// state, not a ref, re-renders Session when it changes so _user recomputes -
|
|
81
|
+
// the same reactive footing the session half already stands on.
|
|
82
|
+
//
|
|
83
|
+
// Only the session half is passed through normalizeCaller. BetterAuth's store
|
|
84
|
+
// returns camelCase keys whatever the columns are named, so untransformed it
|
|
85
|
+
// would sit emailVerified beside the resolved caller's email_verified on one
|
|
86
|
+
// object; anything arriving from the server is already snake_case.
|
|
87
|
+
function Session({ children, reloadSuppressedRef, serverUser }) {
|
|
88
|
+
const { data: session, isPending } = authClient.useSession();
|
|
89
|
+
const [resolvedUser, setResolvedUser] = useState(serverUser);
|
|
90
|
+
const wasAuthenticated = useRef(Boolean(serverUser));
|
|
45
91
|
|
|
46
92
|
useEffect(() => {
|
|
47
|
-
if (
|
|
93
|
+
if (session) {
|
|
94
|
+
wasAuthenticated.current = true;
|
|
95
|
+
}
|
|
96
|
+
// Reload after sign-out (or session revocation) so the server can apply
|
|
97
|
+
// the page auth fork - a protected page redirects to the login page.
|
|
98
|
+
// A logout with a callbackUrl suppresses the reload - its own navigation
|
|
99
|
+
// would otherwise race this one.
|
|
100
|
+
if (wasAuthenticated.current && !isPending && !session && !reloadSuppressedRef.current) {
|
|
48
101
|
window.location.reload();
|
|
49
102
|
}
|
|
50
|
-
}, [
|
|
103
|
+
}, [session, isPending]);
|
|
51
104
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
105
|
+
if (isPending) {
|
|
106
|
+
return children(resolvedUser, setResolvedUser);
|
|
107
|
+
}
|
|
108
|
+
if (!session?.user) {
|
|
109
|
+
return children(null, setResolvedUser);
|
|
110
|
+
}
|
|
111
|
+
const resolved = resolvedUser;
|
|
112
|
+
if (!resolved) {
|
|
113
|
+
return children({ roles: [], ...normalizeCaller(session.user) }, setResolvedUser);
|
|
114
|
+
}
|
|
115
|
+
if (resolved.id !== session.user.id) {
|
|
116
|
+
// The ref has not caught up with a changed session user - a sign-in as a
|
|
117
|
+
// different person lands between the session refetch and the /api/user
|
|
118
|
+
// result. Render the last resolved caller whole rather than mixing the new
|
|
119
|
+
// session user with the previous person's resolved fields, or falling back
|
|
120
|
+
// to a caller with no roles - blocks re-evaluate operators on render, so a
|
|
121
|
+
// role-guarded page would act on an empty role list. UpdateSession closes
|
|
122
|
+
// the window when it lands the new caller; a flow that changes the session
|
|
123
|
+
// user without chaining it leaves the previous caller rendered until the
|
|
124
|
+
// next page load.
|
|
125
|
+
return children(resolved, setResolvedUser);
|
|
56
126
|
}
|
|
57
|
-
|
|
127
|
+
// _user is one object with one meaning on both sides: the resolved caller is
|
|
128
|
+
// spread whole, so every field resolveAuthentication emits is readable in app
|
|
129
|
+
// config without this file naming it. session.user is the floor - the client
|
|
130
|
+
// store may hold a fresher one than the resolved state, and the resolved
|
|
131
|
+
// object wins wherever both carry a key.
|
|
132
|
+
const user = { ...normalizeCaller(session.user), ...resolved };
|
|
133
|
+
return children(user, setResolvedUser);
|
|
58
134
|
}
|
|
59
135
|
|
|
60
|
-
function AuthConfigured({ authConfig, children,
|
|
61
|
-
const
|
|
136
|
+
function AuthConfigured({ authConfig, children, serverUser }) {
|
|
137
|
+
const reloadSuppressedRef = useRef(false);
|
|
138
|
+
const auth = {
|
|
139
|
+
authConfig,
|
|
140
|
+
getSession: ({ disableCookieCache } = {}) =>
|
|
141
|
+
authClient.getSession(disableCookieCache ? { query: { disableCookieCache: true } } : {}),
|
|
142
|
+
// Refetches the session through the session atom - the store's only
|
|
143
|
+
// writer - and resolves only after the store is written. fetchSession
|
|
144
|
+
// returns undefined and never rejects (failures are written into the
|
|
145
|
+
// atom), so the atom's own { data, error } is read back and returned,
|
|
146
|
+
// keeping unwrap working and surfacing a failed refresh to the action.
|
|
147
|
+
refreshSession: async ({ disableCookieCache } = {}) => {
|
|
148
|
+
const sessionAtom = authClient.$store.atoms.session;
|
|
149
|
+
await sessionAtom
|
|
150
|
+
.get()
|
|
151
|
+
.refetch(disableCookieCache ? { query: { disableCookieCache: true } } : undefined);
|
|
152
|
+
const { data, error } = sessionAtom.get();
|
|
153
|
+
return { data, error };
|
|
154
|
+
},
|
|
155
|
+
// The server-resolved caller - roles from the active member row and the
|
|
156
|
+
// merged attributes bag - for re-syncing after session changes.
|
|
157
|
+
getResolvedUser: async () => {
|
|
158
|
+
const response = await fetch(`${lowdefyConfig.basePath ?? ''}/api/user`, {
|
|
159
|
+
credentials: 'same-origin',
|
|
160
|
+
});
|
|
161
|
+
if (!response.ok) {
|
|
162
|
+
// /api/user itself always answers 200 { user } - user: null when no
|
|
163
|
+
// caller is admitted, which updateSession branches on. A non-OK
|
|
164
|
+
// response is a transport failure (gateway error page, 5xx) with no
|
|
165
|
+
// parsable body.
|
|
166
|
+
throw new Error(`Failed to fetch the resolved user (HTTP ${response.status}).`);
|
|
167
|
+
}
|
|
168
|
+
return response.json();
|
|
169
|
+
},
|
|
170
|
+
suppressSignOutReload: () => {
|
|
171
|
+
reloadSuppressedRef.current = true;
|
|
172
|
+
},
|
|
173
|
+
acceptInvitation: (params) => authClient.organization.acceptInvitation(sessionScoped(params)),
|
|
174
|
+
// addPasskey runs the WebAuthn browser ceremony itself - options fetch,
|
|
175
|
+
// authenticator prompt, verification.
|
|
176
|
+
addPasskey: (params) => authClient.passkey.addPasskey(params),
|
|
177
|
+
changePassword: (params) => authClient.changePassword(sessionScoped(params)),
|
|
178
|
+
deletePasskey: (params) => authClient.passkey.deletePasskey(params),
|
|
179
|
+
updatePasskey: (params) => authClient.passkey.updatePasskey(params),
|
|
180
|
+
leaveOrganization: (params) => authClient.organization.leave(sessionScoped(params)),
|
|
181
|
+
// The caller's memberships from the org plugin, keyed on the session
|
|
182
|
+
// alone - not the active organization. sessionScoped so the list read
|
|
183
|
+
// does not also fire the org-list atom's own refetch of the same route.
|
|
184
|
+
listOrganizations: () => authClient.organization.list(sessionScoped()),
|
|
185
|
+
// POST /oauth2/consent. The signed authorization query is not passed
|
|
186
|
+
// here - the oauthProviderClient fetch plugin stamps it onto the body as
|
|
187
|
+
// oauth_query from window.location.search, so the call must run while
|
|
188
|
+
// the page still holds the query the oauth-provider redirect arrived
|
|
189
|
+
// with.
|
|
190
|
+
oauth2Consent: (params) => authClient.oauth2.consent(params),
|
|
191
|
+
phoneNumberRequestPasswordReset: (params) =>
|
|
192
|
+
authClient.phoneNumber.requestPasswordReset(params),
|
|
193
|
+
phoneNumberResetPassword: (params) => authClient.phoneNumber.resetPassword(params),
|
|
194
|
+
phoneNumberSendOtp: (params) => authClient.phoneNumber.sendOtp(params),
|
|
195
|
+
phoneNumberVerify: (params) => authClient.phoneNumber.verify(params),
|
|
196
|
+
requestPasswordReset: (params) => authClient.requestPasswordReset(params),
|
|
197
|
+
resetPassword: (params) => authClient.resetPassword(params),
|
|
198
|
+
revokeOtherSessions: () => authClient.revokeOtherSessions(sessionScoped()),
|
|
199
|
+
sendVerificationEmail: (params) => authClient.sendVerificationEmail(params),
|
|
200
|
+
setActiveOrganization: (params) => authClient.organization.setActive(sessionScoped(params)),
|
|
201
|
+
signInEmail: (params) => authClient.signIn.email(params),
|
|
202
|
+
signInMagicLink: (params) => authClient.signIn.magicLink(params),
|
|
203
|
+
signInOauth2: (params) => authClient.signIn.oauth2(params),
|
|
204
|
+
signInPasskey: (params) => authClient.signIn.passkey(params),
|
|
205
|
+
signInPhoneNumber: (params) => authClient.signIn.phoneNumber(params),
|
|
206
|
+
signInSocial: (params) => authClient.signIn.social(params),
|
|
207
|
+
signOut: () => authClient.signOut(),
|
|
208
|
+
signUpEmail: (params) => authClient.signUp.email(params),
|
|
209
|
+
twoFactorDisable: (params) => authClient.twoFactor.disable(sessionScoped(params)),
|
|
210
|
+
twoFactorEnable: (params) => authClient.twoFactor.enable(sessionScoped(params)),
|
|
211
|
+
twoFactorGenerateBackupCodes: (params) =>
|
|
212
|
+
authClient.twoFactor.generateBackupCodes(sessionScoped(params)),
|
|
213
|
+
twoFactorVerifyBackupCode: (params) => authClient.twoFactor.verifyBackupCode(params),
|
|
214
|
+
twoFactorVerifyTotp: (params) => authClient.twoFactor.verifyTotp(params),
|
|
215
|
+
};
|
|
62
216
|
return (
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
</SessionProvider>
|
|
217
|
+
<Session reloadSuppressedRef={reloadSuppressedRef} serverUser={serverUser}>
|
|
218
|
+
{(user, setResolvedUser) => {
|
|
219
|
+
auth.user = user;
|
|
220
|
+
auth.updateResolvedUser = (resolved) => setResolvedUser(resolved);
|
|
221
|
+
return children(auth);
|
|
222
|
+
}}
|
|
223
|
+
</Session>
|
|
71
224
|
);
|
|
72
225
|
}
|
|
73
226
|
|
|
@@ -13,7 +13,6 @@
|
|
|
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 */
|
|
17
16
|
|
|
18
17
|
function authNotConfigured() {
|
|
19
18
|
throw new Error('Auth not configured.');
|
|
@@ -22,9 +21,31 @@ function authNotConfigured() {
|
|
|
22
21
|
function AuthNotConfigured({ authConfig, children }) {
|
|
23
22
|
const auth = {
|
|
24
23
|
authConfig,
|
|
24
|
+
user: null,
|
|
25
|
+
acceptInvitation: authNotConfigured,
|
|
26
|
+
addPasskey: authNotConfigured,
|
|
27
|
+
changePassword: authNotConfigured,
|
|
28
|
+
deletePasskey: authNotConfigured,
|
|
29
|
+
updatePasskey: authNotConfigured,
|
|
30
|
+
getResolvedUser: authNotConfigured,
|
|
25
31
|
getSession: authNotConfigured,
|
|
26
|
-
|
|
32
|
+
leaveOrganization: authNotConfigured,
|
|
33
|
+
requestPasswordReset: authNotConfigured,
|
|
34
|
+
resetPassword: authNotConfigured,
|
|
35
|
+
revokeOtherSessions: authNotConfigured,
|
|
36
|
+
sendVerificationEmail: authNotConfigured,
|
|
37
|
+
setActiveOrganization: authNotConfigured,
|
|
38
|
+
signInEmail: authNotConfigured,
|
|
39
|
+
signInMagicLink: authNotConfigured,
|
|
40
|
+
signInOauth2: authNotConfigured,
|
|
41
|
+
signInSocial: authNotConfigured,
|
|
27
42
|
signOut: authNotConfigured,
|
|
43
|
+
signUpEmail: authNotConfigured,
|
|
44
|
+
twoFactorDisable: authNotConfigured,
|
|
45
|
+
twoFactorEnable: authNotConfigured,
|
|
46
|
+
twoFactorGenerateBackupCodes: authNotConfigured,
|
|
47
|
+
twoFactorVerifyBackupCode: authNotConfigured,
|
|
48
|
+
twoFactorVerifyTotp: authNotConfigured,
|
|
28
49
|
};
|
|
29
50
|
|
|
30
51
|
return children(auth);
|
|
@@ -0,0 +1,40 @@
|
|
|
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 { readdir, readFile } from 'node:fs/promises';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const devAuthDir = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const prodAuthDir = path.resolve(devAuthDir, '../../../../server/lib/client/auth');
|
|
23
|
+
|
|
24
|
+
// The dev and prod server templates each carry their own copy of the auth
|
|
25
|
+
// components, kept in sync by hand. A change applied to one and not the
|
|
26
|
+
// other ships dev-only or prod-only - the app then behaves differently
|
|
27
|
+
// between lowdefy dev and a production build, which no unit test of either
|
|
28
|
+
// copy can catch.
|
|
29
|
+
test('the auth client templates are byte-identical between server and server-dev', async () => {
|
|
30
|
+
const isTemplate = (file) => !file.includes('.test.');
|
|
31
|
+
const devFiles = (await readdir(devAuthDir)).filter(isTemplate).sort();
|
|
32
|
+
const prodFiles = (await readdir(prodAuthDir)).filter(isTemplate).sort();
|
|
33
|
+
expect(prodFiles).toEqual(devFiles);
|
|
34
|
+
for (const file of devFiles) {
|
|
35
|
+
const dev = await readFile(path.join(devAuthDir, file), 'utf8');
|
|
36
|
+
const prod = await readFile(path.join(prodAuthDir, file), 'utf8');
|
|
37
|
+
// Compared per file so a failure names the file that diverged.
|
|
38
|
+
expect({ file, content: prod }).toEqual({ file, content: dev });
|
|
39
|
+
}
|
|
40
|
+
});
|