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:
Paul Rastoin
2026-08-05 10:08:51 +02:00
committed by GitHub
parent 8a856a4bce
commit 8bfa9c4adb
68 changed files with 312 additions and 136 deletions
@@ -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';