Commit Graph

2111 Commits

Author SHA1 Message Date
Félix Malfait 53d22a3b70 fix(server): require PKCE code_challenge for public OAuth clients (#19840)
## Summary

OAuth 2.1 and the MCP authorization spec mandate PKCE (S256) for public
clients — clients registered with \`token_endpoint_auth_method=none\`
(no client secret). We advertise \`code_challenge_methods_supported:
[\"S256\"]\` in \`/.well-known/oauth-authorization-server\` but our
\`/authorize\` flow accepted requests from public clients without
\`code_challenge\`.

## Why this was a soft failure today

\`oauth.service.ts:178\` already rejects token exchange when a client
presents neither \`client_secret\` nor \`code_verifier\`:

\`\`\`ts
if (!clientSecret && !storedCodeChallenge) {
return this.errorResponse('invalid_request', 'Either client_secret or
code_verifier (PKCE) is required');
}
\`\`\`

So a public client attempting to bypass PKCE would **eventually** fail —
but only after:
1. Getting a valid authorization code issued at \`/authorize\`
2. Round-tripping the user through consent
3. Trying to exchange the code at \`/token\` and finally getting
rejected

That's a wasted user interaction and a fuzzy spec boundary. This PR
rejects at \`/authorize\` instead, matching the spec's \"MUST require
PKCE for public clients\" expectation.

## Fix

Single check in \`AuthService.generateAuthorizationCode\`:

\`\`\`ts
const isPublicClient = !applicationRegistration.oAuthClientSecretHash;

if (isPublicClient && !codeChallenge) {
  throw new AuthException(
\`code_challenge is required for public clients (PKCE S256, per OAuth
2.1)\`,
    AuthExceptionCode.FORBIDDEN_EXCEPTION,
  );
}
\`\`\`

### Why \`!oAuthClientSecretHash\` is the right \"public\" predicate

- Dynamic registration (\`POST /oauth/register\`) hardcodes
\`oAuthClientSecretHash: null\` and rejects any
\`token_endpoint_auth_method != \"none\"\`
(oauth-registration.controller.ts:120-130).
- Confidential clients registered via the workspace settings UI have a
non-null bcrypt hash.
- The same field is already used as the public/confidential gate in
\`validateClient\` and \`validateClientSecret\`.

## Scope

-  Dynamic-registration clients (Claude, other MCP connectors) — MUST
now supply code_challenge. They already do; no behavior change for
conformant clients.
-  The seeded twenty-cli registration — public client, already uses
PKCE. No change.
-  Confidential clients (workspace-admin-registered OAuth apps with a
client_secret) — unaffected, they authenticate at the token endpoint.

## Related

- #19836 — CORS exposes \`WWW-Authenticate\` / \`MCP-Protocol-Version\`
- #19838 — RFC 9728 PRM split + RFC 9207 iss param + \`scope\` in
WWW-Authenticate challenge

## Test plan

- [x] \`tsc --noEmit\` clean on modified file (pre-existing
\`twenty-shared\` dist errors unrelated)
- [ ] Integration-level smoke test after deploy:
  \`\`\`bash
  # Register a dynamic client (public)
  CLIENT_ID=$(curl -s -X POST -H 'Content-Type: application/json' \\
-d
'{\"client_name\":\"pkce-test\",\"redirect_uris\":[\"http://localhost/cb\"]}'
\\
    https://<host>/oauth/register | jq -r .client_id)

# Without code_challenge → should now 4xx at /authorize (cannot easily
test outside the React UI,
  # but the GraphQL authorizeApp mutation will throw AuthException)
  \`\`\`
- [ ] Claude MCP connector still completes OAuth end-to-end (it always
sends code_challenge, so no-op)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 21:17:22 +02:00
github-actions[bot] 1c54e79d6c i18n - translations (#19843)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-18 21:14:49 +02:00
neo773 be9616db60 chore: remove draft email feature flag (#19842) 2026-04-18 21:12:01 +02:00
Charles Bochet 70a73534c0 perf(server): reuse ESM module cache across warm Lambda invocations of logic functions (#19830)
## Summary

Lambda warm-invocations of logic functions were spending **~440 ms**
re-parsing and re-evaluating the user bundle on every call. The executor
wrote the user code to a **randomly-named** temp file and `import()`-ed
it, so each warm call resolved to a new URL and Node's ESM cache could
never reuse the previous module record.

This PR makes the executor write to a **content-hash filename**, skip
the write when the file already exists, and stop deleting it. Identical
code now reuses the same module record across warm calls in the same
container, dropping warm-invocation overhead by **~30–40%**.

## What changed

- `executor/index.mjs`: temp filename derived from `sha256(code)`, write
skipped when file exists, no `fs.rm` on cleanup.
- `lambda.driver.ts`: single structured `[lambda-timing]` log per
invocation with `totalMs / buildExecutorMs / getBuiltCodeMs /
payloadBytes / invokeSendMs / reportDurationMs / billedMs /
initDurationMs / coldStart`. Goes through the standard NestJS `Logger`.

No behavioural change for callers: same input → same output, same error
semantics.

### Caveat: module-scope state now persists across warm calls

With a stable filename, the user bundle is evaluated **once per warm
container**. Any module-scoped state or top-level side-effects in user
code are now shared across invocations of the same container, instead of
re-running on every call. This is documented in the executor and is the
intended trade-off — module scope should be treated as a per-container
cache, not as per-call isolation.

## Findings — measured impact

Same logic function (`fetch-prs`, ~12k PRs to page through), same
workspace, same Lambda config (eu-west-3, 512 MB), token cache primed.

### Warm invocations

| Phase | Before fix | After fix | Δ |
| -------------------------------------- | ------------- |
-------------- | ------------ |
| Executor `import(userBundle)` | ~440 ms | **~0 ms** | **-440 ms** |
| Lambda billed duration | ~1.5–1.7 s | **~1.0–1.1 s** | **~30–40%** |
| Server-perceived round-trip | ~1.7–2.0 s | **~1.0–1.2 s** |
**~30–40%** |

### Cold starts

Unchanged — the cache helps subsequent warm calls in the same container,
not the first one. Init Duration stays ~130–170 ms; total cold call
~2.5–3.0 s.

### Stress

Could not reproduce the previously-reported \"every ~10th call times
out\" behaviour after the fix:

- 30 sequential calls: max 1.7 s, median ~1.1 s, 0 timeouts
- 50 concurrent calls: max 9.4 s (clear cold-start cluster), median ~1.5
s, 0 timeouts

Hypothesis: the warm-import overhead was eating into the headroom
against the function timeout under bursty load; removing it pushed
everything well below the limit.

## Observability

One structured log line per invocation, sent through the standard NestJS
logger:

\`\`\`
[lambda-timing] fnId=abc123 totalMs=1187 buildExecutorMs=2
getBuiltCodeMs=3 payloadBytes=1466321 invokeSendMs=1180
reportDurationMs=992 billedMs=1000 initDurationMs=n/a coldStart=false
\`\`\`

\`coldStart=true\` whenever Lambda spun up a fresh container; on warm
calls \`buildExecutorMs\` and \`getBuiltCodeMs\` collapse to
single-digit ms, confirming the cache fix is working.

## Test plan

- [ ] CI green.
- [ ] Deploy to a Lambda-backed env, trigger a logic function several
times in a row.
- [ ] Confirm \`[lambda-timing]\` warm invocations show \`totalMs\`
~30–40% lower than before, and \`coldStart=false\` after the first call
in a container.
- [ ] Push a new version of an app; confirm the next call shows higher
\`buildExecutorMs\` (new hash, new file written) followed by warm calls
again.
- [ ] Smoke test: errors thrown by the user handler are still surfaced
correctly.

Made with [Cursor](https://cursor.com)
2026-04-18 11:30:56 +02:00
neo773 1d575f0496 fix oauth permission check (#19829)
was regressed due to https://github.com/twentyhq/twenty/pull/19441
2026-04-18 11:20:23 +02:00
Félix Malfait b292a93376 fix(server): honor X-Forwarded-* via configurable trust proxy (#19824)
## The bug

Pasting `https://<workspace>.twenty.com/mcp` into an MCP client (Claude
connector, etc.) fails discovery. Curl shows why:

```bash
$ curl -si https://twentyfortwenty.twenty.com/.well-known/oauth-protected-resource
HTTP/2 200
...
{
  \"resource\": \"http://{workspace}.twenty.com/mcp\",
  \"authorization_servers\": [\"http://twentyfortwenty.twenty.com\"],
  ...
}
```

The response advertises `http://` even though the request came in on
`https://`. RFC 9728 / RFC 8707 require the client to validate that the
advertised `resource` matches the URL it connected to, so strict MCP
clients reject the mismatch and OAuth never starts.

## Why request.protocol returns \"http\"

Per [Express docs](https://expressjs.com/en/guide/behind-proxies.html),
`request.protocol` returns the socket-level protocol unless
`app.set('trust proxy', ...)` is configured. In our deployment:

```
client -- https --> Cloudflare -- https --> ingress-nginx -- http --> NestJS pod
```

TLS is terminated at the edge. The upstream TCP connection into the pod
is plain HTTP, and nginx sets `X-Forwarded-Proto: https` for the pod to
read. Without a `trust proxy` setting, Express ignores
`X-Forwarded-Proto` and `request.protocol === 'http'`.

`main.ts` currently has no `app.set('trust proxy', ...)` call anywhere.

## Why this only surfaced now

`grep -rn request.protocol` finds three pre-existing call sites —
`RestApiMetadataService`, `OpenApiService`, `RouteTriggerService`. All
three wrap it in `getServerUrl({ serverUrlEnv: SERVER_URL,
serverUrlFallback: \`${request.protocol}://${request.get('host')}\` })`,
which returns `SERVER_URL` whenever it's non-empty. In production
`SERVER_URL` is always set (e.g. \`api.twenty.com\`), so the
\`request.protocol\` branch is effectively dead code there.

#19755 introduced the first call site that uses `request.protocol`
unconditionally — the OAuth discovery controller has to echo the request
host, because the whole point is supporting multiple paste-able origins
(workspace subdomains, custom domains, etc.). That's why this is the
first \"wrong protocol\" bug anyone has seen in our app.

## The fix

One line in `main.ts`:

```ts
app.set('trust proxy', twentyConfigService.get('TRUST_PROXY'));
```

Backed by a new `TRUST_PROXY` env var with a default. `request.protocol`
then honors `X-Forwarded-Proto`, `request.ip` honors `X-Forwarded-For`,
etc. OAuth discovery URLs come out on the right scheme, and any future
`request.protocol` callers Just Work.

## Why this needs to be configurable (not hardcoded)

Twenty is open-source and deployed in at least three distinct
topologies:

1. **Kubernetes with ingress** (us, enterprise self-hosters) — TLS
terminated upstream, needs `trust proxy` **on**.
2. **Self-host behind a user-supplied reverse proxy** (Caddy, Traefik,
nginx — our [recommended
setup](https://twenty.com/developers/section/self-hosting)) — same as
above, needs `trust proxy` **on**.
3. **Self-host with NestJS exposed directly to the internet** — no
upstream proxy, needs `trust proxy` **off** (otherwise any curl with
`X-Forwarded-For: 1.2.3.4` spoofs `request.ip`, poisoning rate-limiters
and audit logs).

There is no single static value that's correct for all three. Express
makes this a setting for exactly this reason — we follow suit.

## Why the default is `'loopback, linklocal, uniquelocal'`

Shorthand for loopback (127/8, ::1), link-local (169.254/16, fe80::/10),
and unique-local (10/8, 172.16/12, 192.168/16, fc00::/7). In practical
terms: **trust peers coming from private networks; don't trust the
public internet**.

This default is correct for shapes 1 and 2 (cloud, proxied self-host)
because the ingress/proxy peer is always a private-network IP in every
sane deployment.

For shape 3 (directly exposed), the default is still safe because public
clients have public IPs, which are not in any of those ranges — so
`X-Forwarded-For` from an attacker on the internet is ignored. The only
way to be bitten is the exotic case where a public client reaches NestJS
through a private-network hop that isn't a proxy (e.g. a NAT appliance
that forwards to the pod on a private IP and blindly appends headers).
Narrow attack surface, and an operator running that kind of setup is
expected to configure `TRUST_PROXY=false` explicitly.

\"Safer than the naïve `true`, more useful than `false`\" — this matches
what Rails, Django, and many other frameworks recommend for
Kubernetes-style deployments.

## Why an env var instead of hardcoded

- Rejecting hardcoded `true`: would expose shape-3 self-hosters to IP
spoofing without a way to opt out.
- Rejecting hardcoded `false`: would leave cloud + shape-2 self-hosters
broken, same bug as today.
- Accepting string-typed env (not boolean): Express's `trust proxy`
accepts booleans, hop counts (`1`, `2`), IP ranges (`'10.0.0.0/8'`), and
named CIDRs (`'loopback'`). A boolean would hide that flexibility;
operators occasionally need the richer values. The string maps 1:1 onto
what Express accepts.

## Deployment matrix

| Deployment | Default works? | Override needed? |
|---|---|---|
| Cloud (us, K8s + nginx ingress + Cloudflare) | ✓ | — |
| Self-host behind reverse proxy (recommended) | ✓ | — |
| Self-host exposed directly on public IP | ✓ (public IPs not in private
ranges) | Optional: `TRUST_PROXY=false` for strictness |
| Local dev (direct, no proxy) | ✓ (no `X-Forwarded-*` headers arrive) |
— |
| Exotic: multi-hop through non-sanitizing private-network middlebox |
Risky | `TRUST_PROXY=false` |

## Related

- Blocks MCP connector OAuth on `<ws>.twenty.com` / custom domains.
After deploy: `curl -s
https://<ws>.twenty.com/.well-known/oauth-protected-resource | jq
.resource` should return `https://...` (not `http://...`).
- Fixes latent issue in `RestApiMetadataService`, `OpenApiService`,
`RouteTriggerService` fallback paths (pre-existing but dead in
production because `SERVER_URL` is always set — no behavior change
there).

## Test plan

- [x] `tsc --noEmit` clean
- [ ] After deploy: `curl -s
https://twentyfortwenty.twenty.com/.well-known/oauth-protected-resource`
returns `https://` URLs
- [ ] After deploy: MCP connector in Claude successfully completes OAuth
against `https://<ws>.twenty.com/mcp`
- [ ] No change in `request.ip` logging behavior on cloud (nginx-ingress
peer is already private-network, was already being trusted implicitly by
every framework layer that wasn't `request.protocol`)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 06:03:30 +02:00
Charles Bochet e878d646ed chore(server): bump logic-function executor lambda memory to 512MB (#19826)
## Summary

The executor lambda for user logic functions is created in
`LambdaDriver` without a `MemorySize` parameter, so AWS Lambda falls
back to its 128 MB default. That cap is too tight for non-trivial logic
functions — large upstream GraphQL responses, JSON parsing of paginated
batches, and chained Twenty Core API mutations push the process over the
limit and trigger an OOM SIGKILL surfaced to the user as:

```
Runtime exited with error: signal: killed
```

This bumps the executor lambda memory to **512 MB** (matching the
existing `BUILDER_LAMBDA_MEMORY_MB`). The change is applied on both:

- The `CreateFunctionCommand` path used when a logic function is first
deployed.
- The `UpdateFunctionConfigurationCommand` path used when the deps/SDK
layer wiring is refreshed — so existing functions get reconfigured on
next deploy without any additional manual action.

## Why 512 MB

Lambda compute is allocated proportionally to memory. 512 MB:
- Matches the builder lambda already in this file
(`BUILDER_LAMBDA_MEMORY_MB`).
- Is comfortably above the 128 MB default that the existing OOMs are
hitting.
- Stays well below the higher tiers, keeping the per-invocation cost
increase modest.


Made with [Cursor](https://cursor.com)
2026-04-17 23:59:05 +02:00
Nathan Nguyen 59e4ed715a fix(server): normalize empty composite phone sub-fields to NULL (#19775)
Fixed using Opus 4.7, I wanted to test this model out and in this repo I
know you guys care about quality, pls let me know if this is good code.
It looks good to me

Fixes #19740.

## Summary

PostgreSQL UNIQUE indexes treat two `''` values as duplicates but two
`NULL`s as distinct. `validateAndInferPhoneInput` was persisting blank
`primaryPhoneNumber` as `''` instead of `NULL`, so a second record with
an empty unique phone failed with a constraint violation. The sibling
composite transforms (`transformEmailsValue`, `removeEmptyLinks`,
`transformTextField`) already canonicalize null-equivalent values;
phones was the outlier.

- Empty-string phone sub-fields now normalize to `null`. `undefined` is
preserved so partial updates leave columns the user did not touch alone.
- `PhonesFieldGraphQLInput` drops the aspirational `CountryCode` brand
on input. GraphQL delivers raw strings at the boundary; branding happens
during validation.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-17 19:36:43 +00:00
neo773 68746e22a0 Send Email Tool: Don't persist message on SMTP only connections (#19756)
Previously this blocked users who only had SMTP configured to send
outbound emails, this fixes it by making messageChannel and persist
layer conditional
2026-04-17 12:56:38 +00:00
github-actions[bot] f94ee2d495 i18n - translations (#19795)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-17 14:15:46 +02:00
Weiko 5cd8b7899d shouldIncludeRecordPageLayouts deprecation (#19774)
## Context
Deprecating shouldIncludeRecordPageLayouts in preparation for page
layout release.

See new workspace with standard page layout from standard app
<img width="570" height="682" alt="Screenshot 2026-04-16 at 18 35 23"
src="https://github.com/user-attachments/assets/bf7fa621-d40d-4c29-8d96-537c58b3eb40"
/>
2026-04-17 11:32:10 +00:00
Charles Bochet 76ea0f37ed Surface structured validation errors during application install (#19787)
## Summary
- Add `WorkspaceMigrationGraphqlApiExceptionInterceptor` to
`MarketplaceResolver` and `ApplicationInstallResolver` so validation
failures during app install return `METADATA_VALIDATION_FAILED` with
structured `extensions.errors` instead of generic
`INTERNAL_SERVER_ERROR`
- Update SDK `installTarballApp()` to pass the full GraphQL error object
(including extensions) through the install flow
- Add `formatInstallValidationErrors` utility to format structured
validation errors for CLI output
- Add integration test verifying structured error responses for invalid
navigation menu items and view fields

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:29:33 +00:00
Marie b31f84fbb8 fix(server): workspace member permissions and profile onboarding (#19786)
## Summary

Aligns **workspace member** editing and **onboarding** with how the
product is actually used: profile and other “settings” fields go through
**`updateWorkspaceMemberSettings`**, while **`/graphql`** record APIs
follow **object-level** permissions for the `workspaceMember` object.

## Product behaviour

### Completing “Create profile” onboarding

Users who must create a profile (empty name at sign-up) get
`ONBOARDING_CREATE_PROFILE_PENDING` set. The onboarding UI saves the
name with **`updateWorkspaceMemberSettings`**, not with a workspace
record **`updateOne`**.

**Before:** The server only cleared the pending flag on
**`workspaceMember.updateOne`**, so the flag could stay set and
onboarding appeared stuck.

**After:** Clearing the profile step runs when
**`updateWorkspaceMemberSettings`** persists an update that includes a
**name** (same rules as before: non-empty name parts). Onboarding can
advance normally after **Continue** on Create profile.

### Two ways to change workspace member data

| Path | Typical use | Who can change what |
|------|----------------|---------------------|
| **`updateWorkspaceMemberSettings`** (metadata API) | Standard member
fields the app treats as “my profile / preferences” (name,
avatar-related settings, locale, time zone, etc.) | **Always** your
**own** workspace member. Changing **another** member still requires
**Workspace members** in role settings (`WORKSPACE_MEMBERS`). Custom
fields are **not** allowed on this endpoint (unchanged). |
| **`/graphql`** record mutations on **`workspaceMember`** | Custom
fields, integrations, anything that goes through the generic record API
| **`WorkspaceMember`** is special-cased in permissions: **read** stays
**on** for everyone, but **update / create / delete** require
**`WORKSPACE_MEMBERS`**, including updating **your own** row via
`/graphql`. So a **Member** without that permission cannot fix their
name through **`updateWorkspaceMember`**; they use **Settings** /
**`updateWorkspaceMemberSettings`** instead. |

This matches **`WorkspaceRolesPermissionsCacheService`**: for the
workspace member object, `canReadObjectRecords` is always true;
`canUpdateObjectRecords` (and delete-related flags) follow
**`WORKSPACE_MEMBERS`**.

### Hooks and delete side-effects

- Removed **`workspaceMember.updateOne`** pre-query hook and
**`WorkspaceMemberPreQueryHookService`**: they duplicated the same rules
the permission cache already enforces for `/graphql`.
- **`WorkspaceMember.deleteOne`** pre-hook still tells users to remove
members via the dedicated flow; the post-hook only runs the
**`deleteUserWorkspace`** side-effect when a member row is actually
removed—**no** extra settings-permission check there, since only callers
that already passed **object** delete permission can remove the row.

## Tests

- **`workspace-members.integration-spec.ts`**: clarifies and extends
coverage so **`/graphql`** **`updateOne`** is denied for **own** record
on a **standard** name field and on a **custom** field when the role
lacks **`WORKSPACE_MEMBERS`**.

## Implementation notes

- **`OnboardingService.completeOnboardingProfileStepIfNameProvided`**
centralises the “clear profile pending if name present” logic;
**`UserResolver.updateWorkspaceMemberSettings`** calls it after save,
using the typed update payload’s **`name`** (no cast).
- **`UserWorkspaceService.updateUserWorkspaceLocaleForUserWorkspace`**:
drops a redundant **`coreEntityCacheService.invalidate`**;
**`updateWorkspaceMemberSettings`** still invalidates the user-workspace
cache after the mutation.
2026-04-17 09:58:34 +00:00
Paul Rastoin bf410ae438 upgrade:status command (#19584)
## Introduction
Introducing a new command in order to determine the curent twenty
instance and workspaces status as it's not stored in database but a
derivation of each current curors


## `upgrade:status` all healthy
<img width="1376" height="1202" alt="image"
src="https://github.com/user-attachments/assets/e90d6987-07d2-4b6b-b573-105249aca325"
/>

## `upgrade:status` Nearly use cases
<img width="1442" height="1304" alt="image"
src="https://github.com/user-attachments/assets/c336cb9d-eb9d-4c7d-9392-ec1ef54a7326"
/>

## `upgrade:status --failed-only`
<img width="1442" height="940" alt="image"
src="https://github.com/user-attachments/assets/93a3dfdb-0d2f-4a01-b185-118e5cf0a078"
/>

## `upgrade:status -w aa8fdcb1-8ee1-4012-98af-44a97caa7411 -w
20202020-1c25-4d02-bf25-6aeccf7ea419 -w
20202020-1c25-4d02-bf25-6aeccf7ea412`
<img width="1486" height="928" alt="image"
src="https://github.com/user-attachments/assets/ec1b1abc-46e8-4e36-9799-ab3a4b85e410"
/>
2026-04-16 16:05:02 +00:00
Etienne aecbc89a3f Fix slow db query issue (#19770)
https://github.com/twentyhq/twenty/pull/19586#discussion_r3074136617
2026-04-16 15:37:58 +00:00
Thomas Trompette 446a3923f2 Add workspace id in job logs (#19764)
Cannot currently investigate spikes
2026-04-16 15:28:37 +00:00
Félix Malfait 4f4f723ed0 Fix MCP discovery: path-aware well-known URL and protocol version (#19766)
## Summary

Adding `https://api.twenty.com/mcp` as an MCP server in Claude fails
with `Couldn't reach the MCP server` before OAuth can start. Two
independent bugs cause this:

1. **Missing path-aware well-known route.** The latest MCP spec
instructs clients to probe `/.well-known/oauth-protected-resource/mcp`
before `/.well-known/oauth-protected-resource`. Only the root path was
registered, so the path-aware request fell through to
`ServeStaticModule` and returned the SPA's `index.html` with HTTP 200.
Strict clients (Claude.ai) tried to parse it as JSON and gave up. Fixed
by registering both paths on the same handler.
2. **Stale protocol version.** Server advertised `2024-11-05`, which
predates Streamable HTTP. We've implemented Streamable HTTP (SSE
response format was added in #19528), so bumped to `2025-06-18`.

Reproduction before the fix:

```
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" https://api.twenty.com/.well-known/oauth-protected-resource/mcp
200 text/html; charset=UTF-8
```

After the fix this returns `application/json` with the RFC 9728 metadata
document.

Note: this is separate from #19755 (host-aware resource URL for
multi-host deployments).

## Test plan

- [x] `npx jest oauth-discovery.controller` — 2/2 tests pass, including
one asserting both routes are registered
- [x] `npx nx lint:diff-with-main twenty-server` passes
- [ ] After deploy, `curl
https://api.twenty.com/.well-known/oauth-protected-resource/mcp` returns
JSON (not HTML)
- [ ] Adding `https://api.twenty.com/mcp` in Claude reaches the OAuth
authorization screen

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:32:45 +02:00
Charles Bochet 4103efcb84 fix: replace slow deep-equal with fastDeepEqual to resolve CPU bottleneck (#19771)
## Summary

- Replaced the `deep-equal` npm package with the existing
`fastDeepEqual` from `twenty-shared/utils` across 5 files in the server
and shared packages
- `deep-equal` was causing severe CPU overhead in the record update hot
path (`executeMany` → `formatTwentyOrmEventToDatabaseBatchEvent` →
`objectRecordChangedValues` → `deepEqual`, called **per field per
record**)
- `fastDeepEqual` is ~100x faster for plain JSON database records since
it skips unnecessary prototype chain inspection and edge-case handling
- Removed the now-unnecessary `LARGE_JSON_FIELDS` branching in
`objectRecordChangedValues` since all fields now use the fast
implementation
2026-04-16 17:23:50 +02:00
Félix Malfait d3df58046c chore(server): drop api-host branch in OAuth discovery (#19768)
## Summary

Follow-up to #19755. Simplifies `OAuthDiscoveryController` by dropping
the `authorization_endpoint → frontend base URL` branch that was there
to make `api.twenty.com/mcp` paste-able in MCP clients.

We've decided not to support pasting `api.twenty.com/mcp` — users can
paste `app.twenty.com/mcp`, `<workspace>.twenty.com/mcp`, or a custom
domain, all of which serve both frontend and API. On those hosts,
`authorization_endpoint` was already pointed at the same host as
`issuer`, which is what we want.

## Change

- Remove `isApiHost` helper and the `authorizeBase` branch — use
`issuer` for `authorization_endpoint`.
- Drop now-unused `TwentyConfigService` and `DomainServerConfigService`
injections.
- Drop duplicate `DomainServerConfigModule` import from
`application-oauth.module.ts` (the module is no longer needed).

Net diff: +1 / -22 across 2 files.

## Breaking change

MCP clients configured with `https://api.twenty.com/mcp` will stop
working. They should be reconfigured with the host matching the
workspace they're connecting to (`<workspace>.twenty.com/mcp`,
`app.twenty.com/mcp`, or a custom domain).

## Test plan

- [x] `yarn jest --testPathPatterns="mcp-auth.guard"` → 2/2 passing
(unchanged)
- [x] `tsc --noEmit` clean on modified files
- [ ] Manual verification on staging: `app.twenty.com/mcp` and
`<workspace>.twenty.com/mcp` OAuth flow still works end-to-end

Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:01:55 +02:00
Félix Malfait cb6953abe3 fix(server): make OAuth discovery and MCP auth metadata host-aware (#19755)
## Summary

OAuth discovery metadata (RFC 9728 protected-resource, RFC 8414
authorization-server) and the MCP `WWW-Authenticate` header were
hardcoded to `SERVER_URL`. This breaks MCP clients that paste any URL
other than `api.twenty.com/mcp` — the metadata declares `resource:
https://api.twenty.com/mcp`, which doesn't match the URL the client
connected to, so the client rejects it and the OAuth flow never starts.

Reproduced with Claude's MCP integration: pasting
`<workspace>.twenty.com/mcp`, `app.twenty.com/mcp`, or a custom domain
returned *"Couldn't reach the MCP server"* because discovery returned a
resource URL for a different host.

Related memory: MCP clients POST to the URL the user entered, not the
discovered resource URL — so every paste-able hostname has to advertise
`resource` for that same hostname.

## What the server now does

`WorkspaceDomainsService.getValidatedRequestBaseUrl(req)` resolves the
canonical base URL for the host the request came in on, validated
against the set of hosts we actually serve:

- `SERVER_URL` (e.g. `api.twenty.com`) — API host
- default base URL (e.g. `app.twenty.com`) — the `DEFAULT_SUBDOMAIN`
base
- `FRONTEND_URL` bare host
- any `<workspace>.twenty.com` subdomain (DB lookup)
- any workspace `customDomain` where `isCustomDomainEnabled = true`
- any registered `publicDomain`

An unrecognized / spoofed Host falls back to
`DomainServerConfigService.getBaseUrl()`. **We never reflect arbitrary
Host values into the response.**

Callers updated:

- `OAuthDiscoveryController.getProtectedResourceMetadata` — echoes the
validated host into `resource` and `authorization_servers`.
- `OAuthDiscoveryController.getAuthorizationServerMetadata` — uses the
validated host for `issuer` and `*_endpoint`, **except**
`authorization_endpoint`: when the request came in via `SERVER_URL`
(API-only, no `/authorize` route), we keep that one pointed at the
default frontend base URL.
- `McpAuthGuard` — sets `WWW-Authenticate: Bearer
resource_metadata=\"<validatedBase>/.well-known/oauth-protected-resource\"`
on 401s, so the MCP client's follow-up discovery fetch lands on the same
host it started on.

## Security

- Workspace identity is already bound to the JWT via per-workspace
signing secrets (`jwtWrapperService.generateAppSecret(tokenType,
workspaceId)`). Host-aware discovery does not weaken that.
- Custom domains are only accepted once `isCustomDomainEnabled = true`
(i.e. after DNS verification), so an attacker can't register a
custom-domain mapping on a workspace and have discovery reflect it
before it's been proven.
- Unknown / spoofed Hosts fall through to the default base URL.

## Drive-by

Fixed a duplicate `DomainServerConfigModule` import in
`application-oauth.module.ts` while adding `WorkspaceDomainsModule`.

## Companion infra change required for custom domains

Customer custom domains (`crm.acme.com/mcp`) also require an
ingress-level fix to exclude `/mcp`, `/oauth`, and `/.well-known` from
the `/s\$uri` rewrite applied when `X-Twenty-Public-Domain: true`.
Shipping that in a twenty-infra PR (will cross-link here).

## Test plan

- [x] 14 new tests in
`WorkspaceDomainsService.getValidatedRequestBaseUrl` covering: missing
Host, SERVER_URL, base URL, FRONTEND_URL, workspace subdomain, unknown
subdomain fallback, enabled custom domain, disabled custom domain,
public domain, completely unrecognized host, lowercase coercion,
malformed Host, single-workspace mode fallback, DB throwing → fallback
- [x] New `oauth-discovery.controller.spec.ts` covering both endpoints
across api / app / workspace-subdomain / custom-domain hosts, plus
`cli_client_id` propagation
- [x] Rewrote `mcp-auth.guard.spec.ts` to cover `WWW-Authenticate` for
all four host types (api, workspace subdomain, custom domain, spoofed
fallback)
- [x] `yarn jest
--testPathPatterns=\"workspace-domains.service|oauth-discovery.controller|mcp-auth.guard\"`
→ 41/41 passing
- [x] `tsc --noEmit` clean on all modified files
- [ ] Manual verification against staging: connect Claude to
`api.twenty.com/mcp`, `app.twenty.com/mcp`,
`<workspace>.twenty.com/mcp`, and a custom domain and confirm OAuth flow
completes on each

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 16:47:42 +02:00
Paul Rastoin 2413af0ba9 [run-instance-commands] Preserve fast slow sequentiality (#19757)
# Introduction
The command was wrongly running all fast and then all slow ignoring
instance commands segment
Leading to 
```ts
1.23.0_AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceCommand_1776090711153 executed successfully
[Nest] 32679  - 04/16/2026, 1:21:07 PM     LOG [InstanceCommandRunnerService] 1.23.0_DropWorkspaceVersionColumnFastInstanceCommand_1785000000000 executed successfully
[Nest] 32679  - 04/16/2026, 1:21:07 PM     LOG [InstanceCommandRunnerService] 1.22.0_BackfillWorkspaceIdOnIndirectEntitiesSlowInstanceCommand_1775758621018 executed successfully
[Nest] 32679  - 04/16/2026, 1:21:07 PM     LOG [RunInstanceCommandsCommand] Instance commands completed
```

No prod/self host impact as 1.23 hasn't been released yet
2026-04-16 12:14:47 +00:00
Raphaël Bosi 2b5b8a8b13 Link command menu items to specific page layout (#19706)
- Add a `pageLayoutId` foreign key to `CommandMenuItem`, allowing
command menu items to be scoped to a specific page layout instead of
being globally available
- Filter command menu items by the current page layout on the frontend.
Items with a `pageLayoutId` only appear when viewing that layout, while
items without one remain globally visible
- Create an effect to track the current page layout ID
- Include a seed example: a "Show Notification" command pinned to the
Star history standalone page layout

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-16 12:07:36 +00:00
martmull 381f3ba7d9 Fix app design 1/2 (#19735)
comply with
https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=96977-349627&m=dev

## After
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 40
37"
src="https://github.com/user-attachments/assets/6d80191a-79a9-4f0f-aa4f-0e447fff4f6d"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 40
22"
src="https://github.com/user-attachments/assets/4f763272-027e-4246-b455-7d46babf7d8c"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 39
11"
src="https://github.com/user-attachments/assets/b9b35e18-8068-447e-821d-5ec28bb5bd16"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 39
05"
src="https://github.com/user-attachments/assets/57d9318a-902f-4fd7-a2a3-5795ebe0b9dc"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 39
02"
src="https://github.com/user-attachments/assets/78a33fa8-6bdd-484e-a82d-bd0f7592a623"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 38
58"
src="https://github.com/user-attachments/assets/f7987aed-c6e1-4032-a611-86817655137d"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 38
55"
src="https://github.com/user-attachments/assets/d1c451ab-1d2d-41e4-a059-cf4303ecabe7"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 38
48"
src="https://github.com/user-attachments/assets/593cae36-2320-443f-a955-93b211a6ee3f"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 37
40"
src="https://github.com/user-attachments/assets/c9f602b1-8de3-4e82-a3a6-344594a0c153"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 37
34"
src="https://github.com/user-attachments/assets/b54ddddf-5dda-46c8-ace3-cffe6015825a"
/>

## before

<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42
18"
src="https://github.com/user-attachments/assets/c0976a0a-0124-48ec-8e7c-78627cea7063"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42
16"
src="https://github.com/user-attachments/assets/d2db926c-4040-411d-9091-8b60e7c519e6"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42
13"
src="https://github.com/user-attachments/assets/2d69f2ff-f26e-4249-91a3-2cf3d261e840"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42
07"
src="https://github.com/user-attachments/assets/1028aabc-77ac-4c51-a8c3-9a194faba87f"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42
01"
src="https://github.com/user-attachments/assets/1caa9f5e-3eaa-433c-9d3b-e0f094f16e8e"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 41
56"
src="https://github.com/user-attachments/assets/f42b6976-3a8f-4591-9283-bda79bdb424b"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 41
53"
src="https://github.com/user-attachments/assets/93d00df8-0091-4dfa-9ac0-f6f376be5962"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 41
43"
src="https://github.com/user-attachments/assets/9deae7e5-39c1-4518-a463-6d79bc5bf132"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 41
37"
src="https://github.com/user-attachments/assets/3e21b521-c47d-482c-ad41-66abfe973772"
/>
2026-04-16 09:47:44 +00:00
Paul Rastoin f5e8c05267 Add logs before and after instance slow data migration (#19753)
As it can take sometime, would result in not seeing any logs until
2026-04-16 09:08:37 +00:00
github-actions[bot] cddc47b61f i18n - translations (#19731)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-15 19:08:24 +02:00
Charles Bochet 446c39d1c0 Fix silent failures in logic function route trigger execution (#19698)
## Summary

Route-triggered logic functions were returning empty 500 responses with
zero server-side logging when the Lambda build chain failed. This PR
makes those failures observable and returns meaningful HTTP responses to
API clients.

- **Observability** — Log errors (with stack traces) at each layer of
the execution chain: `LambdaDriver` (deps-layer fetch, SDK-layer fetch,
invocation), `LogicFunctionExecutorService`, and `RouteTriggerService`.
- **Typed exceptions** — Replace raw `throw error` sites with
`LogicFunctionException` carrying an appropriate code and
`userFriendlyMessage` (new codes: `LOGIC_FUNCTION_EXECUTION_FAILED`,
`LOGIC_FUNCTION_LAYER_BUILD_FAILED`).
- **Correct HTTP semantics** — `RouteTriggerService` maps inner
exception codes to the right `RouteTriggerExceptionCode` so
`LOGIC_FUNCTION_NOT_FOUND` returns 404 and `RATE_LIMIT_EXCEEDED` returns
429 (new code + filter case) instead of a generic 500.
- **User-facing messages** — Forward the inner
`CustomException.userFriendlyMessage` when wrapping into
`RouteTriggerException`, without leaking raw internal error text into
the public exception message.
- **Infra** — Bump Lambda ephemeral storage from 2048 to 4096 MB to
prevent `ENOSPC` errors during yarn install layer builds (root cause of
the original silent failures).
2026-04-15 16:49:43 +00:00
Paul Rastoin a4cc7fb9c5 [Upgrade] Fix workspace creation cursor (#19701)
## Summary

### Problem

The upgrade migration system required new workspaces to always start
from a workspace command, which was too rigid. When the system was
mid-upgrade within an instance command (IC) segment, workspace creation
would fail or produce inconsistent state.

### Solution

#### Workspace-scoped instance command rows

Instance commands now write upgrade migration rows for **all
active/suspended workspaces** alongside the global row. This means every
workspace has a complete migration history, including instance command
records.

- `InstanceCommandRunnerService` reloads `activeOrSuspendedWorkspaceIds`
immediately before writing records (both success and failure paths) to
mitigate race conditions with concurrent workspace creation.
- `recordUpgradeMigration` in `UpgradeMigrationService` accepts a
discriminated union over `status`, handles `error: unknown` formatting
internally, and writes global + workspace rows in batch.

#### Flexible initial cursor for new workspaces

`getInitialCursorForNewWorkspace` now accepts the last **attempted**
(not just completed) instance command with its status:

- If the IC is `completed` and the next step is a workspace segment →
cursor is set to the last WC of that segment (existing behavior).
- If the IC is `failed` or not the last of its segment → cursor is set
to that IC itself, preserving its status.

This allows workspaces to be created at any point during the upgrade
lifecycle, including mid-IC-segment and after IC failure.

#### Relaxed workspace segment validation

`validateWorkspaceCursorsAreInWorkspaceSegment` accepts workspaces whose
cursor is:
1. Within the current workspace segment, OR
2. At the immediately preceding instance command with `completed` status
(handles the `-w` single-workspace upgrade scenario).

Workspaces with cursors in a previous segment, ahead of the current
segment, or at a preceding IC with `failed` status are rejected.

### Test plan
created empty workspaces to allow testing upgrade with several active
workspaces
2026-04-15 15:41:10 +00:00
Marie 2fccd194f3 [Billing for self host] End dummy enterprise key validity (#19560)
<img width="1504" height="755" alt="Screenshot 2026-04-10 at 16 40 07"
src="https://github.com/user-attachments/assets/68a12e40-a077-48df-9e18-885493520a32"
/>


Re-using hasValidEnterpriseKey to avoid breaking changes. 
This will be entirely removed in the next versions.
2026-04-15 13:26:38 +00:00
github-actions[bot] 77e5b06a50 i18n - translations (#19707)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-14 19:00:22 +02:00
Marie bc28e1557c Introduce updateWorkspaceMemberSettings and clarify product (#19441)
## Summary

Introduces a dedicated **metadata** mutation to update **standard
(non-custom)** workspace member settings, moves profile-related UI to
use it, and aligns **workspace member** record permissions with the rest
of the CRM so users cannot escalate visibility via RLS by editing their
own member record.

## Product behaviour

### Profile and appearance (standard fields)

- Users can still update **their own** standard workspace member fields
that the product exposes in **Settings / Profile** (e.g. name, locale,
color scheme, avatar flow) via the new
**`updateWorkspaceMemberSettings`** mutation.
- The mutation returns a **boolean**; the app **merges** the updated
fields into local state so the UI stays in sync without refetching the
full workspace member record.
- **Locale** changes also keep **`userWorkspace`** in sync when a locale
is present in the payload (including from the workspace `updateOne` path
when applicable).

### Custom fields on workspace members

- The dedicated metadata mutation **rejects** any **custom** workspace
member field (and unknown keys). Those updates must go through the
normal **object** `updateOne` pipeline, which is subject to **object-
and field-level** permissions like other records. But since we don't
have object- and field-level permission configuration for system objects
yet, this permission is derived from Workspace member settings
permission.
- **Workspace member** is no longer exempt from ORM permission
validation for updates merely because it is a **system** object. Users
who **do not** have workspace member access (e.g. no **Workspace
members** settings permission and no equivalent broad settings access on
the role) **cannot** use `updateOne` on `workspaceMember` to change
**custom** (or other) fields on their own row—even though that row is
used for RLS predicates.
- This closes a path where someone could widen what they can see by
writing to fields that drive row-level rules.

### Who can change another member

- Updating **another** user’s workspace member still requires
**Workspace members** (or equivalent) settings permission, consistent
with admin tooling.
2026-04-14 16:29:00 +00:00
github-actions[bot] 59a222e0f0 i18n - translations (#19703)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-14 18:38:55 +02:00
Félix Malfait 307b6c94de Align GraphQL error handling for billing and AI chat (#19690)
## What changed

This refactor fixes AI chat error surfacing by aligning both the backend
and frontend with the existing GraphQL error architecture instead of
adding AI-local error translation.

On the backend:
- add a dedicated GraphQL billing exception path
- register billing GraphQL handling globally for GraphQL requests
- reuse the existing AI GraphQL interceptor path for agent/chat
exceptions
- keep billing status classification shared between REST and GraphQL
- remove the earlier attempt to preserve `CustomException` metadata in
the global GraphQL fallback

On the frontend:
- keep the original Apollo GraphQL error object in AI chat state
- reuse shared Apollo/GraphQL helpers for user-facing messages and
error-type checks
- delete AI-specific error extraction helpers that duplicated generic
GraphQL parsing
- replace a few direct `extensions.subCode` call sites with a shared
predicate

## Why it changed

The original bug was that `BillingException` and AI exceptions thrown
from chat were not being translated into GraphQL errors with the
expected `extensions.subCode` and `extensions.userFriendlyMessage`, so
the AI chat UI had nothing structured to inspect.

An intermediate fix worked mechanically but pushed `CustomException`
handling into the global GraphQL fallback, which blurred the intended
layering. This PR moves the behavior back to explicit GraphQL edges.

## Root cause

`AgentChatResolver` could throw `BillingException` and `AgentException`,
but:
- billing had a REST exception filter and no shared GraphQL equivalent
- AI chat was not consistently using the same GraphQL exception
translation path as the sibling AI resolver
- the frontend chat UI had drifted into AI-specific error parsing
instead of consuming the same structured Apollo errors as the rest of
the app

## Impact

- `BILLING_CREDITS_EXHAUSTED` is now preserved through GraphQL and can
render the existing credits-exhausted UI in chat
- `API_KEY_NOT_CONFIGURED` is preserved through the AI GraphQL path
- AI chat now follows the same general GraphQL error consumption pattern
as the rest of the frontend
- billing GraphQL handling is less dependent on individual resolver
authors remembering to add a filter

## Validation

- `yarn jest --config packages/twenty-server/jest.config.mjs
packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/billing-graphql-api-exception-handler.util.spec.ts
packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/__tests__/agent-graphql-api-exception-handler.util.spec.ts`
- `yarn jest --config packages/twenty-front/jest.config.mjs
packages/twenty-front/src/utils/__tests__/is-graphql-error-of-type.util.test.ts`
- `npx oxlint --type-aware ...` on touched backend/frontend files
- `npx prettier --check ...` on touched backend/frontend files

## Follow-up ideas

- consolidate frontend GraphQL error helpers further so more existing
direct `extensions.subCode` checks move to shared utilities
- consider whether common GraphQL exception filter registration should
live in a more explicit GraphQL-specific module instead of
`CoreEngineModule`
- add an end-to-end test for a real `sendChatMessage` GraphQL failure
path in AI chat

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 18:31:59 +02:00
github-actions[bot] 9cf6f42313 i18n - translations (#19699)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-14 18:08:57 +02:00
Raphaël Bosi a88d1f4442 Introduce standalone page (#19675)
Add support for standalone pages: a new `PageLayout` type
(`STANDALONE_PAGE`) that can be rendered independently at
`/page/:pageLayoutId`, not tied to any record or object context.

- New `STANDALONE_PAGE` page layout type
- New `PAGE_LAYOUT` navigation menu item type: adds a `pageLayoutId`
foreign key to `NavigationMenuItemEntity`, allowing sidebar items to
link directly to standalone pages
- New `GLOBAL_OBJECT_CONTEXT` command menu availability type: separates
object-context-dependent commands (Create Record, Import, Export, See
Deleted, Create View, Hide Deleted) from truly global ones, so
standalone pages only show relevant commands
- Frontend routing & rendering: adds a `/page/:pageLayoutId` route with
its own page component, header, and command menu
- Widget rendering refactor
- Instance commands: two fast 1.22 migrations: `pageLayoutId` column +
`STANDALONE_PAGE` enum, and `GLOBAL_OBJECT_CONTEXT` availability type
enum
- Workspace command: backfills existing command menu items from `GLOBAL`
to `GLOBAL_OBJECT_CONTEXT` where appropriate
- Dev seeds: adds a sample "Star History" standalone page with an iframe
widget for local development
2026-04-14 15:52:45 +00:00
martmull b3354ab6e7 Fix multi-workspace-registration (#19685)
## Before

<img width="1512" height="915" alt="image"
src="https://github.com/user-attachments/assets/5cb05f76-b672-404e-b31d-ca455802f97a"
/>


## After

<img width="1512" height="726" alt="image"
src="https://github.com/user-attachments/assets/58229c4c-3ac6-4428-9c4d-3586a2b9ee36"
/>
2026-04-14 12:41:19 +00:00
Paul Rastoin 762de40c3d Improve upgrade registry logging for pre-release bundles (#19689)
```ts
Registered 3 fast instance command(s), 0 slow instance command(s), and 14 workspace command(s) for 1.21.0
Registered 5 fast instance command(s), 1 slow instance command(s), and 3 workspace command(s) for 1.22.0
Registered 1 fast instance command(s), 0 slow instance command(s), and 1 workspace command(s) for 1.23.0 (pre-release)
```
2026-04-14 12:33:47 +00:00
Paul Rastoin ef328755bb Bump current version to 1.23.0 (#19683) 2026-04-14 12:04:44 +00:00
Jeevan Mohan Pawar b194b67ac4 fix(address): populate street line from place details (#19326)
## Summary
- extract and expose `street` from Google place details (`street_number`
+ `route`) on the server DTO
- request and type `street` in front-end geo-map place details query
- use `placeData.street` as the preferred value for `addressStreet1` in
address autofill
- add regression coverage for query fields and street-line precedence
behavior

## Why
Address autocomplete selection currently writes full place text
(including city/state/postcode/country) into `addressStreet1`,
duplicating values already mapped to dedicated fields.

Fixes #18860

---------

Signed-off-by: jeevan6996 <jeevanpawar5890@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-14 11:44:36 +00:00
Raphaël Bosi eb13378760 Fix Quick Lead command menu item not appearing (#19635)
- Refactored prefillWorkflowCommandMenuItems and
prefillFrontComponentCommandMenuItems to use
validateBuildAndRunWorkspaceMigration instead of raw TypeORM
createQueryBuilder inserts
- This ensures the flat entity cache is properly updated when seeding
command menu items, fixing the Quick Lead item not appearing after
workspace creation
- Moved command menu item prefill calls outside the transaction since
they now go through the migration pipeline
2026-04-14 09:30:31 +00:00
Paul Rastoin 3e699c4458 Fix upgrade commands discovery outside of cli (#19671)
# Introduction
We were allowing the sequence to be empty in the worker context that was
facing an edge case importing the UpgradeModule through the
WorkspaceModule god module, no commands were discovered and it was
throwing as the sequence must have at least one workspace commands to
allow a workspace creation

Though the issue was also applicable to the twenty-server `AppModule`
too that was not discovering any commands

## Integration tests were passing
The integration test were importing the `CommandModule` at the nest
testing app creating leading to asymmetric testing context
It was a requirement for a legacy commands import and global assignation

## Fix
The `UpgradeModule` now import both `WorkspaceCommandsProviderModule`
and `InstanceCommandProviderModule` which ships the commands directly in
the module
We could consider moving the commands into the `engine/upgrade` folder

## Concern
Bootstrap could become more and more long to load at both server and
worker start
When this becomes a problem we will have to only import the latest
workspace command or whatever
For the moment this is not worth it the risk to import not the latest
workspace command
2026-04-14 09:20:33 +00:00
oniani1 d583984bf0 fix(api-key): batch role resolution with DataLoader to fix N+1 (#19590)
## Summary

The `role` @ResolveField on `ApiKeyResolver` calls `getRolesByApiKeys`
with a single-element array per API key. When a query returns N API
keys, this produces N separate DB queries to resolve their roles.

This adds an `apiKeyRoleLoader` to the existing DataLoader
infrastructure. All API key IDs in a single GraphQL request are
collected and resolved in one batched query.

- Before: N queries (one per API key)
- After: 1 query (batched via DataLoader)

## Changes

- `dataloader.service.ts` - new `createApiKeyRoleLoader` method,
delegates to `ApiKeyRoleService.getRolesByApiKeys`
- `dataloader.interface.ts` - `apiKeyRoleLoader` added to `IDataloaders`
- `dataloader.module.ts` - import `ApiKeyModule` so `ApiKeyRoleService`
is available
- `api-key.resolver.ts` - `role()` now uses
`context.loaders.apiKeyRoleLoader.load()` instead of calling the service
directly

## Test plan

- [ ] Verify `apiKeys { id role { label } }` query returns the same
results as before
- [ ] Confirm only 1 role_target query fires regardless of how many API
keys are returned

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-14 08:43:31 +00:00
Paul Rastoin 714f149b0c Move backfill page layout to 1.23 (#19670) 2026-04-14 08:39:24 +00:00
srijita2506 5fa3094800 test: fix failing useColorScheme test and remove FIXME (#19593)
Summary
This PR fixes a bug in the `useColorScheme` test suite and removes a
lingering `FIXME` comment where the color scheme was unexpectedly
unsetting during state updates.

Root Cause
Previously, the Jotai state was being initialized *inside* the
`renderHook` callback using `useSetAtomState`. When the `setColorScheme`
function was called, it triggered a hook re-render, which caused the
callback to execute again and overwrite the new state with the hardcoded
`'System'` initial state.

The Fix
- Removed the state initialization from inside the render cycle.
- Bootstrapped the state on a fresh store using `resetJotaiStore()` and
`store.set()` *before* rendering the hook.
- Updated the mock `workspaceMember` to correctly use the
`CurrentWorkspaceMember` type.
- Removed the `FIXME` comment and successfully asserted that the color
scheme updates to `'Dark'`.

Testing
Ran tests locally to confirm the fix works as expected:
`corepack yarn jest --config packages/twenty-front/jest.config.mjs
--testPathPattern=useColorScheme.test.tsx`

---------

Co-authored-by: Srabani Ghorai <subhojit04ghorai@gmail.com>
2026-04-14 06:40:40 +00:00
Hussain Arslan e041125426 fix: return 404 for deleted workspace webhook race (#19439)
Handle late TwentyORM workspace-not-found exceptions in the workflow
webhook REST exception filter so deleted workspaces return a 404 instead
of surfacing as internal errors.

Also add a focused regression spec covering the deleted-workspace ORM
codes and the existing workflow-trigger status mappings.

Closes #15544

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-13 22:38:36 +00:00
Félix Malfait 455022f652 Add ClickHouse-backed metered credit cap enforcement (#19586)
## Summary
Implements a ClickHouse-backed polling system to enforce metered-credit
caps for workflow executions, replacing reliance on Stripe billing
alerts. The system re-evaluates tier caps against live pricing on every
poll cycle, allowing price/tier changes to propagate immediately without
recreating Stripe alert objects.

## Key Changes

- **BillingUsageCapService**: New service that queries ClickHouse for
current-period credit usage and evaluates whether a subscription has
reached its metered-credit allowance (tier cap + credit balance)
  - `isClickHouseEnabled()`: Checks if ClickHouse is configured
- `getCurrentPeriodCreditsUsed()`: Sums creditsUsedMicro from usageEvent
table for a workspace within a billing period
- `evaluateCap()`: Determines if usage has reached the allowance by
reading live pricing from the subscription

- **EnforceUsageCapJob**: Cron job that polls all active subscriptions
and updates `hasReachedCurrentPeriodCap` on metered items
  - Runs every 2 minutes to keep cap enforcement in sync with live usage
- Supports shadow mode (log-only) via
`BILLING_USAGE_CAP_CLICKHOUSE_ENABLED` flag for safe rollout
- Continues processing after per-subscription errors with detailed
logging

- **EnforceUsageCapCronCommand**: CLI command to register the
enforcement cron job

- **MeteredCreditService**: Extracted
`extractMeteredPricingInfoFromSubscription()` as a pure function for
callers that already hold the subscription with pricing loaded, avoiding
redundant DB queries

- **Configuration**: Added `BILLING_USAGE_CAP_CLICKHOUSE_ENABLED` flag
to control enforcement mode (active vs. shadow)

- **Constants**: Added `METERED_OPERATION_TYPES` to define which
operation types count toward the metered product's credit cap

## Implementation Details

- The service queries ClickHouse for the sum of `creditsUsedMicro` in
the current billing period, matching Stripe meter semantics
- Pricing is re-read on every evaluation, so tier changes propagate
within one poll cycle without Stripe alert recreation
- The cron job only updates the database when the cap state actually
changes (no-op if already in the correct state)
- Shadow mode allows safe validation before enabling enforcement;
transitions are logged but not persisted
- Comprehensive test coverage for both the service and cron job,
including error handling and state transitions

https://claude.ai/code/session_01VksTSrYLXJVCPVBQhQdBTe

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 21:17:41 +02:00
Charles Bochet fa354b4c1c Remove orphaned workspaceId column from BillingSubscriptionItemEntity (#19660)
## Summary
- Removes the `workspaceId` column, `@Index()`, and `@ManyToOne`
workspace relation from `BillingSubscriptionItemEntity`
- The entity defined these fields but they don't exist in the actual
database table, causing `column X.workspaceId does not exist` errors at
runtime
- The workspace relationship is already accessible through the parent
`BillingSubscriptionEntity`
2026-04-13 17:53:33 +00:00
martmull 0894f2004b Remove app record if first install fails (#19659)
If application install fails, and if app was created, uninstall app

fixes
https://discord.com/channels/1130383047699738754/1491822937462804590
2026-04-13 17:39:59 +00:00
Paul Rastoin 87f5c0083f Prevent cross version upgrade mismatch in 1.22 (#19627)
## Introduction
As the new upgrade sequence engine is released in `1.22` it requires all
workspaces to be in `1.21.0` which mean they will have a cursor on the
sequence

As if if someone upgrades from `1.20` to `1.22` no `upgradeMigration`
will exist and throw a pretty basic `Could not find any cursor, database
might not been initialized correctly`

Here we allow a meaningful error
2026-04-13 14:53:12 +00:00
github-actions[bot] 9f6855e7dd i18n - translations (#19652)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-13 16:54:50 +02:00
neo773 88c0b24d9e Add per-workspace error handling to CronTriggerCronJob (#19640)
fix sonarly 19618
2026-04-13 13:59:54 +00:00