@lowdefy/server 6.1.0 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/App.jsx +41 -21
- package/client/Page.jsx +90 -26
- package/client/clearReloadedForTypes.js +29 -0
- package/client/clientBuildImports.test.mjs +6 -7
- package/client/getShownPage.js +28 -0
- package/client/getShownPage.test.mjs +52 -0
- package/client/loadAllIcons.js +45 -0
- package/client/loadPageTypes.js +44 -0
- package/client/main.jsx +22 -1
- package/client/reloadedForTypesKey.js +21 -0
- package/client/shouldReloadForTypes.js +38 -0
- package/client/shouldReloadForTypes.test.mjs +63 -0
- package/client/types.js +28 -0
- package/lib/build/security.js +26 -0
- package/lib/client/auth/Auth.jsx +11 -6
- package/lib/client/auth/AuthConfigured.jsx +222 -36
- package/lib/client/auth/AuthNotConfigured.js +44 -4
- package/lib/client/auth/createFetchCheckingBuild.js +45 -0
- package/lib/client/auth/createFetchCheckingBuild.test.mjs +100 -0
- package/lib/client/bundleBuildId.js +23 -0
- package/lib/client/createLogUsage.js +46 -25
- package/lib/client/createLogUsage.test.mjs +90 -0
- package/lib/client/getCspNonce.js +25 -0
- package/lib/server/auth/createSystemContext.js +70 -0
- package/lib/server/auth/{getAuthConfig.js → getAuth.js} +15 -9
- package/lib/server/auth/getStrategies.js +40 -0
- package/lib/server/getClientAddress.js +56 -0
- package/lib/server/getClientAddress.test.mjs +108 -0
- package/lib/server/log/createHandleError.js +7 -0
- package/lib/server/log/createHandleError.test.mjs +17 -0
- package/lib/server/log/createLogger.test.mjs +15 -0
- package/lib/server/log/logRequest.js +5 -3
- package/lib/server/log/projectErrorForLog.js +6 -0
- package/lib/server/log/serializeErrorForLog.test.mjs +13 -1
- package/lib/server/sentry/captureSentryError.js +17 -3
- package/lib/server/sentry/captureSentryError.test.mjs +37 -13
- package/lib/server/sentry/initSentry.js +39 -18
- package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
- package/lib/server/sentry/initSentry.test.mjs +132 -4
- package/lib/server/sentry/scrubEvent.js +12 -1
- package/lib/server/sentry/sentrySdk.js +23 -0
- package/lib/server/sentry/setSentryUser.js +3 -3
- package/lowdefy/build.mjs +2 -0
- package/lowdefy/build.test.mjs +88 -0
- package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
- package/package.json +42 -36
- package/package.original.json +42 -36
- package/src/app.js +74 -17
- package/src/app.test.mjs +118 -0
- package/src/html/collectChunkClosure.js +31 -0
- package/src/html/collectLazyImports.js +41 -0
- package/src/html/collectPageTypesAssets.js +57 -0
- package/src/html/collectPageTypesAssets.test.mjs +130 -0
- package/src/html/getAssets.js +20 -1
- package/src/html/getHomePath.js +29 -0
- package/src/html/getPageAssets.js +32 -0
- package/src/html/getPageAssets.test.mjs +47 -0
- package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
- package/src/html/renderPage.js +80 -19
- package/src/html/renderPage.test.mjs +189 -0
- package/src/html/template.js +50 -15
- package/src/html/template.test.mjs +126 -0
- package/src/index.js +28 -29
- package/src/initServer.js +61 -0
- package/src/initServer.test.mjs +116 -0
- package/src/lib/getRequestPath.js +29 -0
- package/src/middleware/apiContext.js +65 -13
- package/src/middleware/apiContext.test.mjs +66 -6
- package/src/middleware/createSameOriginGuard.js +36 -0
- package/src/middleware/createSameOriginGuard.test.mjs +102 -0
- package/src/middleware/errorHandler.js +56 -4
- package/src/middleware/errorHandler.test.mjs +440 -0
- package/{lib/server/auth/session.js → src/middleware/noStore.js} +9 -13
- package/src/middleware/refuseOtherBuild.js +49 -0
- package/src/middleware/refuseOtherBuild.test.mjs +63 -0
- package/src/middleware/requestTimeout.js +38 -0
- package/src/middleware/sentry.js +13 -5
- package/src/middleware/stampBuildId.js +30 -0
- package/src/middleware/stampBuildId.test.mjs +41 -0
- package/src/middleware/stampCaller.js +38 -0
- package/src/middleware/stampCaller.test.mjs +64 -0
- package/src/routes/agent.js +15 -3
- package/src/routes/apiPage.js +51 -9
- package/src/routes/apiPage.test.mjs +99 -15
- package/src/routes/auth.js +15 -9
- package/src/routes/auth.test.mjs +85 -0
- package/src/routes/clientError.js +11 -13
- package/src/routes/cron.js +3 -1
- package/src/routes/cronForward.js +3 -1
- package/src/routes/detached.js +22 -9
- package/src/routes/endpoints.js +27 -26
- package/src/routes/endpointsTextAnswer.test.mjs +77 -0
- package/src/routes/mcp.js +82 -5
- package/src/routes/mcp.test.mjs +215 -0
- package/src/routes/mcpMemberToken.test.mjs +125 -0
- package/src/routes/mcpProtectedResourceMetadata.js +34 -0
- package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
- package/src/routes/mountOauthDiscovery.js +44 -0
- package/src/routes/mountOauthDiscovery.test.mjs +79 -0
- package/src/routes/mountPageRoutes.js +29 -0
- package/src/routes/mountPageRoutes.test.mjs +56 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
- package/src/routes/request.js +5 -3
- package/src/routes/requestBody.test.mjs +146 -0
- package/src/routes/usage.js +6 -2
- package/src/routes/user.js +27 -0
- package/src/routes/websocket.js +26 -3
- package/src/routes/websocket.test.mjs +106 -0
- package/src/routes/wellKnownFallback.js +30 -0
- package/vite.config.js +55 -2
- package/client/shouldReloadForBuild.js +0 -48
- package/client/shouldReloadForBuild.test.mjs +0 -75
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import clearReloadedForTypes from './clearReloadedForTypes.js';
|
|
18
|
+
import shouldReloadForTypes from './shouldReloadForTypes.js';
|
|
19
|
+
|
|
20
|
+
function createWindow({ href = 'https://example.com/orders', throws = false } = {}) {
|
|
21
|
+
const stored = {};
|
|
22
|
+
return {
|
|
23
|
+
location: { href },
|
|
24
|
+
sessionStorage: {
|
|
25
|
+
getItem: (key) => {
|
|
26
|
+
if (throws) throw new Error('storage disabled');
|
|
27
|
+
return stored[key] ?? null;
|
|
28
|
+
},
|
|
29
|
+
setItem: (key, value) => {
|
|
30
|
+
if (throws) throw new Error('storage disabled');
|
|
31
|
+
stored[key] = value;
|
|
32
|
+
},
|
|
33
|
+
removeItem: (key) => {
|
|
34
|
+
delete stored[key];
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('shouldReloadForTypes reloads once, renders on the second failure, then may retry', () => {
|
|
41
|
+
const window = createWindow();
|
|
42
|
+
expect(shouldReloadForTypes({ window })).toBe(true);
|
|
43
|
+
expect(shouldReloadForTypes({ window })).toBe(false);
|
|
44
|
+
expect(shouldReloadForTypes({ window })).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('shouldReloadForTypes reloads again at a different URL', () => {
|
|
48
|
+
const window = createWindow();
|
|
49
|
+
expect(shouldReloadForTypes({ window })).toBe(true);
|
|
50
|
+
window.location.href = 'https://example.com/invoices';
|
|
51
|
+
expect(shouldReloadForTypes({ window })).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('shouldReloadForTypes does not reload when storage is disabled', () => {
|
|
55
|
+
expect(shouldReloadForTypes({ window: createWindow({ throws: true }) })).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('shouldReloadForTypes reloads again at a URL whose reload once loaded its types', () => {
|
|
59
|
+
const window = createWindow();
|
|
60
|
+
expect(shouldReloadForTypes({ window })).toBe(true);
|
|
61
|
+
clearReloadedForTypes({ window });
|
|
62
|
+
expect(shouldReloadForTypes({ window })).toBe(true);
|
|
63
|
+
});
|
package/client/types.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// The client resolves blocks, actions, operators and icons from these objects
|
|
18
|
+
// when it renders and runs events (initLowdefyContext keeps the references), so
|
|
19
|
+
// loadPageTypes merges each page's types into them in place and one set of
|
|
20
|
+
// registries serves every page the tab visits.
|
|
21
|
+
const types = {
|
|
22
|
+
actions: {},
|
|
23
|
+
blocks: {},
|
|
24
|
+
icons: {},
|
|
25
|
+
operators: {},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default types;
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
// Build artifacts are read from disk because the Hono server runs as
|
|
18
|
+
// unbundled Node.js ESM — JSON imports would need import attributes, and
|
|
19
|
+
// client code imports the build JSON directly through Vite instead.
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { serializer } from '@lowdefy/helpers';
|
|
23
|
+
|
|
24
|
+
const raw = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'build/security.json'), 'utf8'));
|
|
25
|
+
|
|
26
|
+
export default serializer.deserialize(raw);
|
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,20 +23,27 @@ 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
|
);
|
|
40
38
|
}
|
|
41
|
-
|
|
39
|
+
// An app whose only auth key is auth.dev has no auth stack, but the dev
|
|
40
|
+
// server still resolves a caller for auth.dev.mockUser - pass it through so
|
|
41
|
+
// _user reads the same identity in the browser as on the server.
|
|
42
|
+
return (
|
|
43
|
+
<AuthNotConfigured authConfig={authConfig} user={user}>
|
|
44
|
+
{(auth) => children(auth)}
|
|
45
|
+
</AuthNotConfigured>
|
|
46
|
+
);
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
export default Auth;
|
|
@@ -14,59 +14,245 @@
|
|
|
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
|
+
emailOTPClient,
|
|
22
|
+
magicLinkClient,
|
|
23
|
+
organizationClient,
|
|
24
|
+
phoneNumberClient,
|
|
25
|
+
twoFactorClient,
|
|
26
|
+
} from 'better-auth/client/plugins';
|
|
27
|
+
import { oauthProviderClient } from '@better-auth/oauth-provider/client';
|
|
28
|
+
import { passkeyClient } from '@better-auth/passkey/client';
|
|
29
|
+
|
|
30
|
+
import claimCallerReload from '@lowdefy/client/claimCallerReload.js';
|
|
31
|
+
import { normalizeCaller } from '@lowdefy/helpers';
|
|
32
|
+
|
|
33
|
+
import bundleBuildId from '../bundleBuildId.js';
|
|
34
|
+
import createFetchCheckingBuild from './createFetchCheckingBuild.js';
|
|
28
35
|
|
|
29
36
|
// The app basePath, from Vite's BASE_URL (`${config.basePath}/`, set in
|
|
30
37
|
// vite.config.js). build/config.json is server-only - it carries every
|
|
31
38
|
// deployment environment's settings - so it is never imported into the client.
|
|
32
39
|
const basePath = import.meta.env.BASE_URL.replace(/\/$/, '');
|
|
33
40
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
if (basePath) {
|
|
37
|
-
authConfigManager.setConfig({ basePath: `${basePath}/api/auth` });
|
|
38
|
-
}
|
|
41
|
+
// Reloads a tab whose auth call fails against a newer build.
|
|
42
|
+
const fetchCheckingBuild = createFetchCheckingBuild({ buildId: bundleBuildId });
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
// GenericOAuth providers have no client plugin here. BetterAuth 1.7.0 dropped
|
|
45
|
+
// genericOAuthClient, and the server plugin's own routes with it: a generic
|
|
46
|
+
// provider is registered as a social provider keyed by its providerId, so it
|
|
47
|
+
// signs in through signIn.social and returns on /callback/:id like any
|
|
48
|
+
// built-in one. The client's path proxy would still turn a signIn.oauth2 call
|
|
49
|
+
// into POST /sign-in/oauth2, a route no 1.7 server serves.
|
|
50
|
+
const authClient = createAuthClient({
|
|
51
|
+
baseURL: `${window.location.origin}${basePath}/api/auth`,
|
|
52
|
+
fetchOptions: { customFetchImpl: fetchCheckingBuild },
|
|
53
|
+
plugins: [
|
|
54
|
+
adminClient(),
|
|
55
|
+
emailOTPClient(),
|
|
56
|
+
magicLinkClient(),
|
|
57
|
+
oauthProviderClient(),
|
|
58
|
+
organizationClient(),
|
|
59
|
+
passkeyClient(),
|
|
60
|
+
phoneNumberClient(),
|
|
61
|
+
twoFactorClient(),
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// UpdateSession owns session freshness - suppress BetterAuth's own
|
|
66
|
+
// signal-driven refetch on the calls that would otherwise fire one, so
|
|
67
|
+
// nothing competes with (and aborts) the refetch UpdateSession awaits.
|
|
68
|
+
// Only calls that mutate an existing session are suppressed - calls that
|
|
69
|
+
// can establish one (the sign-ins, and the two-factor, phone-number,
|
|
70
|
+
// email-otp and passkey verifies) keep their signal: its refetch is what makes the store
|
|
71
|
+
// notice a login when no navigation follows.
|
|
72
|
+
// disableSignal silences all atomListeners for the call, including the
|
|
73
|
+
// organization plugin's org atoms - none are exposed to app config, so a
|
|
74
|
+
// future design exposing one must refresh it through its own action.
|
|
75
|
+
const sessionScoped = (params) => ({
|
|
76
|
+
...params,
|
|
77
|
+
fetchOptions: { ...params?.fetchOptions, disableSignal: true },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// The server resolves the caller per request and embeds it in the page
|
|
81
|
+
// config, so the first render never flashes unauthenticated. The BetterAuth
|
|
82
|
+
// client store takes over once its session fetch settles.
|
|
83
|
+
//
|
|
84
|
+
// Roles and merged attributes resolve server-side from the active member row
|
|
85
|
+
// - the base session carries neither. The last server-resolved caller is
|
|
86
|
+
// held in state: while the session user is unchanged it stays authoritative
|
|
87
|
+
// for the fields the session does not carry, and UpdateSession refreshes it
|
|
88
|
+
// from /api/user after a change (e.g. SetActiveOrganization). Holding it in
|
|
89
|
+
// state, not a ref, re-renders Session when it changes so _user recomputes -
|
|
90
|
+
// the same reactive footing the session half already stands on.
|
|
91
|
+
//
|
|
92
|
+
// Only the session half is passed through normalizeCaller. BetterAuth's store
|
|
93
|
+
// returns camelCase keys whatever the columns are named, so untransformed it
|
|
94
|
+
// would sit emailVerified beside the resolved caller's email_verified on one
|
|
95
|
+
// object; anything arriving from the server is already snake_case.
|
|
96
|
+
function Session({ children, reloadSuppressedRef, serverUser }) {
|
|
97
|
+
const { data: session, isPending } = authClient.useSession();
|
|
98
|
+
const [resolvedUser, setResolvedUser] = useState(serverUser);
|
|
99
|
+
const wasAuthenticated = useRef(Boolean(serverUser));
|
|
44
100
|
|
|
45
101
|
useEffect(() => {
|
|
46
|
-
if (
|
|
102
|
+
if (session) {
|
|
103
|
+
wasAuthenticated.current = true;
|
|
104
|
+
}
|
|
105
|
+
// An auth call that navigates away (a logout or sign-in with a callback)
|
|
106
|
+
// suppresses both reloads below - its own navigation would otherwise race
|
|
107
|
+
// them.
|
|
108
|
+
if (reloadSuppressedRef.current) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// Reload after sign-out (or session revocation) so the server can apply
|
|
112
|
+
// the page auth fork - a protected page redirects to the login page.
|
|
113
|
+
if (wasAuthenticated.current && !isPending && !session) {
|
|
114
|
+
window.location.reload();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// The session now names another person than the caller this page renders
|
|
118
|
+
// (a sign-in as someone else, here or in another tab). Reload so the server
|
|
119
|
+
// renders the page for the new person, rather than keep showing the
|
|
120
|
+
// previous person's page and data. At most once per reload interval
|
|
121
|
+
// (claimCallerReload): past it the previous caller stays rendered, and its
|
|
122
|
+
// requests fail when the server answers as the new person.
|
|
123
|
+
if (
|
|
124
|
+
session?.user &&
|
|
125
|
+
resolvedUser &&
|
|
126
|
+
session.user.id !== resolvedUser.id &&
|
|
127
|
+
claimCallerReload({ window })
|
|
128
|
+
) {
|
|
47
129
|
window.location.reload();
|
|
48
130
|
}
|
|
49
|
-
}, [
|
|
131
|
+
}, [session, isPending, resolvedUser]);
|
|
50
132
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
133
|
+
if (isPending) {
|
|
134
|
+
return children(resolvedUser, setResolvedUser);
|
|
135
|
+
}
|
|
136
|
+
if (!session?.user) {
|
|
137
|
+
return children(null, setResolvedUser);
|
|
138
|
+
}
|
|
139
|
+
const resolved = resolvedUser;
|
|
140
|
+
if (!resolved) {
|
|
141
|
+
return children({ roles: [], ...normalizeCaller(session.user) }, setResolvedUser);
|
|
142
|
+
}
|
|
143
|
+
if (resolved.id !== session.user.id) {
|
|
144
|
+
// The session names another person than the resolved caller; the effect
|
|
145
|
+
// above reloads the page. Until it unloads, render the last resolved
|
|
146
|
+
// caller whole rather than mixing the new session user with the previous
|
|
147
|
+
// person's resolved fields, or falling back to a caller with no roles -
|
|
148
|
+
// blocks re-evaluate operators on render, so a role-guarded page would act
|
|
149
|
+
// on an empty role list.
|
|
150
|
+
return children(resolved, setResolvedUser);
|
|
55
151
|
}
|
|
56
|
-
|
|
152
|
+
// _user is one object with one meaning on both sides: the resolved caller is
|
|
153
|
+
// spread whole, so every field resolveAuthentication emits is readable in app
|
|
154
|
+
// config without this file naming it. session.user is the floor - the client
|
|
155
|
+
// store may hold a fresher one than the resolved state, and the resolved
|
|
156
|
+
// object wins wherever both carry a key.
|
|
157
|
+
const user = { ...normalizeCaller(session.user), ...resolved };
|
|
158
|
+
return children(user, setResolvedUser);
|
|
57
159
|
}
|
|
58
160
|
|
|
59
|
-
function AuthConfigured({ authConfig, children,
|
|
60
|
-
const
|
|
161
|
+
function AuthConfigured({ authConfig, children, serverUser }) {
|
|
162
|
+
const reloadSuppressedRef = useRef(false);
|
|
163
|
+
const auth = {
|
|
164
|
+
authConfig,
|
|
165
|
+
getSession: ({ disableCookieCache } = {}) =>
|
|
166
|
+
authClient.getSession(disableCookieCache ? { query: { disableCookieCache: true } } : {}),
|
|
167
|
+
// Refetches the session through the session atom - the store's only
|
|
168
|
+
// writer - and resolves only after the store is written. fetchSession
|
|
169
|
+
// returns undefined and never rejects (failures are written into the
|
|
170
|
+
// atom), so the atom's own { data, error } is read back and returned,
|
|
171
|
+
// keeping unwrap working and surfacing a failed refresh to the action.
|
|
172
|
+
refreshSession: async ({ disableCookieCache } = {}) => {
|
|
173
|
+
const sessionAtom = authClient.$store.atoms.session;
|
|
174
|
+
await sessionAtom
|
|
175
|
+
.get()
|
|
176
|
+
.refetch(disableCookieCache ? { query: { disableCookieCache: true } } : undefined);
|
|
177
|
+
const { data, error } = sessionAtom.get();
|
|
178
|
+
return { data, error };
|
|
179
|
+
},
|
|
180
|
+
// The server-resolved caller - roles from the active member row and the
|
|
181
|
+
// merged attributes bag - for re-syncing after session changes.
|
|
182
|
+
getResolvedUser: async () => {
|
|
183
|
+
const response = await fetchCheckingBuild(`${basePath}/api/user`, {
|
|
184
|
+
credentials: 'same-origin',
|
|
185
|
+
});
|
|
186
|
+
if (!response.ok) {
|
|
187
|
+
// /api/user itself always answers 200 { user } - user: null when no
|
|
188
|
+
// caller is admitted, which updateSession branches on. A non-OK
|
|
189
|
+
// response is a transport failure (gateway error page, 5xx) with no
|
|
190
|
+
// parsable body.
|
|
191
|
+
throw new Error(`Failed to fetch the resolved user (HTTP ${response.status}).`);
|
|
192
|
+
}
|
|
193
|
+
return response.json();
|
|
194
|
+
},
|
|
195
|
+
suppressSessionReload: () => {
|
|
196
|
+
reloadSuppressedRef.current = true;
|
|
197
|
+
},
|
|
198
|
+
acceptInvitation: (params) => authClient.organization.acceptInvitation(sessionScoped(params)),
|
|
199
|
+
// addPasskey runs the WebAuthn browser ceremony itself - options fetch,
|
|
200
|
+
// authenticator prompt, verification.
|
|
201
|
+
addPasskey: (params) => authClient.passkey.addPasskey(params),
|
|
202
|
+
changePassword: (params) => authClient.changePassword(sessionScoped(params)),
|
|
203
|
+
deletePasskey: (params) => authClient.passkey.deletePasskey(params),
|
|
204
|
+
updatePasskey: (params) => authClient.passkey.updatePasskey(params),
|
|
205
|
+
leaveOrganization: (params) => authClient.organization.leave(sessionScoped(params)),
|
|
206
|
+
// The caller's memberships from the org plugin, keyed on the session
|
|
207
|
+
// alone - not the active organization. sessionScoped so the list read
|
|
208
|
+
// does not also fire the org-list atom's own refetch of the same route.
|
|
209
|
+
listOrganizations: () => authClient.organization.list(sessionScoped()),
|
|
210
|
+
// POST /oauth2/consent. The signed authorization query is not passed
|
|
211
|
+
// here - the oauthProviderClient fetch plugin stamps it onto the body as
|
|
212
|
+
// oauth_query from window.location.search, so the call must run while
|
|
213
|
+
// the page still holds the query the oauth-provider redirect arrived
|
|
214
|
+
// with.
|
|
215
|
+
oauth2Consent: (params) => authClient.oauth2.consent(params),
|
|
216
|
+
// POST /oauth2/continue - resumes the authorization after the post-login
|
|
217
|
+
// organization choice (the page runs SetActiveOrganization first). Same
|
|
218
|
+
// oauth_query contract as oauth2Consent: the signed query rides from
|
|
219
|
+
// window.location.search, so the call must run before any navigation.
|
|
220
|
+
oauth2Continue: (params) => authClient.oauth2.continue(params),
|
|
221
|
+
// The one-time email code: send, then sign in with it.
|
|
222
|
+
emailOtpSend: (params) => authClient.emailOtp.sendVerificationOtp(params),
|
|
223
|
+
emailOtpVerify: (params) => authClient.signIn.emailOtp(params),
|
|
224
|
+
phoneNumberRequestPasswordReset: (params) =>
|
|
225
|
+
authClient.phoneNumber.requestPasswordReset(params),
|
|
226
|
+
phoneNumberResetPassword: (params) => authClient.phoneNumber.resetPassword(params),
|
|
227
|
+
phoneNumberSendOtp: (params) => authClient.phoneNumber.sendOtp(params),
|
|
228
|
+
phoneNumberVerify: (params) => authClient.phoneNumber.verify(params),
|
|
229
|
+
requestPasswordReset: (params) => authClient.requestPasswordReset(params),
|
|
230
|
+
resetPassword: (params) => authClient.resetPassword(params),
|
|
231
|
+
revokeOtherSessions: () => authClient.revokeOtherSessions(sessionScoped()),
|
|
232
|
+
sendVerificationEmail: (params) => authClient.sendVerificationEmail(params),
|
|
233
|
+
setActiveOrganization: (params) => authClient.organization.setActive(sessionScoped(params)),
|
|
234
|
+
signInEmail: (params) => authClient.signIn.email(params),
|
|
235
|
+
signInMagicLink: (params) => authClient.signIn.magicLink(params),
|
|
236
|
+
signInPasskey: (params) => authClient.signIn.passkey(params),
|
|
237
|
+
signInPhoneNumber: (params) => authClient.signIn.phoneNumber(params),
|
|
238
|
+
signInSocial: (params) => authClient.signIn.social(params),
|
|
239
|
+
signOut: () => authClient.signOut(),
|
|
240
|
+
signUpEmail: (params) => authClient.signUp.email(params),
|
|
241
|
+
twoFactorDisable: (params) => authClient.twoFactor.disable(sessionScoped(params)),
|
|
242
|
+
twoFactorEnable: (params) => authClient.twoFactor.enable(sessionScoped(params)),
|
|
243
|
+
twoFactorGenerateBackupCodes: (params) =>
|
|
244
|
+
authClient.twoFactor.generateBackupCodes(sessionScoped(params)),
|
|
245
|
+
twoFactorVerifyBackupCode: (params) => authClient.twoFactor.verifyBackupCode(params),
|
|
246
|
+
twoFactorVerifyTotp: (params) => authClient.twoFactor.verifyTotp(params),
|
|
247
|
+
};
|
|
61
248
|
return (
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
</SessionProvider>
|
|
249
|
+
<Session reloadSuppressedRef={reloadSuppressedRef} serverUser={serverUser}>
|
|
250
|
+
{(user, setResolvedUser) => {
|
|
251
|
+
auth.user = user;
|
|
252
|
+
auth.updateResolvedUser = (resolved) => setResolvedUser(resolved);
|
|
253
|
+
return children(auth);
|
|
254
|
+
}}
|
|
255
|
+
</Session>
|
|
70
256
|
);
|
|
71
257
|
}
|
|
72
258
|
|
|
@@ -14,18 +14,58 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
import { ConfigError } from '@lowdefy/errors';
|
|
18
18
|
|
|
19
19
|
function authNotConfigured() {
|
|
20
|
-
throw new
|
|
20
|
+
throw new ConfigError(
|
|
21
|
+
'Auth is not configured. Add an "auth" section to lowdefy.yaml to use auth actions.'
|
|
22
|
+
);
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
// Every key AuthConfigured exposes must be present here, or an auth action in an
|
|
26
|
+
// app without an auth section fails with "auth.x is not a function" instead of
|
|
27
|
+
// the ConfigError that names the missing config.
|
|
28
|
+
function AuthNotConfigured({ authConfig, children, user = null }) {
|
|
24
29
|
const auth = {
|
|
25
30
|
authConfig,
|
|
31
|
+
user,
|
|
32
|
+
acceptInvitation: authNotConfigured,
|
|
33
|
+
addPasskey: authNotConfigured,
|
|
34
|
+
changePassword: authNotConfigured,
|
|
35
|
+
deletePasskey: authNotConfigured,
|
|
36
|
+
getResolvedUser: authNotConfigured,
|
|
26
37
|
getSession: authNotConfigured,
|
|
27
|
-
|
|
38
|
+
leaveOrganization: authNotConfigured,
|
|
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
|
+
signInPasskey: authNotConfigured,
|
|
57
|
+
signInPhoneNumber: authNotConfigured,
|
|
58
|
+
signInSocial: authNotConfigured,
|
|
28
59
|
signOut: authNotConfigured,
|
|
60
|
+
signUpEmail: authNotConfigured,
|
|
61
|
+
suppressSessionReload: authNotConfigured,
|
|
62
|
+
twoFactorDisable: authNotConfigured,
|
|
63
|
+
twoFactorEnable: authNotConfigured,
|
|
64
|
+
twoFactorGenerateBackupCodes: authNotConfigured,
|
|
65
|
+
twoFactorVerifyBackupCode: authNotConfigured,
|
|
66
|
+
twoFactorVerifyTotp: authNotConfigured,
|
|
67
|
+
updatePasskey: authNotConfigured,
|
|
68
|
+
updateResolvedUser: authNotConfigured,
|
|
29
69
|
};
|
|
30
70
|
|
|
31
71
|
return children(auth);
|
|
@@ -0,0 +1,45 @@
|
|
|
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 shouldReloadForBuild from '@lowdefy/client/shouldReloadForBuild.js';
|
|
18
|
+
|
|
19
|
+
// fetch for the auth client and /api/user. When an auth call fails and the
|
|
20
|
+
// server's x-lowdefy-build (src/middleware/stampBuildId.js) shows a newer build
|
|
21
|
+
// answered, the route may have been renamed or removed since this bundle
|
|
22
|
+
// loaded, so the tab reloads once onto the current build. The promise never
|
|
23
|
+
// settles: the page is unloading, and a rejection would surface as an auth
|
|
24
|
+
// error. A successful call from a newer build carries on; the next navigation
|
|
25
|
+
// reloads the tab (client/Page.jsx). buildId is the build this bundle was made
|
|
26
|
+
// from.
|
|
27
|
+
function createFetchCheckingBuild({ buildId }) {
|
|
28
|
+
return async function fetchCheckingBuild(input, init) {
|
|
29
|
+
const response = await fetch(input, init);
|
|
30
|
+
if (
|
|
31
|
+
!response.ok &&
|
|
32
|
+
shouldReloadForBuild({
|
|
33
|
+
bundleBuildId: buildId,
|
|
34
|
+
serverBuildId: response.headers.get('x-lowdefy-build'),
|
|
35
|
+
window,
|
|
36
|
+
})
|
|
37
|
+
) {
|
|
38
|
+
window.location.reload();
|
|
39
|
+
return new Promise(() => {});
|
|
40
|
+
}
|
|
41
|
+
return response;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default createFetchCheckingBuild;
|
|
@@ -0,0 +1,100 @@
|
|
|
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 { jest } from '@jest/globals';
|
|
18
|
+
|
|
19
|
+
import createFetchCheckingBuild from './createFetchCheckingBuild.js';
|
|
20
|
+
|
|
21
|
+
const fetchCheckingBuild = createFetchCheckingBuild({ buildId: 'build-1' });
|
|
22
|
+
|
|
23
|
+
function mockFetch({ status, buildId }) {
|
|
24
|
+
const response = {
|
|
25
|
+
ok: status >= 200 && status < 300,
|
|
26
|
+
status,
|
|
27
|
+
headers: new Headers(buildId ? { 'x-lowdefy-build': buildId } : {}),
|
|
28
|
+
};
|
|
29
|
+
global.fetch = jest.fn(() => Promise.resolve(response));
|
|
30
|
+
return response;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createWindow() {
|
|
34
|
+
const stored = {};
|
|
35
|
+
return {
|
|
36
|
+
location: { reload: jest.fn() },
|
|
37
|
+
sessionStorage: {
|
|
38
|
+
getItem: (key) => stored[key] ?? null,
|
|
39
|
+
setItem: (key, value) => {
|
|
40
|
+
stored[key] = value;
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
global.window = createWindow();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
delete global.fetch;
|
|
52
|
+
delete global.window;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
async function settles(promise) {
|
|
56
|
+
const marker = Symbol('pending');
|
|
57
|
+
const result = await Promise.race([promise, new Promise((r) => setTimeout(() => r(marker), 20))]);
|
|
58
|
+
return result !== marker;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test('fetchCheckingBuild reloads and never settles when an auth call fails against a newer build', async () => {
|
|
62
|
+
mockFetch({ status: 404, buildId: 'build-2' });
|
|
63
|
+
const promise = fetchCheckingBuild('/api/auth/session', {});
|
|
64
|
+
expect(await settles(promise)).toBe(false);
|
|
65
|
+
expect(window.location.reload).toHaveBeenCalledTimes(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('fetchCheckingBuild returns a failed response from the same build without reloading', async () => {
|
|
69
|
+
const response = mockFetch({ status: 401, buildId: 'build-1' });
|
|
70
|
+
await expect(fetchCheckingBuild('/api/auth/sign-in/email', {})).resolves.toBe(response);
|
|
71
|
+
expect(window.location.reload).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('fetchCheckingBuild returns a successful response from a newer build without reloading', async () => {
|
|
75
|
+
const response = mockFetch({ status: 200, buildId: 'build-2' });
|
|
76
|
+
await expect(fetchCheckingBuild('/api/auth/get-session', {})).resolves.toBe(response);
|
|
77
|
+
expect(window.location.reload).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('fetchCheckingBuild returns a failed response without a build header without reloading', async () => {
|
|
81
|
+
const response = mockFetch({ status: 502 });
|
|
82
|
+
await expect(fetchCheckingBuild('/api/auth/get-session', {})).resolves.toBe(response);
|
|
83
|
+
expect(window.location.reload).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('fetchCheckingBuild reloads at most once for the same newer build', async () => {
|
|
87
|
+
mockFetch({ status: 404, buildId: 'build-2' });
|
|
88
|
+
fetchCheckingBuild('/api/auth/session', {});
|
|
89
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
90
|
+
const response = mockFetch({ status: 404, buildId: 'build-2' });
|
|
91
|
+
await expect(fetchCheckingBuild('/api/auth/session', {})).resolves.toBe(response);
|
|
92
|
+
expect(window.location.reload).toHaveBeenCalledTimes(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('fetchCheckingBuild passes the request through to fetch unchanged', async () => {
|
|
96
|
+
mockFetch({ status: 200, buildId: 'build-1' });
|
|
97
|
+
const init = { method: 'POST', body: '{}' };
|
|
98
|
+
await fetchCheckingBuild('/api/auth/sign-out', init);
|
|
99
|
+
expect(global.fetch).toHaveBeenCalledWith('/api/auth/sign-out', init);
|
|
100
|
+
});
|