@lowdefy/server-dev 0.0.0-experimental-20260909201552 → 0.0.0-experimental-20260910082708
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 +1 -1
- package/client/Inspector.jsx +1 -1
- package/client/Page.jsx +0 -13
- package/lib/client/auth/Auth.jsx +8 -6
- package/lib/client/auth/AuthConfigured.jsx +37 -200
- package/lib/client/auth/AuthNotConfigured.js +8 -41
- package/lib/client/utils/usePageConfig.js +0 -15
- package/lib/docs/getBrowser.test.mjs +2 -0
- package/lib/docs/runEndpoint.test.mjs +1 -1
- package/lib/docs/runRequest.test.mjs +3 -4
- package/lib/server/auth/{getStrategies.js → getAuthConfig.js} +10 -12
- package/lib/server/auth/getDevSession.js +70 -0
- package/lib/server/auth/getDevSession.test.mjs +138 -0
- package/lib/server/auth/getDevSessionAuthDevOnly.test.mjs +73 -0
- package/lib/server/auth/getHeadlessUser.js +8 -7
- package/lib/server/auth/getMockUser.js +6 -10
- package/lib/server/auth/headlessUser.js +2 -3
- package/lib/server/auth/resolveHeadlessUser.test.mjs +4 -0
- package/lib/server/auth/session.js +39 -0
- package/lib/server/createLowdefyContext.js +49 -87
- package/lib/server/log/logRequest.js +3 -1
- package/manager/processes/startServer.mjs +2 -0
- package/manager/processes/warnAuthUrlPortMismatch.mjs +47 -0
- package/manager/processes/warnAuthUrlPortMismatch.test.mjs +93 -0
- package/manager/utils/createCustomPluginTypesMap.mjs +2 -2
- package/manager/watchers/serverArtifactWatcher.mjs +2 -0
- package/package.json +35 -38
- package/package.original.json +35 -38
- package/src/app.js +16 -37
- package/src/html/renderDevPage.js +14 -5
- package/src/middleware/errorHandler.js +11 -24
- package/src/middleware/errorHandler.test.mjs +18 -72
- package/src/routes/apiPage.js +37 -0
- package/src/routes/auth.js +26 -18
- package/src/routes/auth.test.mjs +84 -0
- package/src/routes/cronForward.js +1 -3
- package/src/routes/detached.js +6 -10
- package/src/routes/docs/runEndpoint.js +3 -2
- package/src/routes/docs/runRequest.js +4 -2
- package/src/routes/endpoints.js +9 -14
- package/src/routes/jitPage.js +2 -42
- package/src/routes/mcp.js +5 -71
- package/lib/client/RedirectingPage.jsx +0 -56
- package/lib/client/auth/authNotConfiguredParity.test.mjs +0 -61
- package/lib/client/auth/authTemplateSync.test.mjs +0 -40
- package/lib/client/auth/betterAuthSessionContract.test.mjs +0 -181
- package/lib/server/auth/createSystemContext.js +0 -70
- package/lib/server/auth/getAuth.js +0 -46
- package/lib/server/loadDynamicJsMap.js +0 -47
- package/src/routes/jitPage.test.mjs +0 -107
- package/src/routes/mcp.test.mjs +0 -199
- package/src/routes/mcpProtectedResourceMetadata.js +0 -34
- package/src/routes/mcpProtectedResourceMetadata.test.mjs +0 -65
- package/src/routes/mountOauthDiscovery.js +0 -44
- package/src/routes/mountOauthDiscovery.test.mjs +0 -79
- package/src/routes/normalizeMcpProtocolVersionHeader.js +0 -46
- package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +0 -62
- package/src/routes/user.js +0 -27
package/client/App.jsx
CHANGED
package/client/Inspector.jsx
CHANGED
|
@@ -123,7 +123,7 @@ async function bootstrapFromCheckpoint({ basePath, pageId, checkpointName, isCan
|
|
|
123
123
|
context._internal.update();
|
|
124
124
|
} catch (error) {
|
|
125
125
|
// eslint-disable-next-line no-console
|
|
126
|
-
console.error(
|
|
126
|
+
console.error('Failed to load checkpoint "%s":', checkpointName, error);
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
package/client/Page.jsx
CHANGED
|
@@ -20,7 +20,6 @@ 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';
|
|
24
23
|
import usePageConfig from '../lib/client/utils/usePageConfig.js';
|
|
25
24
|
|
|
26
25
|
const Page = ({
|
|
@@ -47,22 +46,10 @@ const Page = ({
|
|
|
47
46
|
}
|
|
48
47
|
}, [pageConfig?._warnings, lowdefy]);
|
|
49
48
|
|
|
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
49
|
if (!pageConfig) {
|
|
60
50
|
router.replace({ pathname: '/404' });
|
|
61
51
|
return '';
|
|
62
52
|
}
|
|
63
|
-
if (pageConfig.authRedirect) {
|
|
64
|
-
return <RedirectingPage redirect={pageConfig.authRedirect} />;
|
|
65
|
-
}
|
|
66
53
|
if (pageConfig.buildError) {
|
|
67
54
|
return (
|
|
68
55
|
<BuildErrorPage
|
package/lib/client/auth/Auth.jsx
CHANGED
|
@@ -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,24 +25,24 @@ 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
|
|
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,
|
|
33
|
+
function Auth({ children, session }) {
|
|
32
34
|
if (authConfig.configured === true) {
|
|
33
35
|
return (
|
|
34
|
-
<AuthConfigured
|
|
36
|
+
<AuthConfigured serverSession={session} authConfig={authConfig}>
|
|
35
37
|
{(auth) => children(auth)}
|
|
36
38
|
</AuthConfigured>
|
|
37
39
|
);
|
|
38
40
|
}
|
|
39
41
|
// An app whose only auth key is auth.dev has no auth stack, but the dev
|
|
40
|
-
// server still resolves a
|
|
41
|
-
// _user reads the same identity in the browser as on the server.
|
|
42
|
+
// server still resolves a session for auth.dev.mockUser - pass it through
|
|
43
|
+
// so _user reads the same identity in the browser as on the server.
|
|
42
44
|
return (
|
|
43
|
-
<AuthNotConfigured authConfig={authConfig}
|
|
45
|
+
<AuthNotConfigured authConfig={authConfig} session={session}>
|
|
44
46
|
{(auth) => children(auth)}
|
|
45
47
|
</AuthNotConfigured>
|
|
46
48
|
);
|
|
@@ -14,223 +14,60 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
/* eslint-disable react/jsx-props-no-spreading */
|
|
18
|
+
|
|
19
|
+
import React, { useEffect, useRef } from 'react';
|
|
19
20
|
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} from '
|
|
27
|
-
import { oauthProviderClient } from '@better-auth/oauth-provider/client';
|
|
28
|
-
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';
|
|
29
28
|
|
|
30
|
-
import {
|
|
29
|
+
import { serializer } from '@lowdefy/helpers';
|
|
31
30
|
|
|
32
31
|
import rawLowdefyConfig from '../../../build/config.json';
|
|
33
32
|
|
|
34
33
|
const lowdefyConfig = serializer.deserialize(rawLowdefyConfig);
|
|
35
34
|
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// resolved by the client's dynamic path proxy from the call itself
|
|
42
|
-
// (signIn.oauth2 -> POST /sign-in/oauth2, POST because providerId is always
|
|
43
|
-
// sent), so the sign-in a Lowdefy GenericOAuth provider makes is unaffected.
|
|
44
|
-
const authClient = createAuthClient({
|
|
45
|
-
baseURL: `${window.location.origin}${lowdefyConfig.basePath ?? ''}/api/auth`,
|
|
46
|
-
plugins: [
|
|
47
|
-
adminClient(),
|
|
48
|
-
emailOTPClient(),
|
|
49
|
-
magicLinkClient(),
|
|
50
|
-
oauthProviderClient(),
|
|
51
|
-
organizationClient(),
|
|
52
|
-
passkeyClient(),
|
|
53
|
-
phoneNumberClient(),
|
|
54
|
-
twoFactorClient(),
|
|
55
|
-
],
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// UpdateSession owns session freshness - suppress BetterAuth's own
|
|
59
|
-
// signal-driven refetch on the calls that would otherwise fire one, so
|
|
60
|
-
// nothing competes with (and aborts) the refetch UpdateSession awaits.
|
|
61
|
-
// Only calls that mutate an existing session are suppressed - calls that
|
|
62
|
-
// can establish one (the sign-ins, and the two-factor, phone-number,
|
|
63
|
-
// email-otp and passkey verifies) keep their signal: its refetch is what makes the store
|
|
64
|
-
// notice a login when no navigation follows.
|
|
65
|
-
// disableSignal silences all atomListeners for the call, including the
|
|
66
|
-
// organization plugin's org atoms - none are exposed to app config, so a
|
|
67
|
-
// future design exposing one must refresh it through its own action.
|
|
68
|
-
const sessionScoped = (params) => ({
|
|
69
|
-
...params,
|
|
70
|
-
fetchOptions: { ...params?.fetchOptions, disableSignal: true },
|
|
71
|
-
});
|
|
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
|
+
}
|
|
72
40
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// Roles and merged attributes resolve server-side from the active member row
|
|
78
|
-
// - the base session carries neither. The last server-resolved caller is
|
|
79
|
-
// held in state: while the session user is unchanged it stays authoritative
|
|
80
|
-
// for the fields the session does not carry, and UpdateSession refreshes it
|
|
81
|
-
// from /api/user after a change (e.g. SetActiveOrganization). Holding it in
|
|
82
|
-
// state, not a ref, re-renders Session when it changes so _user recomputes -
|
|
83
|
-
// the same reactive footing the session half already stands on.
|
|
84
|
-
//
|
|
85
|
-
// Only the session half is passed through normalizeCaller. BetterAuth's store
|
|
86
|
-
// returns camelCase keys whatever the columns are named, so untransformed it
|
|
87
|
-
// would sit emailVerified beside the resolved caller's email_verified on one
|
|
88
|
-
// object; anything arriving from the server is already snake_case.
|
|
89
|
-
function Session({ children, reloadSuppressedRef, serverUser }) {
|
|
90
|
-
const { data: session, isPending } = authClient.useSession();
|
|
91
|
-
const [resolvedUser, setResolvedUser] = useState(serverUser);
|
|
92
|
-
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';
|
|
93
45
|
|
|
94
46
|
useEffect(() => {
|
|
95
|
-
if (
|
|
96
|
-
wasAuthenticated.current = true;
|
|
97
|
-
}
|
|
98
|
-
// Reload after sign-out (or session revocation) so the server can apply
|
|
99
|
-
// the page auth fork - a protected page redirects to the login page.
|
|
100
|
-
// A logout with a callbackUrl suppresses the reload - its own navigation
|
|
101
|
-
// would otherwise race this one.
|
|
102
|
-
if (wasAuthenticated.current && !isPending && !session && !reloadSuppressedRef.current) {
|
|
47
|
+
if (wasAuthenticated.current && status === 'unauthenticated') {
|
|
103
48
|
window.location.reload();
|
|
104
49
|
}
|
|
105
|
-
}, [
|
|
50
|
+
}, [status]);
|
|
106
51
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return children(null, setResolvedUser);
|
|
112
|
-
}
|
|
113
|
-
const resolved = resolvedUser;
|
|
114
|
-
if (!resolved) {
|
|
115
|
-
return children({ roles: [], ...normalizeCaller(session.user) }, setResolvedUser);
|
|
116
|
-
}
|
|
117
|
-
if (resolved.id !== session.user.id) {
|
|
118
|
-
// The ref has not caught up with a changed session user - a sign-in as a
|
|
119
|
-
// different person lands between the session refetch and the /api/user
|
|
120
|
-
// result. Render the last resolved caller whole rather than mixing the new
|
|
121
|
-
// session user with the previous person's resolved fields, or falling back
|
|
122
|
-
// to a caller with no roles - blocks re-evaluate operators on render, so a
|
|
123
|
-
// role-guarded page would act on an empty role list. UpdateSession closes
|
|
124
|
-
// the window when it lands the new caller; a flow that changes the session
|
|
125
|
-
// user without chaining it leaves the previous caller rendered until the
|
|
126
|
-
// next page load.
|
|
127
|
-
return children(resolved, setResolvedUser);
|
|
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 '';
|
|
128
56
|
}
|
|
129
|
-
|
|
130
|
-
// spread whole, so every field resolveAuthentication emits is readable in app
|
|
131
|
-
// config without this file naming it. session.user is the floor - the client
|
|
132
|
-
// store may hold a fresher one than the resolved state, and the resolved
|
|
133
|
-
// object wins wherever both carry a key.
|
|
134
|
-
const user = { ...normalizeCaller(session.user), ...resolved };
|
|
135
|
-
return children(user, setResolvedUser);
|
|
57
|
+
return children(session);
|
|
136
58
|
}
|
|
137
59
|
|
|
138
|
-
function AuthConfigured({ authConfig, children,
|
|
139
|
-
const
|
|
140
|
-
const auth = {
|
|
141
|
-
authConfig,
|
|
142
|
-
getSession: ({ disableCookieCache } = {}) =>
|
|
143
|
-
authClient.getSession(disableCookieCache ? { query: { disableCookieCache: true } } : {}),
|
|
144
|
-
// Refetches the session through the session atom - the store's only
|
|
145
|
-
// writer - and resolves only after the store is written. fetchSession
|
|
146
|
-
// returns undefined and never rejects (failures are written into the
|
|
147
|
-
// atom), so the atom's own { data, error } is read back and returned,
|
|
148
|
-
// keeping unwrap working and surfacing a failed refresh to the action.
|
|
149
|
-
refreshSession: async ({ disableCookieCache } = {}) => {
|
|
150
|
-
const sessionAtom = authClient.$store.atoms.session;
|
|
151
|
-
await sessionAtom
|
|
152
|
-
.get()
|
|
153
|
-
.refetch(disableCookieCache ? { query: { disableCookieCache: true } } : undefined);
|
|
154
|
-
const { data, error } = sessionAtom.get();
|
|
155
|
-
return { data, error };
|
|
156
|
-
},
|
|
157
|
-
// The server-resolved caller - roles from the active member row and the
|
|
158
|
-
// merged attributes bag - for re-syncing after session changes.
|
|
159
|
-
getResolvedUser: async () => {
|
|
160
|
-
const response = await fetch(`${lowdefyConfig.basePath ?? ''}/api/user`, {
|
|
161
|
-
credentials: 'same-origin',
|
|
162
|
-
});
|
|
163
|
-
if (!response.ok) {
|
|
164
|
-
// /api/user itself always answers 200 { user } - user: null when no
|
|
165
|
-
// caller is admitted, which updateSession branches on. A non-OK
|
|
166
|
-
// response is a transport failure (gateway error page, 5xx) with no
|
|
167
|
-
// parsable body.
|
|
168
|
-
throw new Error(`Failed to fetch the resolved user (HTTP ${response.status}).`);
|
|
169
|
-
}
|
|
170
|
-
return response.json();
|
|
171
|
-
},
|
|
172
|
-
suppressSignOutReload: () => {
|
|
173
|
-
reloadSuppressedRef.current = true;
|
|
174
|
-
},
|
|
175
|
-
acceptInvitation: (params) => authClient.organization.acceptInvitation(sessionScoped(params)),
|
|
176
|
-
// addPasskey runs the WebAuthn browser ceremony itself - options fetch,
|
|
177
|
-
// authenticator prompt, verification.
|
|
178
|
-
addPasskey: (params) => authClient.passkey.addPasskey(params),
|
|
179
|
-
changePassword: (params) => authClient.changePassword(sessionScoped(params)),
|
|
180
|
-
deletePasskey: (params) => authClient.passkey.deletePasskey(params),
|
|
181
|
-
updatePasskey: (params) => authClient.passkey.updatePasskey(params),
|
|
182
|
-
leaveOrganization: (params) => authClient.organization.leave(sessionScoped(params)),
|
|
183
|
-
// The caller's memberships from the org plugin, keyed on the session
|
|
184
|
-
// alone - not the active organization. sessionScoped so the list read
|
|
185
|
-
// does not also fire the org-list atom's own refetch of the same route.
|
|
186
|
-
listOrganizations: () => authClient.organization.list(sessionScoped()),
|
|
187
|
-
// POST /oauth2/consent. The signed authorization query is not passed
|
|
188
|
-
// here - the oauthProviderClient fetch plugin stamps it onto the body as
|
|
189
|
-
// oauth_query from window.location.search, so the call must run while
|
|
190
|
-
// the page still holds the query the oauth-provider redirect arrived
|
|
191
|
-
// with.
|
|
192
|
-
oauth2Consent: (params) => authClient.oauth2.consent(params),
|
|
193
|
-
// POST /oauth2/continue - resumes the authorization after the post-login
|
|
194
|
-
// organization choice (the page runs SetActiveOrganization first). Same
|
|
195
|
-
// oauth_query contract as oauth2Consent: the signed query rides from
|
|
196
|
-
// window.location.search, so the call must run before any navigation.
|
|
197
|
-
oauth2Continue: (params) => authClient.oauth2.continue(params),
|
|
198
|
-
// The one-time email code: send, then sign in with it.
|
|
199
|
-
emailOtpSend: (params) => authClient.emailOtp.sendVerificationOtp(params),
|
|
200
|
-
emailOtpVerify: (params) => authClient.signIn.emailOtp(params),
|
|
201
|
-
phoneNumberRequestPasswordReset: (params) =>
|
|
202
|
-
authClient.phoneNumber.requestPasswordReset(params),
|
|
203
|
-
phoneNumberResetPassword: (params) => authClient.phoneNumber.resetPassword(params),
|
|
204
|
-
phoneNumberSendOtp: (params) => authClient.phoneNumber.sendOtp(params),
|
|
205
|
-
phoneNumberVerify: (params) => authClient.phoneNumber.verify(params),
|
|
206
|
-
requestPasswordReset: (params) => authClient.requestPasswordReset(params),
|
|
207
|
-
resetPassword: (params) => authClient.resetPassword(params),
|
|
208
|
-
revokeOtherSessions: () => authClient.revokeOtherSessions(sessionScoped()),
|
|
209
|
-
sendVerificationEmail: (params) => authClient.sendVerificationEmail(params),
|
|
210
|
-
setActiveOrganization: (params) => authClient.organization.setActive(sessionScoped(params)),
|
|
211
|
-
signInEmail: (params) => authClient.signIn.email(params),
|
|
212
|
-
signInMagicLink: (params) => authClient.signIn.magicLink(params),
|
|
213
|
-
signInOauth2: (params) => authClient.signIn.oauth2(params),
|
|
214
|
-
signInPasskey: (params) => authClient.signIn.passkey(params),
|
|
215
|
-
signInPhoneNumber: (params) => authClient.signIn.phoneNumber(params),
|
|
216
|
-
signInSocial: (params) => authClient.signIn.social(params),
|
|
217
|
-
signOut: () => authClient.signOut(),
|
|
218
|
-
signUpEmail: (params) => authClient.signUp.email(params),
|
|
219
|
-
twoFactorDisable: (params) => authClient.twoFactor.disable(sessionScoped(params)),
|
|
220
|
-
twoFactorEnable: (params) => authClient.twoFactor.enable(sessionScoped(params)),
|
|
221
|
-
twoFactorGenerateBackupCodes: (params) =>
|
|
222
|
-
authClient.twoFactor.generateBackupCodes(sessionScoped(params)),
|
|
223
|
-
twoFactorVerifyBackupCode: (params) => authClient.twoFactor.verifyBackupCode(params),
|
|
224
|
-
twoFactorVerifyTotp: (params) => authClient.twoFactor.verifyTotp(params),
|
|
225
|
-
};
|
|
60
|
+
function AuthConfigured({ authConfig, children, serverSession }) {
|
|
61
|
+
const auth = { authConfig, getSession, signIn, signOut };
|
|
226
62
|
return (
|
|
227
|
-
<
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
63
|
+
<SessionProvider session={serverSession}>
|
|
64
|
+
<Session>
|
|
65
|
+
{(session) => {
|
|
66
|
+
auth.session = session;
|
|
67
|
+
return children(auth);
|
|
68
|
+
}}
|
|
69
|
+
</Session>
|
|
70
|
+
</SessionProvider>
|
|
234
71
|
);
|
|
235
72
|
}
|
|
236
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
|
import { ConfigError } from '@lowdefy/errors';
|
|
18
19
|
|
|
@@ -22,51 +23,17 @@ function authNotConfigured() {
|
|
|
22
23
|
);
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// the
|
|
28
|
-
|
|
26
|
+
// A dev mock user reaches the browser through this component: an app whose
|
|
27
|
+
// only auth key is auth.dev has no auth stack, but the dev server still
|
|
28
|
+
// resolves a session for it, so _user reads the same identity in the browser
|
|
29
|
+
// as on the server.
|
|
30
|
+
function AuthNotConfigured({ authConfig, children, session = null }) {
|
|
29
31
|
const auth = {
|
|
30
32
|
authConfig,
|
|
31
|
-
|
|
32
|
-
acceptInvitation: authNotConfigured,
|
|
33
|
-
addPasskey: authNotConfigured,
|
|
34
|
-
changePassword: authNotConfigured,
|
|
35
|
-
deletePasskey: authNotConfigured,
|
|
36
|
-
getResolvedUser: authNotConfigured,
|
|
33
|
+
session,
|
|
37
34
|
getSession: authNotConfigured,
|
|
38
|
-
|
|
39
|
-
listOrganizations: authNotConfigured,
|
|
40
|
-
oauth2Consent: authNotConfigured,
|
|
41
|
-
oauth2Continue: authNotConfigured,
|
|
42
|
-
emailOtpSend: authNotConfigured,
|
|
43
|
-
emailOtpVerify: authNotConfigured,
|
|
44
|
-
phoneNumberRequestPasswordReset: authNotConfigured,
|
|
45
|
-
phoneNumberResetPassword: authNotConfigured,
|
|
46
|
-
phoneNumberSendOtp: authNotConfigured,
|
|
47
|
-
phoneNumberVerify: authNotConfigured,
|
|
48
|
-
refreshSession: authNotConfigured,
|
|
49
|
-
requestPasswordReset: authNotConfigured,
|
|
50
|
-
resetPassword: authNotConfigured,
|
|
51
|
-
revokeOtherSessions: authNotConfigured,
|
|
52
|
-
sendVerificationEmail: authNotConfigured,
|
|
53
|
-
setActiveOrganization: authNotConfigured,
|
|
54
|
-
signInEmail: authNotConfigured,
|
|
55
|
-
signInMagicLink: authNotConfigured,
|
|
56
|
-
signInOauth2: authNotConfigured,
|
|
57
|
-
signInPasskey: authNotConfigured,
|
|
58
|
-
signInPhoneNumber: authNotConfigured,
|
|
59
|
-
signInSocial: authNotConfigured,
|
|
35
|
+
signIn: authNotConfigured,
|
|
60
36
|
signOut: authNotConfigured,
|
|
61
|
-
signUpEmail: authNotConfigured,
|
|
62
|
-
suppressSignOutReload: authNotConfigured,
|
|
63
|
-
twoFactorDisable: authNotConfigured,
|
|
64
|
-
twoFactorEnable: authNotConfigured,
|
|
65
|
-
twoFactorGenerateBackupCodes: authNotConfigured,
|
|
66
|
-
twoFactorVerifyBackupCode: authNotConfigured,
|
|
67
|
-
twoFactorVerifyTotp: authNotConfigured,
|
|
68
|
-
updatePasskey: authNotConfigured,
|
|
69
|
-
updateResolvedUser: authNotConfigured,
|
|
70
37
|
};
|
|
71
38
|
|
|
72
39
|
return children(auth);
|
|
@@ -30,7 +30,6 @@ function parseJsModule(text) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export async function fetchPageConfig(url) {
|
|
33
|
-
const basePath = url.replace(/\/api\/page\/.*$/, '');
|
|
34
33
|
// A stalled request (server restart mid-request, exhausted sockets) must
|
|
35
34
|
// become a visible error, never an eternal Suspense fallback — the reload
|
|
36
35
|
// recovery path cannot fire while the page tree is suspended.
|
|
@@ -51,20 +50,6 @@ export async function fetchPageConfig(url) {
|
|
|
51
50
|
if (res.status === 404) {
|
|
52
51
|
return null;
|
|
53
52
|
}
|
|
54
|
-
if (res.status === 401 || res.status === 403) {
|
|
55
|
-
// 401: logged-out navigation to a protected page. 403: authorised but second
|
|
56
|
-
// factor not yet enrolled. Page renders a redirect screen and full-loads to
|
|
57
|
-
// the destination so it can return here afterwards. Returning a settled value
|
|
58
|
-
// (never a parked promise) keeps the SWR key healthy: if the navigation is
|
|
59
|
-
// dropped, the tab still recovers on the next reload event or via the manual
|
|
60
|
-
// link on the redirect screen.
|
|
61
|
-
const { redirect } = await res.json();
|
|
62
|
-
const authRedirect = redirect ?? `${basePath}/404`;
|
|
63
|
-
console.warn(
|
|
64
|
-
`Lowdefy dev: "${url}" returned ${res.status} - redirecting to "${authRedirect}".`
|
|
65
|
-
);
|
|
66
|
-
return { authRedirect };
|
|
67
|
-
}
|
|
68
53
|
const data = await res.json();
|
|
69
54
|
if (data?.buildError) {
|
|
70
55
|
return data;
|
|
@@ -67,6 +67,7 @@ test('openPage injects the default roleless user when no user is given', async (
|
|
|
67
67
|
await openPage({ browser, origin: 'http://localhost:3001', pageId: 'home' });
|
|
68
68
|
|
|
69
69
|
expect(decodeUserCookie(addCookies)).toEqual({
|
|
70
|
+
sub: 'lowdefy-headless',
|
|
70
71
|
id: 'lowdefy-headless',
|
|
71
72
|
name: 'Lowdefy Headless',
|
|
72
73
|
roles: [],
|
|
@@ -84,6 +85,7 @@ test('openPage injects a per-call user with roles', async () => {
|
|
|
84
85
|
});
|
|
85
86
|
|
|
86
87
|
expect(decodeUserCookie(addCookies)).toEqual({
|
|
88
|
+
sub: 'lowdefy-headless',
|
|
87
89
|
id: 'agent',
|
|
88
90
|
name: 'Lowdefy Headless',
|
|
89
91
|
roles: ['user-admin'],
|
|
@@ -159,7 +159,7 @@ test('runEndpoint defaults the payload to an empty object', async () => {
|
|
|
159
159
|
});
|
|
160
160
|
|
|
161
161
|
test('runEndpoint passes the user to createLowdefyContext and logs the run', async () => {
|
|
162
|
-
const user = { roles: ['admin'],
|
|
162
|
+
const user = { roles: ['admin'], id: 'user_1' };
|
|
163
163
|
|
|
164
164
|
await runEndpoint({ endpointId: 'create_order', user, honoContext });
|
|
165
165
|
|
|
@@ -38,7 +38,6 @@ jest.unstable_mockModule('../server/createLowdefyContext.js', () => ({
|
|
|
38
38
|
default: mockCreateLowdefyContext,
|
|
39
39
|
}));
|
|
40
40
|
|
|
41
|
-
const { ConfigError } = await import('@lowdefy/errors');
|
|
42
41
|
const { default: runRequest } = await import('./runRequest.js');
|
|
43
42
|
|
|
44
43
|
const honoContext = { req: { path: '/lowdefy-docs/run-request' } };
|
|
@@ -57,7 +56,7 @@ beforeEach(() => {
|
|
|
57
56
|
});
|
|
58
57
|
|
|
59
58
|
test('runRequest passes a user object to createLowdefyContext', async () => {
|
|
60
|
-
const user = { roles: ['admin'],
|
|
59
|
+
const user = { roles: ['admin'], id: 'user_1' };
|
|
61
60
|
|
|
62
61
|
const result = await runRequest({
|
|
63
62
|
pageId: 'home',
|
|
@@ -84,10 +83,10 @@ test('runRequest logs the user it ran the request as', async () => {
|
|
|
84
83
|
});
|
|
85
84
|
});
|
|
86
85
|
|
|
87
|
-
test('runRequest throws
|
|
86
|
+
test('runRequest throws when user is not an object', async () => {
|
|
88
87
|
await expect(
|
|
89
88
|
runRequest({ pageId: 'home', requestId: 'get_rows', user: 'admin', honoContext })
|
|
90
|
-
).rejects.toThrow(
|
|
89
|
+
).rejects.toThrow(/run_request "user" must be an object/);
|
|
91
90
|
await expect(
|
|
92
91
|
runRequest({ pageId: 'home', requestId: 'get_rows', user: ['admin'], honoContext })
|
|
93
92
|
).rejects.toThrow(/run_request "user" must be an object/);
|
|
@@ -14,27 +14,25 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import { getAuthConfig as getApiAuthConfig } from '@lowdefy/api';
|
|
18
18
|
import { getSecretsFromEnv } from '@lowdefy/node-utils';
|
|
19
19
|
|
|
20
|
+
import adapters from '../../../build/plugins/auth/adapters.js';
|
|
20
21
|
import appMeta from '../../build/appMeta.js';
|
|
21
22
|
import authJson from '../../build/auth.js';
|
|
22
|
-
import
|
|
23
|
+
import callbacks from '../../../build/plugins/auth/callbacks.js';
|
|
24
|
+
import events from '../../../build/plugins/auth/events.js';
|
|
25
|
+
import providers from '../../../build/plugins/auth/providers.js';
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
// in config order when no session resolves.
|
|
27
|
-
function getStrategies({ logger }) {
|
|
28
|
-
if (authJson.configured !== true) {
|
|
29
|
-
return [];
|
|
30
|
-
}
|
|
31
|
-
return getAuthStrategies({
|
|
27
|
+
function getAuthConfig({ logger }) {
|
|
28
|
+
return getApiAuthConfig({
|
|
32
29
|
appMeta,
|
|
33
30
|
authJson,
|
|
31
|
+
dev: true,
|
|
34
32
|
logger,
|
|
35
|
-
plugins: {
|
|
33
|
+
plugins: { adapters, callbacks, events, providers },
|
|
36
34
|
secrets: getSecretsFromEnv(),
|
|
37
35
|
});
|
|
38
36
|
}
|
|
39
37
|
|
|
40
|
-
export default
|
|
38
|
+
export default getAuthConfig;
|
|
@@ -0,0 +1,70 @@
|
|
|
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 { createSessionCallback } from '@lowdefy/api';
|
|
18
|
+
|
|
19
|
+
import authJson from '../../build/auth.js';
|
|
20
|
+
import callbacks from '../../../build/plugins/auth/callbacks.js';
|
|
21
|
+
import getHeadlessUser from './getHeadlessUser.js';
|
|
22
|
+
import getMockUser from './getMockUser.js';
|
|
23
|
+
|
|
24
|
+
// THE single source of dev sessions (mock user, headless renderer). Two
|
|
25
|
+
// invariants live here, by construction rather than by convention:
|
|
26
|
+
//
|
|
27
|
+
// 1. Prod parity: the dev user runs through the identical Auth.js session
|
|
28
|
+
// callback a real sign-in uses (OIDC claim copy, userFields mapping,
|
|
29
|
+
// custom session-callback plugins, roles validation, hashed_id) — so what
|
|
30
|
+
// a mock or headless session sees is exactly what a real authenticated
|
|
31
|
+
// user would see in production, and dev validates prod behaviour.
|
|
32
|
+
// 2. Client/server parity: every consumer — the server request context
|
|
33
|
+
// (session.js) and the browser client's GET /api/auth/session
|
|
34
|
+
// (routes/auth.js) — obtains the session from this one function, so the
|
|
35
|
+
// two can never diverge.
|
|
36
|
+
//
|
|
37
|
+
// A dev user needs no auth stack behind it: an app whose only auth key is
|
|
38
|
+
// auth.dev is not an auth configuration (auth.configured is false), and its
|
|
39
|
+
// mock user is still the caller here and in the browser.
|
|
40
|
+
//
|
|
41
|
+
// Auth.js sessions carry an expires timestamp (the client SessionProvider
|
|
42
|
+
// schedules refetches off it); dev sessions never expire while the server
|
|
43
|
+
// runs, a day keeps polling calm.
|
|
44
|
+
const DEV_SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
45
|
+
|
|
46
|
+
// An explicit `user` is a per-call caller (createLowdefyContext's `user`
|
|
47
|
+
// option, used by the headless agent tools): it wins over the ambient mock
|
|
48
|
+
// user and headless cookie, and runs through the same session pipeline.
|
|
49
|
+
async function getDevSession(c, { user: injectedUser } = {}) {
|
|
50
|
+
const user = injectedUser ?? getMockUser() ?? getHeadlessUser(c);
|
|
51
|
+
if (!user) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const sessionCallback = createSessionCallback({
|
|
55
|
+
authConfig: authJson,
|
|
56
|
+
plugins: { callbacks },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// The dev user acts as both token and user, exactly how a decoded JWT
|
|
60
|
+
// reaches the session callback on a real request.
|
|
61
|
+
const session = await sessionCallback({
|
|
62
|
+
session: { user: {} },
|
|
63
|
+
token: user,
|
|
64
|
+
user,
|
|
65
|
+
});
|
|
66
|
+
session.expires = new Date(Date.now() + DEV_SESSION_MAX_AGE_MS).toISOString();
|
|
67
|
+
return session;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default getDevSession;
|