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>
## Summary
`PermissionsException` thrown by `SettingsPermissionGuard` (and other
permission code paths) was bubbling up through every typed REST
exception filter and landing in the global `UnhandledExceptionFilter`,
which falls back to **500** for anything that isn't an `HttpException`.
So a forbidden user (e.g. an API key whose role doesn't have
`DATA_MODEL`) calling `GET /rest/metadata/objects` got:
```
HTTP/1.1 500 Internal Server Error "Entity performing the request does not have permission"
```
GraphQL already had the right plumbing via
`permissionGraphqlApiExceptionHandler` (`ForbiddenError` → 403,
`UserInputError` → 400, `NotFoundError` → 404). This PR mirrors it on
the REST side.
## What
- New util `permissionRestApiExceptionCodeToHttpStatus` mapping every
`PermissionsExceptionCode` → HTTP status, with `assertUnreachable` to
force explicit handling of future codes.
- New filter `PermissionsRestApiExceptionFilter`
(`@Catch(PermissionsException)`) that delegates to
`HttpExceptionHandlerService.handleError(...)` with the resolved status.
- Wired `PermissionsRestApiExceptionFilter` (placed first, so the typed
filter wins over any sibling catch-all) into `@UseFilters(...)` of every
REST controller that uses `SettingsPermissionGuard` or whose service can
throw `PermissionsException`:
- `object-metadata`, `field-metadata`, `webhook`, `api-key`
- `view`, `view-sort`, `view-group`, `view-filter`, `view-filter-group`,
`view-field`
- `page-layout`, `page-layout-widget`, `page-layout-tab`
- `front-component`, `ai-generate-text`
- Unit tests covering 403 / 400 / 404 / 500 mappings.
## Mapping
| Code | Status |
|------|--------|
| `PERMISSION_DENIED`, `NO_AUTHENTICATION_CONTEXT`,
`ROLE_LABEL_ALREADY_EXISTS`, `CANNOT_UNASSIGN_LAST_ADMIN`,
`CANNOT_UPDATE_SELF_ROLE`, `CANNOT_DELETE_LAST_ADMIN_USER`,
`ROLE_NOT_EDITABLE`, `CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT`,
`CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT` | **403** |
| `INVALID_ARG`, `INVALID_SETTING`,
`CANNOT_GIVE_WRITING_PERMISSION_ON_NON_READABLE_OBJECT`,
`CANNOT_GIVE_WRITING_PERMISSION_WITHOUT_READING_PERMISSION`,
`ONLY_FIELD_RESTRICTION_ALLOWED`,
`FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT`,
`FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT`,
`EMPTY_FIELD_PERMISSION_NOT_ALLOWED`,
`ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET`, `ROLE_CANNOT_BE_ASSIGNED_TO_USERS`
| **400** |
| `ROLE_NOT_FOUND`, `OBJECT_METADATA_NOT_FOUND`,
`FIELD_METADATA_NOT_FOUND`, `FIELD_PERMISSION_NOT_FOUND`,
`PERMISSION_NOT_FOUND` | **404** |
| All remaining "internal" codes (rethrown as-is in GraphQL) | **500** |
## Before
<img width="507" height="216" alt="Screenshot 2026-05-19 at 19 26 07"
src="https://github.com/user-attachments/assets/21d633aa-7ee8-4923-94e4-7ad57258a29e"
/>
## After
<img width="610" height="385" alt="Screenshot 2026-05-19 at 19 26 01"
src="https://github.com/user-attachments/assets/0103b7ee-7df7-4aef-999a-73c22901afd2"
/>
## Context
Calling `POST /rest/views` (and other metadata mutations) currently
returns a generic `500` for user-input failures:
Ex:
1. **Invalid `objectMetadataId`** —
`resolveEntityRelationUniversalIdentifiers` throws
`FlatEntityMapsException(RELATION_UNIVERSAL_IDENTIFIER_NOT_FOUND)`.
Should be `404`.
2. **Missing required field** (e.g. `icon`) — Postgres raises a `NOT
NULL` violation, wrapped as
`WorkspaceMigrationRunnerException(EXECUTION_FAILED)` carrying a
`QueryFailedError`. Should be `400`.
Neither was caught by `ViewRestApiExceptionFilter`, so both fell through
to `UnhandledExceptionFilter` and were emitted as `500`s without
reaching Sentry.
Same gap existed on most metadata GraphQL resolvers — only
`page-layout*` and `role` resolvers covered
`WorkspaceMigrationRunnerException` via
`WorkspaceMigrationGraphqlApiExceptionInterceptor`.
## Changes
### New filters
REST (`HttpExceptionHandlerService` + Sentry-aware):
- `FlatEntityMapsRestApiExceptionFilter` — maps
`RELATION_UNIVERSAL_IDENTIFIER_NOT_FOUND` / `ENTITY_NOT_FOUND` → `404`,
`ENTITY_ALREADY_EXISTS` → `409`, others → `500`.
- `WorkspaceMigrationRunnerRestApiExceptionFilter` — for
`EXECUTION_FAILED`, unwraps the underlying `metadata` /
`workspaceSchema` / `actionTranspilation` error; if it's a
`QueryFailedError` it gets remapped to `400` via
`HttpExceptionHandlerService`. `APPLICATION_NOT_FOUND` → `404`,
`DDL_LOCKED` → `503`, otherwise `500`.
GraphQL (graphql-errors + existing formatter):
- `FlatEntityMapsGraphqlApiExceptionFilter` — kept as the GraphQL-shaped
counterpart (`NotFoundError` / `InternalServerError`).
- `WorkspaceMigrationRunnerGraphqlApiExceptionFilter` — reuses
`workspaceMigrationRunnerExceptionFormatter` for parity with the
existing interceptor.
### Wiring
Filters are now declared **per controller / resolver** via `@UseFilters`
(no global `APP_FILTER` registration) so they participate in the normal
NestJS filter chain instead of being preempted by
`UnhandledExceptionFilter`.
REST:
- `view.controller.ts` — adds `FlatEntityMapsRestApiExceptionFilter` and
`WorkspaceMigrationRunnerRestApiExceptionFilter`.
GraphQL (14 resolvers, all that mutate flat entities):
- `FlatEntityMapsGraphqlApiExceptionFilter` added to: `view`,
`view-field`, `view-field-group`, `view-sort`, `view-group`,
`view-filter`, `view-filter-group`, `page-layout`, `page-layout-tab`,
`page-layout-widget`, `role`, `object-metadata`, `field-metadata`,
`index-metadata`.
- `WorkspaceMigrationRunnerGraphqlApiExceptionFilter` added to the same
list **except** the four already covered by
`WorkspaceMigrationGraphqlApiExceptionInterceptor` (`page-layout`,
`page-layout-tab`, `page-layout-widget`, `role`) — to avoid
double-handling.
## Why per-resolver / per-controller instead of global
Earlier attempt to register the filters globally via `APP_FILTER`
regressed: NestJS reverses the global filter list and
`selectExceptionFilterMetadata` is first-match-wins, so
`UnhandledExceptionFilter` (registered last via `app.useGlobalFilters`
in `main.ts`) ended up first in the iteration order and preempted every
domain-specific filter. The per-resolver / per-controller approach is
explicit and predictable.
## Before
<img width="953" height="450" alt="Screenshot 2026-05-12 at 15 31 40"
src="https://github.com/user-attachments/assets/3c3bc6a8-f6bc-4032-97d0-7243540cfb90"
/>
## After
<img width="1050" height="598" alt="Screenshot 2026-05-12 at 15 31 17"
src="https://github.com/user-attachments/assets/c66c9ce5-d1ea-4f1d-b2fe-07979e2261f7"
/>
<img width="1068" height="503" alt="Screenshot 2026-05-12 at 15 31 09"
src="https://github.com/user-attachments/assets/ddd9eed8-812b-47d6-96cb-b019b807991b"
/>
This PR introduces a few changes:
- Add three actions: see deleted dashboards, destroy dashboard and
restore dashboard
- Remove the soft delete and restore on all the page layout entities
- Cascade the destruction of a dashboard to a page layout
Video QA:
https://github.com/user-attachments/assets/ab993b11-dd9c-4e88-880c-92691a521cc2
# Introduction
Creating dedicated folders and module for both `page-layout-tab` and
`page-layout-widget`
The addition diff with deletion is due to the module being added
First PR to implement application tokens
- add new application role in twenty-server
- move duplicated constants and types to twenty-shared
- will add role configuration utils into twenty-sdk in another PR