Proxy API routes through the vite dev server to keep local dev same-origin (#23779)
Replaces #23774 (closed), rebased on latest main. ## Problem Since the cookie-session migration (#23642), the front sends every request with `credentials: 'include'` and the server only reflects `Access-Control-Allow-Origin` for the exact origins in the credentialed allowlist (`SERVER_URL`, `FRONTEND_URL`, `AUTH_COOKIE_ALLOWED_ORIGINS`). Any other origin gets the `*` wildcard, which browsers reject for credentialed requests. Local dev is split-origin by default (front on `localhost:3001`, API on `localhost:3000`), and with `IS_MULTIWORKSPACE_ENABLED` every workspace subdomain (`apple.localhost:3001`, ...) is yet another origin. Each locally created workspace would need a manual `AUTH_COOKIE_ALLOWED_ORIGINS` entry. ## Solution Make local dev same-origin instead of widening the CORS policy: the vite dev server now proxies all top-level API route prefixes to the backend, and the front calls its own origin. - `vite.config.ts` adds a `server.proxy` covering the backend's top-level prefixes (`/graphql`, `/metadata`, `/admin-panel`, `/auth`, `/rest`, `/file`, `/client-config`, ...), defined in `src/config/apiProxyPrefixes.ts`. Keys are anchored regexes (`^/auth($|[/?])`) so SPA routes sharing a prefix (`/authorize`, `/settings`) are not swallowed. The target defaults to `http://localhost:3000` and follows `REACT_APP_SERVER_BASE_URL`. `changeOrigin` stays off so the backend sees the browser's Host: same-origin checks (CSRF, cookie issuance) and workspace resolution by subdomain work unchanged through the proxy. - `config/index.ts` collapses to `window._env_?.REACT_APP_SERVER_BASE_URL || window.location.origin`. Every supported production path injects `window._env_` (docker entrypoint fails hard without `REACT_APP_SERVER_BASE_URL`; a server-served front gets it from `generateFrontConfig()`), and in dev the current origin is correct on `localhost:3001` and every `*.localhost:3001` workspace subdomain thanks to the proxy. The removed `http://<hostname>:3000` fallback only served an un-injected production bundle browsed on localhost, a setup whose credentialed auth the cookie-session migration had already broken. The credentialed allowlist itself is unchanged and stays strict; since dev traffic is same-origin, the per-subdomain cookie-allowlist problem disappears without loosening any production CORS/CSRF policy. ## Tests - `src/config/__tests__/apiProxyPrefixes.test.ts` guards the proxy boundary in both directions: representative backend path shapes (including `/metadata?query=...` and `/auth/...`) must match, every SPA route from the `AppPath` enum and vite's own dev paths must not — so a future route collision fails unit tests instead of breaking dev. - Verified against running dev servers: API paths proxy to the backend from both `localhost:3001` and `apple.localhost:3001`, while SPA routes `/settings` and `/authorize` still serve the vite app; a same-origin POST from `apple.localhost:3001` goes through with no CORS involvement. - `lint:diff-with-main` and `typecheck` pass for twenty-front. --------- Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
// Adding or renaming a value here also requires updating the nginx ingress rules
|
||||
// in the infra repo, which route these prefixes to the server instead of the front.
|
||||
export enum ApiPath {
|
||||
AdminPanel = 'admin-panel',
|
||||
App = 'app',
|
||||
ApplicationRegistrationClaim = 'application-registration-claim',
|
||||
Apps = 'apps',
|
||||
Auth = 'auth',
|
||||
ClientConfig = 'client-config',
|
||||
Cloudflare = 'cloudflare',
|
||||
Emailing = 'emailing',
|
||||
File = 'file',
|
||||
FileUpload = 'file-upload',
|
||||
Files = 'files',
|
||||
GraphQL = 'graphql',
|
||||
Health = 'healthz',
|
||||
Mcp = 'mcp',
|
||||
Metadata = 'metadata',
|
||||
OAuth = 'oauth',
|
||||
OpenApi = 'open-api',
|
||||
PublicAssets = 'public-assets',
|
||||
Rest = 'rest',
|
||||
RouteTrigger = 's',
|
||||
Webhooks = 'webhooks',
|
||||
WellKnown = '.well-known',
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export enum AppBasePath {
|
||||
Auth = '/auth',
|
||||
Settings = '/settings',
|
||||
Root = '/',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPath } from '@/types/ApiPath';
|
||||
import { AppBasePath } from '@/types/AppBasePath';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
|
||||
const apiPaths = new Set<string>(Object.values(ApiPath));
|
||||
|
||||
const getFirstPathSegment = (path: string) =>
|
||||
path.replace(/^\//, '').split('/')[0];
|
||||
|
||||
const frontRoutes = [...Object.values(AppPath), ...Object.values(AppBasePath)];
|
||||
|
||||
const frontRoutesWithSegment = frontRoutes
|
||||
.map((frontRoute) => ({
|
||||
frontRoute,
|
||||
firstPathSegment: getFirstPathSegment(frontRoute),
|
||||
}))
|
||||
.filter(
|
||||
({ firstPathSegment }) =>
|
||||
firstPathSegment !== '' && firstPathSegment !== '*',
|
||||
);
|
||||
|
||||
describe('ApiPath and front route collisions', () => {
|
||||
it.each(frontRoutesWithSegment)(
|
||||
'should not serve the front route $frontRoute from a path the server owns',
|
||||
({ firstPathSegment }) => {
|
||||
expect(apiPaths.has(firstPathSegment)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -11,6 +11,7 @@ export type { AllowedAddressSubField } from './AddressFieldsType';
|
||||
export { ALLOWED_ADDRESS_SUBFIELDS } from './AddressFieldsType';
|
||||
export { AggregateOperations } from './AggregateOperations';
|
||||
export type { AllowedFullNameSortSubField } from './AllowedFullNameSortSubField';
|
||||
export { ApiPath } from './ApiPath';
|
||||
export { AppBasePath } from './AppBasePath';
|
||||
export { AppPath } from './AppPath';
|
||||
export type { Arrayable } from './Arrayable';
|
||||
|
||||
Reference in New Issue
Block a user