Commit Graph

713 Commits

Author SHA1 Message Date
Etienne eda41b4eba feat(ai) - add observability (#20850)
**AI Chat - Tool Executions (counters, tagged with model)**
ai-chat/tool-execution-succeeded: number of tool calls invoked by the AI
that completed without error
ai-chat/tool-execution-failed: number of tool calls invoked by the AI
that threw an error
**AI Chat - Token Usage (counters, tagged with model)**
ai-chat/input-tokens: total input tokens sent to the model across all
turns
ai-chat/output-tokens: total output tokens generated by the model
ai-chat/cache-read-tokens: input tokens served from the model's prompt
cache (cheaper)
ai-chat/cache-write-tokens: input tokens written into the prompt cache
for future reuse
**AI Chat - Latency (histograms in ms, tagged with model)**
ai-chat/turn-latency-ms: total duration of a full chat turn (from stream
start to stream end)
ai-chat/step-latency-ms: duration of a single reasoning/tool-call step
within a turn
ai-chat/ttft-ms: time-to-first-token, i.e. how long until the model
starts streaming output
**MCP - Tool Executions (counters)**
mcp/tool-execution-succeeded: number of MCP tool calls that completed
successfully
mcp/tool-execution-failed: number of MCP tool calls that threw an error
2026-05-22 15:32:51 +00:00
neo773 323e66433e lint: migrate prettier to oxfmt (#20783)
Most changes are `implements` being unwrapped this is not a oxfmt
regression
Prettier in 3.7 (we're on 3.1) changed this behaviour prettier blog
[post](https://prettier.io/blog/2025/11/27/3.7.0#change-18094)

This unifies our linting tooling

---------

Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-05-22 00:21:33 +02:00
Charles Bochet 1a9f786e42 refactor(filters): pass fieldMetadataItems array to dispatcher (#20737)
## Summary

Alternative to #20717. Same goal (clean up the filter dispatcher API
after #20670) but smaller and follows the codebase's "pass data, not
behavior" style.

The dispatcher takes a `fieldMetadataItems: FieldShared[]` array
directly instead of a `findFieldMetadataItemById: (id) => FieldShared |
undefined` callback. The util builds the id lookup internally — once per
call, used for both source-field and relation-target-field lookups. No
new types, no separate hydration step.

## What changes

**`twenty-shared`**
- `computeRecordGqlOperationFilter` /
`turnRecordFilterIntoRecordGqlOperationFilter` /
`turnRecordFilterGroupsIntoGqlOperationFilter`: replace
`findFieldMetadataItemById` param with `fieldMetadataItems` /
`fieldMetadataItemById` (internal Map).
- Remove the exported `FindFieldMetadataItemById` type.
- `turnAnyFieldFilterIntoRecordGqlFilter`: rename its internal
`fieldById` Map for consistency.
- Tests updated to pass arrays.

**Frontend (15 call sites)**
- Switch from `fieldMetadataItemByIdMapSelector` to
`flattenedFieldMetadataItemsSelector`.
- Pass `fieldMetadataItems: flattenedFieldMetadataItems` to the
dispatcher.
- `useFindManyRecordsSelectedInContextStore` keeps the Map selector
because it still does a per-filter lookup for the soft-delete check.

**Server (5 call sites)**
- Pass
`Object.values(flatFieldMetadataMaps.byUniversalIdentifier).filter(isDefined)`.

## Why this over #20717

#20717 moves resolution into a separate hydration step + introduces a
`HydratedRecordFilter` type. The bug that #20717 originally surfaced was
Sentry catching 4 critical runtime errors during review
(`fieldMetadataItemByIdMap` declared but not passed). The added type and
the explicit hydration boundary are extra surface area for not much
benefit — the existing API was a callback wrapping a Map at every call
site, and the natural simplification is to just pass the Map (or its
array) directly.

Net diff: **196 insertions, 203 deletions** (~7 lines net removed). 32
files.

## Test plan
- [x] Shared filter unit tests pass (461 tests)
- [x] Frontend filter/context-store tests pass (13 tests)
- [x] Frontend typecheck passes
- [x] Server typecheck passes
- [x] Lint passes (frontend + server)
- [ ] Integration tests on #20670 still pass — workflow find-records +
chart-data with relation-traversal filter still work end-to-end through
the new array param
2026-05-19 22:49:53 +02:00
Félix Malfait 291ce5ccdb fix(filters): make filter dispatcher own relation-target resolution (#20670)
## Summary

Two relation-traversal bugs surfaced post-merge of #20533, both rooted
in the same architectural smell: the GraphQL filter dispatcher took a
flat `fields: FieldShared[]` array and silently dropped any filter whose
`relationTargetFieldMetadataId` wasn't in that array. Callers had to
remember to pre-augment the list with relation targets — and 16+ call
sites did not all know this.

This PR fixes both bugs and removes the smell.

### Bug 1 — Save as new view loses the relation target

`useCreateViewFromCurrentView` built the create-filter input without
`relationTargetFieldMetadataId`. The saved view's filter persisted
without the traversal — on reload the chip showed "Company contains
'air'" instead of "Company → Name contains 'air'". Discarded at save
time, not at read time.

Fix: include `relationTargetFieldMetadataId` in the create input.
(Commit 1.)

### Bug 2 — Workflow Search Records drops one-hop traversals

`FindRecordsWorkflowAction` built its fields list from
`flatObjectMetadata.fieldIds` only (source object's fields). The shared
dispatcher then couldn't resolve the relation target field on the
related object and silently dropped the filter — a configured "People
where Company → Name Contains 'Airbnb'" came through as `{ and: [] }`.

This was the same shape as bugs already fixed in 5 other call sites
(chart filters, view filters, record table, etc.). The pattern was:
caller forgets to augment fields → dispatcher silently drops the filter.

Fix (commit 2): change the dispatcher to take a
`findFieldMetadataItemById: (id) => FieldShared | undefined` resolver
callback. Both source-field and relation-target-field lookups go through
the same resolver, so callers no longer need to know about the
augmentation requirement. Frontend callers pass a workspace-wide
resolver built from `flattenedFieldMetadataItemsSelector`; server
callers wrap `findFlatEntityByIdInFlatEntityMaps` on
`flatFieldMetadataMaps`. In both cases relation-target lookups just
work, because the resolver can see fields on related objects.

## Why this matters

Before: "if you call the dispatcher, pre-augment your fields list with
relation targets, or filters get silently dropped." An invariant only
enforceable by code review, broken often enough to ship two user-visible
bugs in one week.

After: the dispatcher resolves field ids itself. There's no list to
forget to augment. The failure mode (filter silently dropped) becomes
structurally impossible at the dispatcher boundary.

Net diff: 240 insertions, 319 deletions. Removed
`augmentFieldsWithRelationTargets` (frontend) and the workflow
whack-a-mole code (server).

## Test plan
- [ ] Save view: create an advanced filter using a one-hop relation
traversal, click "Save as new view", reload, confirm the chip still
reads "Source → Target operator value"
- [ ] Workflow: configure a Search Records action with a
relation-traversal filter, run the workflow, confirm the filter is
actually applied
- [ ] Dashboard chart: configure a chart with a relation-traversal
filter, confirm the chart data respects it
- [ ] Record table, group-by, calendar, total count, footer aggregates:
all continue to work with both plain and relation-traversal filters
2026-05-19 11:55:22 +02:00
Thomas des Francs d5e65c563e Add MCP tool annotations (#20672)
## Summary

Adds explicit MCP tool annotations for the Twenty MCP server so ChatGPT
app submission review can inspect the exposed tools without relying on
protocol defaults.

## Changes

- Adds one-export annotation constants for closed-world read-only tools,
open-world read-only tools, and `execute_tool`.
- Attaches annotations to the five exposed MCP tools:
`search_help_center`, `get_tool_catalog`, `learn_tools`, `execute_tool`,
and `load_skills`.
- Marks `search_help_center` as read-only and open-world because it
performs outbound help-center HTTP requests.
- Keeps `get_tool_catalog`, `learn_tools`, and `load_skills` read-only
and closed-world.
- Keeps `execute_tool` non-read-only, open-world, and destructive
because it can route to tools that create/update/delete records or send
email.
- Returns annotations through `tools/list` and updates MCP tests to
cover them.

No output schemas are included in this PR.

## Validation

- `git diff --check origin/main...HEAD`
- `jest --config packages/twenty-server/jest.config.mjs
packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-tool-executor.service.spec.ts
packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts
--runInBand`

Note: the Jest command was run with arm64 Node because the available
shared `node_modules` install contains the arm64 SWC native binding.
2026-05-18 12:14:34 +00:00
Shubham Singh 01535a3b3e fix(server): handle network errors in RestApiService catch block (#20644)
## Summary
- Added safe null check for `err.response?.data?.errors` in
`RestApiService.call()` catch block
- When the internal HTTP client fails with a network-level error
(ECONNREFUSED, timeout), `err.response` is `undefined` — accessing
`.data.errors` on it throws a `TypeError` which gets silently swallowed,
returning an empty 500
- Now falls back to throwing the raw error message for network failures
instead of crashing

## Changes
- `packages/twenty-server/src/engine/api/rest/rest-api.service.ts`

Fixes #20136

---------

Co-authored-by: Marie Stoppa <marie@twenty.com>
2026-05-18 09:51:42 +00:00
Félix Malfait c938fbf4d6 feat(twenty-front): relation traversal in filter dropdown (stacked) (#20533)
**Stacked on #20527** 




https://github.com/user-attachments/assets/48995655-401a-4c35-8094-e88da8408bdd





## Summary

Surfaces the one-hop relation traversal added in #20527 through the
existing **composite sub-field dropdown pattern**. Clicking a
MANY_TO_ONE relation field in the "+ Filter" picker now opens the same
second-level dropdown that composite fields (FULL_NAME, ADDRESS,
CURRENCY, etc.) already use — populated with the target object's
filterable fields. Picking one (e.g. `Company → Name`) builds a filter
that serializes to the nested GraphQL filter the backend now accepts: `{
company: { name: { ilike: "%X%" } } }`.

No new components. The whole feature reuses
`AdvancedFilterSubFieldSelectMenu` + the existing
`subFieldNameUsedInDropdownComponentState` + the existing `MenuItem
hasSubMenu` indicator. Only the conditions that gate the sub-menu (and
the sub-menu's content for relations) were broadened.

## What landed

| File | Change |
|---|---|
| `ObjectFilterDropdownFilterSelectMenuItem` | Sub-menu chevron now
shows on MANY_TO_ONE relations (`isManyToOneRelationField` util). |
| `AdvancedFilterFieldSelectMenu` | Relation clicks open the sub-menu
alongside composite clicks. |
| `AdvancedFilterSubFieldSelectMenu` | New branch: when the sub-menu
type is `'RELATION'`, render the target object's filterable fields via
`useFilterableFieldMetadataItems(targetObjectMetadataId)`. Composite
logic untouched. |
| `objectFilterDropdownSubMenuFieldType` state | Widened to accept a
`'RELATION'` sentinel. Role-permissions sub-field menu narrows it back
out (it doesn't traverse relations). |
| `useSelectFieldUsedInAdvancedFilterDropdown` | New optional
`targetFieldMetadataItem` arg. When present, the stored RecordFilter's
`type` is the target field's type so the operand picker and value input
render the target's operands (`'TEXT'` operators when filtering
`company.name`, etc.). |
| `turnRecordFilterIntoGqlOperationFilter` (shared) | When the filter
targets a `RELATION` field with a `subFieldName`, synthesize a
field-metadata for the target, recurse to build the inner filter, then
wrap it under the relation field's name → `{ relationName: {
targetFieldName: { ...operator } } }`. |

`RecordFilter.subFieldName` stays narrowly typed as
`CompositeFieldSubFieldName` so the wide downstream consumers
(`shouldShowFilterTextInput`, composite handlers in the serializer,
etc.) don't change. The relation target field's name is stored through a
narrowly-scoped cast at the dropdown's storage point — the serializer
checks `filter.type === 'RELATION'` before interpreting it as a target
field name, so the cast can't be mis-read by composite-only code paths.

## Test plan

- [ ] Open a table view on People, click "+ Filter", click "Company" →
sub-menu opens with Company's filterable fields
- [ ] Pick "Name" → operand picker shows TEXT operators (Contains,
Equals, …)
- [ ] Type "Airbnb" → filter applies, table shows people whose company
name contains "Airbnb"
- [ ] Verify network tab: the GraphQL filter variable is `{ company: {
name: { ilike: "%Airbnb%" } } }`
- [ ] Same flow with a composite target field (e.g. `Company →
annualRecurringRevenue → amountMicros`) — should work end-to-end
(backend supports composite-within-relation; #20527 has an integration
test covering this)
- [ ] Composite fields (FULL_NAME, ADDRESS) still open their normal
sub-menu and filter correctly — no regression
- [ ] Role-permissions field-select sub-field menu is unaffected (it
bails out early on the RELATION sentinel)

## Out of scope

- ONE_TO_MANY traversal (no backend support yet)
- Aggregates (`people.count > 5`)
- Persisting relation-traversal filters into a saved view (ViewFilter
has no `relationPath` column yet; that's a separate slice)
- REST API DSL changes
- AI Tools

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 16:42:47 +02:00
Félix Malfait af4765effe feat(twenty-server): one-hop relation filters in GraphQL API (#20527)
## Summary

Adds support for filtering records by fields on a related MANY_TO_ONE
object via the GraphQL API. Backend only — no frontend, no REST, no
view-filter persistence yet.

```graphql
{
  people(filter: { company: { name: { like: "%Airbnb%" } } }) {
    edges { node { id } }
  }
}
```

### Where the work lands

- **Schema** — `relation-field-metadata-gql-type.generator.ts` now emits
`{relationName}: TargetFilterInput` alongside the existing
`{joinColumnName}: UUIDFilter` for MANY_TO_ONE relations. Mirrors the
order-by generator that already does this for sort. Lazy thunks in
`object-metadata-filter-gql-input-type.generator.ts` handle the cycle
between filter inputs.
- **Arg processor** — `FilterArgProcessorService` no longer hard-rejects
accessing a relation by its name. When the value is a nested object on a
MANY_TO_ONE field, it recurses into the target object's metadata so each
leaf still gets validated and coerced. Depth-capped at 1.
- **Query parser** — new `parseRelationSubFilter` branch in
`graphql-query-filter-field.parser.ts`. When triggered: looks up the
target object metadata, calls `ensureRelationJoin` against the outer
query builder, and recurses via a child
`GraphqlQueryFilterConditionParser` scoped to the target.
`and`/`or`/`not` inside the relation filter keep working because the
child dispatches through the same `parseKeyFilter`.
- **Shared join utility** — `ensureRelationJoin.util.ts` is a single
function that inspects `queryBuilder.expressionMap.joinAttributes` for
the alias before adding a `LEFT JOIN`. Rewired the existing inline
`qb.leftJoin` calls in the order parser and group-by service to use it,
so filter-driven joins no longer collide with sort-driven joins on the
same relation.

### Out of scope (explicit)

- ONE_TO_MANY reverse traversal (needs EXISTS subqueries)
- Aggregates (`company.people.count > 5` — needs HAVING)
- View-filter storage (no `relationPath` column on `ViewFilterEntity`)
- REST DSL changes
- Frontend filter-picker UX
- Nesting deeper than one hop (parser and arg-processor both reject)

### Open question for review

Permissions. The order-by-on-relation code path already lets users sort
People by Company.name without a Company read-permission check, and this
PR matches that behavior for filters — felt wrong to add a stricter gate
only on the filter side. If we want object-permission gating on the
relation target, it should be a follow-up that covers both paths
consistently. The only attack surface today is existence inference via
timing, identical to what sort already exposes.

## Test plan

- [x] `tsc --noEmit` — clean for changed files (5 unrelated pre-existing
errors on main untouched)
- [x] `oxlint --type-aware` + `prettier --check` — 0 errors on all 17
changed/new files
- [x] `jest filter-arg-processor.service.spec` — 229 tests pass (the new
optional `flatObjectMetadataMaps` arg is backwards-compatible)
- [x] Integration test (`filter-by-relation-field.integration-spec.ts`,
6 cases) — needs to be verified against a seeded test DB. Could not
exercise the happy path in my isolated worktree; depth-2 rejection
passed there.
- [ ] EXPLAIN ANALYZE on the integration test query to confirm the FK on
`person.companyId` is indexed for both standard and custom MANY_TO_ONE
relations.

### Integration test cases

1. Filter People by `company.name = "Airbnb"` (exact match)
2. Filter People by `company.name like "%irbnb%"`
3. Non-matching filter returns empty
4. Combined with a scalar filter at root via `and`
5. **Combined with `orderBy` on the same relation** — proves the
join-dedupe works (without `ensureRelationJoin`, TypeORM throws
"duplicate alias")
6. Depth-2 nesting (`company.accountOwner.name`) returns
`INVALID_ARGS_FILTER`

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:45:32 +02:00
Weiko a47e1e0e5e Fix time consuming search ilike fallback (#20544)
## Context
When the tsvector full-text search returns 0 hits on the first page,
SearchService falls back to ILIKE '%word%' over searchVector::text. The
leading wildcard makes the GIN index unusable, so it seq-scans the
table.
On large searchable custom objects (e.g. a workspace with ~500k rows in
_logs) a single fallback can take 2–3s, multiplied across all searchable
objects in one request.

## Implementation
Wrap the fallback query in a tiny TypeORM transaction and apply a
Postgres per-statement timeout via set_config('statement_timeout', ms,
true) (= SET LOCAL). On timeout, Postgres throws 57014 (QUERY_CANCELED);
we catch it, warn-log with workspace/object context, and return [] for
that object

## Note
This PR bounds the slow fallback and doesn't make it fast. The right
structural fix is to let the fallback use an index. Since tsvector does
not work with certain language (which is the reason why the ILIKE
fallback was implemented in the first place), we should probably use the
pg_trgm extension instead (@FelixMalfait)

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-05-14 10:00:33 +00:00
Weiko 27fd124c2e Dedicated REST controllers for object & field metadata (#20364)
## Summary
- Replace the dynamic `RestApiMetadataController` (which parsed
`/rest/metadata/*path` and proxied to internal GraphQL) with two
dedicated controllers: `ObjectMetadataController` and
  `FieldMetadataController`.
- Drop the GraphQL hop: reads hit Postgres directly via TypeORM
repositories; writes call the existing
`{create,update,delete}One{Object,Field}` service methods.
- Introduce a new clean response shape behind a workspace feature flag
(`IS_REST_METADATA_API_NEW_FORMAT_DIRECT`) — see grace period below.
- Update the OpenAPI spec so the REST playground reflects the (default)
legacy shape during the grace period.

  ## Why
The legacy metadata controller was over-complex: it routed every method
through a path parser, a set of GraphQL query-builder factories, an
internal GraphQL call, and a
`cleanGraphQLResponse` post-processor. Operation names from GraphQL
(`createOneObject`, `updateOneField`, …) leaked straight into REST
responses. The internal-GraphQL hop also gave us
nothing on metadata reads — pagination, filtering, and serialization all
happen against the same Postgres tables either way.

  ## Feature flag & grace period
  `IS_REST_METADATA_API_NEW_FORMAT_DIRECT` (workspace-scoped):
- **Existing workspaces:** flag absent → resolves to `false` → **legacy
response shape** (no behavior change).
- **Newly created workspaces:** flag seeded to `true` via
`DEFAULT_FEATURE_FLAGS` → **new response shape** from day one.
- **Toggle:** support-assisted (no frontend); customers contact us to
opt into the new shape early.
- **Removal:** the flag, the legacy adapter utils
(`to-legacy-{object,field}-metadata-response.util.ts`), and the
parametrized test wrapper get deleted after the grace window. New shape
becomes the only shape; OpenAPI flips to new shape; POST loses the
conditional and reverts to a declarative response.

  ## Response shapes

| Operation | Legacy (flag OFF, default for existing) | New (flag ON) |
|-----------|-----------------------------------------|---------------|
| `GET /rest/metadata/objects` | `{ data: { objects: [...] }, pageInfo,
totalCount }` | `{ data: [...], pageInfo, totalCount }` |
| `GET /rest/metadata/objects/:id` | `{ data: { object: {...} } }` | `{
... }` |
| `POST /rest/metadata/objects` | `201 { data: { createOneObject: {...}
} }` | `201 { ... }` |
| `PATCH/PUT /rest/metadata/objects/:id` | `{ data: { updateOneObject:
{...} } }` | `{ ... }` |
| `DELETE /rest/metadata/objects/:id` | `{ data: { deleteOneObject: {
... } } }` | `{ ... }` |

Same matrix for `/rest/metadata/fields`. Cursor params
(`starting_after`, `ending_before`, `limit`) and `totalCount` are
preserved across both shapes. POST returns `201` in both (old
  controller already did — the doc on main saying `200` was wrong).

  ## Implementation notes
- Reads go straight to Postgres with TypeORM cursor pagination
(`paginateByIdCursor` util, mutually-exclusive `starting_after` /
`ending_before`). No cache on this path — caching +
  filterable pagination didn't combine cleanly.
- Object endpoints inline `fields[]` via a single follow-up `WHERE
objectMetadataId IN (...)` query.
- Controllers read the flag via `FeatureFlagService.isFeatureEnabled`
and conditionally pass the result through a legacy-shape adapter util
before returning.
- Per-domain REST exception filters
(`{Object,Field}MetadataRestApiExceptionFilter`); the `exceptionCode →
httpStatus` switch is extracted to a util so it can be merged with the
  existing GraphQL handler later.
- New controllers live inside the metadata domain modules
(`metadata-modules/{object,field}-metadata/controllers/`) to match
existing precedent (view-field, view, page-layout, …).
- Removes: `RestApiMetadataController`, `RestApiMetadataService`,
`metadata/query-builder/`, `clean-graphql-response.utils.ts`.
- Integration tests are parametrized over both flag values via
`describe.each` — both shapes are asserted in CI.
- OpenAPI fixes inherited from the migration (kept as-is): documents
flat `fields: [...]` rather than the obsolete `{edges:{node:[...]}}`
wrapping; always emits `totalCount`; POST
status `201`. These match what customers actually receive on both
shapes.

Note: Next goal is to implement something similar for graphql and remove
nestjs-query dependency for those 2 entities, then generalise it.
Note2: We have the same issue with Core Rest API such as
```json
{
  "data": {
    "createCompany": {
      "id": "123e4567-e89b-12d3-a456-426614174000",
      "createdAt": "2026-05-07T12:14:52.769Z",
      "updatedAt": "2026-05-07T12:14:52.769Z",
      "deletedAt": "2026-05-07T12:14:52.769Z",
   ...
```
with "createCompany" here which is odd compared to REST standards (FYI
@etiennejouan @charlesBochet)

## Before (Without feature flag)
<img width="1346" height="712" alt="Screenshot 2026-05-12 at 20 50 38"
src="https://github.com/user-attachments/assets/316ce225-1045-4aac-97a9-60fd537eb1ec"
/>
<img width="1378" height="729" alt="Screenshot 2026-05-12 at 20 52 24"
src="https://github.com/user-attachments/assets/a621ab6f-e4f8-44d5-817c-1efd25d33c30"
/>

## After (With feature flag)
<img width="1376" height="728" alt="Screenshot 2026-05-12 at 20 50 46"
src="https://github.com/user-attachments/assets/2424d9c5-e4ed-497c-8e5c-6b54d78675e4"
/>
<img width="1375" height="727" alt="Screenshot 2026-05-12 at 20 51 47"
src="https://github.com/user-attachments/assets/101d957f-38ed-45d9-ab7b-f4f4eb983397"
/>

---------

Co-authored-by: prastoin <paul@twenty.com>
2026-05-13 12:31:16 +00:00
MkDev11 bbc55193f5 Fix phone unique constraints (#20261)
## Summary

Closes #20195

Fix phone field unique constraints so phone numbers are considered
unique by both `primaryPhoneNumber` and `primaryPhoneCallingCode`.

- Include `primaryPhoneCallingCode` in the shared phone composite unique
constraint metadata
- Align the frontend settings composite field config with the backend
metadata
- Return all included unique composite subfields when building
create-many conflict fields
- Match composite unique conflict fields as a group during create-many
upserts

## Root Cause

Phone composite metadata only marked `primaryPhoneNumber` as part of the
unique constraint. That made different international phone numbers with
the same national number conflict, for example `+1 123456789` and `+32
123456789`.

## Test Plan

- `yarn workspace twenty-shared build`
- `jest --runTestsByPath <index action handler and create-many utility
specs>`
- `prettier --check <touched files>`
- `oxlint --type-aware <touched files>`
- `nx run twenty-shared:typecheck`
- `nx run twenty-server:typecheck`
- `nx run twenty-front:typecheck`

---------

Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: prastoin <paul@twenty.com>
2026-05-13 11:57:19 +00:00
Charles Bochet ac653182b2 feat(server): migrate all remaining JWT token types to ES256 (#20513)
## Summary

Extends the asymmetric signing work from #20467 to cover **every
remaining `JwtTokenTypeEnum` value**: `LOGIN`, `WORKSPACE_AGNOSTIC`,
`FILE`, `API_KEY`, `APPLICATION_ACCESS`, `APPLICATION_REFRESH`,
`APP_OAUTH_STATE`, plus the ACCESS-shaped session token issued by the
code interpreter tool.

After this PR, every JWT the server signs is ES256 with a `kid` pointing
at the current `core."signingKey"` row, while legacy HS256 tokens (no
`kid` header) remain verifiable indefinitely through the existing
fallback in `JwtWrapperService.resolveVerificationKey`. No new entity /
migration / config: this is a pure routing change on top of the
infrastructure that already shipped.

## Why

`#20467` only flipped `ACCESS` and `REFRESH` to ES256. Every other JWT
type was still HS256-signed against the global `APP_SECRET`, which kept
the original blast radius (a leaked `APP_SECRET` invalidates *every* JWT
type forever). Migrating the rest unifies the sign path on rotatable
per-server private keys without forcing any token reissue.

## Mechanical changes

### Sign side (8 services)
- `LoginTokenService.generateLoginToken`
- `TransientTokenService.generateTransientToken`
- `WorkspaceAgnosticTokenService.generateWorkspaceAgnosticToken`
- `ApplicationTokenService.signApplicationToken` (`APPLICATION_ACCESS` +
`APPLICATION_REFRESH`)
- `ApiKeyService.generateApiKeyToken`
- `FileUrlService.signFileByIdUrl` / `signWorkspaceLogoUrl`
- `ConnectionProviderOAuthFlowService.signState` (`APP_OAUTH_STATE`)
- `CodeInterpreterTool.generateSessionToken`

Each call site swaps `jwtWrapperService.sign(payload, { secret:
generateAppSecret(...), ... })` for `await
jwtWrapperService.signAsync(payload, { expiresIn, [jwtid] })`. The
`generateAppSecret` calls on the sign side are dropped (verifier-side
`generateAppSecret` stays in `resolveVerificationKey` for the HS256
fallback).

### Verifier side
- `WorkspaceAgnosticTokenService.validateToken` now goes through
`verifyJwtToken` instead of the bespoke `verify({ secret })` path, so
new ES256 tokens are accepted while the legacy HS256 fallback inside
`resolveVerificationKey` still serves the old shape.
- `JwtWrapperService.sign()` is kept (legacy compat / tests) but is now
strictly deprecated — there are no remaining production callers.

### Async ripple (`signFileByIdUrl` was synchronous)
- `FileUrlService.signFileByIdUrl` and `signWorkspaceLogoUrl` are now
`async`; the `signUrl` callback used by `getRecordImageIdentifier` is
widened to accept `Promise<string | null>`.
- Every direct/indirect caller is updated: admin panel (user lookup +
statistics + top workspaces), search service
(`computeSearchObjectResults`, `getImageIdentifierValue`), workspace
resolver (`logo` resolver, public workspace by domain/id),
`WorkspaceMemberTranspiler` (now `async toWorkspaceMemberDto[s]` /
`toDeletedWorkspaceMemberDto[s]` / `generateSignedAvatarUrl`),
`UserService.loadSignedAvatarUrlsByUserId`,
`UserWorkspaceService.castWorkspaceToAvailableWorkspace`,
workspace-invitation, approved-access-domain, agent-chat-streaming,
agent-message-part resolver, navigation-menu-item record identifier,
file-ai-chat / file-core-picture / file-email-attachment / file-workflow
/ files-field services, rich-text & files-field query result getters,
and the code-interpreter tool.

## Backward compatibility

- **Legacy HS256 tokens (no `kid`)** keep verifying via
`resolveVerificationKey` → `extractAppSecretBody` → `generateAppSecret`
for both `workspaceId`-bearing and `userId`-bearing payloads.
- The `API_KEY` HS256-via-ACCESS-secret fallback (#16504) still kicks in
inside `verifyJwtToken` for pre-2025-12-12 API keys.
- No payload shape changes, no DB writes, no env var changes — old
tokens issued by `main` continue to authenticate.

## Tests

### Unit (all green locally — 63/63)
Updated specs for every migrated service to mock `signAsync` instead of
`sign` and assert the new option shape:
- `login-token.service.spec.ts`, `transient-token.service.spec.ts`,
`workspace-agnostic-token.service.spec.ts`,
`application-token.service.spec.ts`, `api-key.service.spec.ts`,
`connection-provider-oauth-flow.service.spec.ts`.

### Integration (`jwt-key-rotation.integration-spec.ts`)
- Existing ACCESS coverage (current key, legacy HS256 fallback,
rotated-out key, revoked key, unknown kid) is preserved.
- New `it.each` assertion: `REFRESH`, `WORKSPACE_AGNOSTIC`, and `LOGIN`
tokens emitted by the real signUp → signUpInNewWorkspace →
getAuthTokensFromLoginToken pipeline are ES256 with a `kid` matching the
current signing key — proves end-to-end that the migration didn't
regress those flows.

## Open question (separate decision)

This PR keeps the legacy HS256 verification fallback **forever**. We may
eventually want to sunset it for `API_KEY` once telemetry shows
pre-migration tokens are gone, but that's a separate product/security
decision and not part of this change.

## Test plan

- [ ] CI green
- [ ] `npx nx lint:diff-with-main twenty-server` passes
- [ ] `npx nx typecheck twenty-server` passes
- [ ] `jwt-key-rotation` integration suite passes (new + existing
assertions)
- [ ] Manually verify: signing in issues an ES256 ACCESS / REFRESH
token, generating an API key issues an ES256 token with `kid`, signed
file URL JWT is ES256 with `kid`
- [ ] Pre-existing HS256 tokens still authenticate (covered by
integration test, but worth a manual check with a token from `main`)
2026-05-13 10:53:45 +00:00
Paul Rastoin a159a68e2c [twenty-server] no floating promises lint rule (#20499)
## Introduction

That's an audit + RFC

## Fire-and-forget (`void`) -- Intentional, correct

These are telemetry, metrics, and audit logging in hot paths or
non-critical contexts. `void` is the right choice.

| File | What was voided |
|---|---|
| `sign-in-up.service.ts` | `metricsService.incrementCounter` (sign-up
metric) + `auditService.insertWorkspaceEvent` (workspace created) |
| `use-graphql-error-handler.hook.ts` | 5x
`metricsService.incrementCounter` (GraphQL operation metrics) |
| `bullmq.driver.ts` | 2x `metricsService.incrementCounter` (job
completed/failed metrics) |
| `call-webhook.job.ts` | 2x `auditService.insertWorkspaceEvent` + 1x
`metricsService.incrementCounter` |
| `custom-domain-manager.service.ts` | `analytics.insertWorkspaceEvent`
(domain activation event) |
| `logic-function-executor.service.ts` |
`auditService.insertWorkspaceEvent` (function execution) |
| `workflow-runner.workspace-service.ts` |
`metricsService.incrementCounter` (throttle metric) |
| `cleaner.workspace-service.ts` | `metricsService.incrementCounter`
(deleted workspace metric) |
| `stream-agent-chat.job.ts` | Detached IIFE for streaming chunks
(intentional concurrent pipeline) |
| `workspace-auth-context.middleware.ts` |
`withWorkspaceAuthContext(...)` (AsyncLocalStorage, returns void anyway)
|

## Top-level script entry points (`void bootstrap()`)

These are module-level calls where the promise has no consumer. `void`
makes the lint rule happy and documents the intent.

| File | What changed |
|---|---|
| `main.ts` | `void bootstrap()` |
| `command.ts` | `void bootstrap()` |
| `queue-worker.ts` | `void bootstrap()` |
| `truncate-db.ts` | `void dropSchemasSequentially()` |
| `codegen/index.ts` | `void generateTests(forceArg)` |

## Now properly awaited -- Real bug fixes

These were floating promises that could silently fail, lose data, or
cause race conditions.

| File | What was fixed |
|---|---|
| `billing-sync-plans-data.command.ts` | `meters.map(async ...)` wrapped
in `Promise.all` -- was returning before upserts finished |
| `cache-storage.service.ts` | `setAdd` and `setPop` had `.then()`
chains that weren't returned/awaited |
| `create-audit-log-from-internal-event.ts` | 4x
`auditService.createObjectEvent` now awaited inside a job |
| `cleaner.workspace-service.ts` | 2x `emailService.send(...)` now
awaited -- emails could silently fail |
| `agent-async-executor.service.ts` | `calculateAndBillUsage` +
`billNativeWebSearchUsage` in `finally` block now awaited |
| `repair-tool-call.util.ts` | `calculateAndBillUsage` now awaited |
| `agent-title-generation.service.ts` | `calculateAndBillUsage` now
awaited |
| `chat-execution.service.ts` | `billNativeWebSearchUsage` now awaited |
| `ai-generate-text.controller.ts` | `calculateAndBillUsage` in
`finally` block now awaited |
| `agent-turn.resolver.ts` | `messageQueueService.add(...)` now awaited
|
| `command.ts` | `app.close()` now awaited (was exiting before graceful
shutdown) |
| `i18n.service.ts` | `loadTranslations()` in `onModuleInit` now awaited
|
| `workspace-query-hook.explorer.ts` | `explore()` in `onModuleInit` now
awaited |
| `message-queue.explorer.ts` | `handleProcessorGroupCollection` in
`onModuleInit` now awaited |
| `ai-billing.service.spec.ts` | Test now properly `await`s the async
call |
| `messaging-messages-import.service.spec.ts` | `expect(...)` now
properly `await`ed for async assertion |
| `archive.finalize()` (3 files) | Voided -- promise resolution already
handled by `pipeline()` / `on('end')` |

## Impersonation & security audit trail -- Upgraded from `void` to
`await`

These were previously fire-and-forget but are
security/compliance-critical events that must be reliably persisted.

| File | What was fixed |
|---|---|
| `impersonation.service.ts` | 4x `auditService.insertWorkspaceEvent`
now awaited (impersonation attempt, token generation
attempt/success/failure) |
| `auth.resolver.ts` | 5x `auditService.insertWorkspaceEvent` now
awaited (impersonation token exchange attempt/success/failure at server
and workspace levels) |
| `auth.service.ts` | 2x `analytics.insertWorkspaceEvent` now awaited
(impersonation attempted/issued) |

## Billing audit -- Upgraded from `void` to `await`

Payment events should be reliably persisted for financial/compliance
reporting.

| File | What was fixed |
|---|---|
| `billing-webhook-invoice.service.ts` |
`auditService.insertWorkspaceEvent(PAYMENT_RECEIVED_EVENT)` now awaited
inside Stripe webhook handler |

## Fire-and-forget with proper error handling -- Upgraded from bare
`void`

These remain non-blocking but now catch and log errors instead of
risking unhandled rejections.

| File | What was fixed |
|---|---|
| `logic-function-executor.service.ts` |
`applicationLogsService.writeLogs` now uses `.catch()` instead of bare
`void` -- user-facing logs should surface errors |

## Systemic infrastructure fixes

| File | What was fixed |
|---|---|
| `metrics.service.ts` | `incrementCounter`: Redis cache write
(`metricsCacheService.updateCounter`) now uses `.catch()` internally
instead of raw `await` -- prevents unhandled rejections across all `void
metricsService.incrementCounter(...)` call sites when Redis is unhealthy
|
| `audit.service.ts` | `preventIfDisabled`: made properly `async` with
`await` and consistent `Promise<{ success: boolean }>` return type.
Removed broken `catch` that returned an `AuditException` as a value
(wrong constructor args, unreachable dead code). Removed unused
`AuditException` import |

## Fixed in this session (beyond original PR)

| File | What changed |
|---|---|
| `telemetry.listener.ts` | Removed misleading `Promise.all` + `void`
combo; replaced with simple `for...of` + `void` |
| `message-queue.explorer.ts` | Changed from `void` to `await` so
startup crashes on registration failure |
2026-05-13 10:08:42 +00:00
Charles Bochet 10876138d2 refactor: stop reading joinColumnName from relation field settings (#20304)
## Summary

`joinColumnName` on relation field settings is always derivable from the
field name (and the target object name for morph relations). This PR
stops reading it from settings anywhere in production code; the stored
value is no longer used.

The settings field is **not** removed from data yet — a follow-up can
drop it once we are confident nothing depends on the stored value.

## Helpers

The helpers are split by layer because frontend and backend hold morph
relations differently: the frontend has a base name plus a
`morphRelations[]` array, the backend has one row per target with the
name already morph-resolved.

| Helper | Layer | When to use |
|---|---|---|
| `computeRelationGqlFieldJoinColumnName` | Shared / frontend
(`gqlField`) | Non-morph relation on the frontend. |
| `computeMorphRelationGqlFieldName` | Shared / frontend (`gqlField`) |
Need the per-target morph gqlField name (e.g. `targetCompany`). |
| `computeMorphRelationGqlFieldJoinColumnName` | Shared / frontend
(`gqlField`) | Per-target morph join column on the frontend. Prefer over
the non-morph helper for any morph field — it forces the per-target
inputs. |
| `computeMorphOrRelationFieldJoinColumnName` | Backend
(`FlatFieldMetadata.name`) | Any backend read or write — the flat name
is already morph-resolved, so one helper covers both cases. |
| `computeMorphRelationFlatFieldName` | Backend
(`FlatFieldMetadata.name`) | **Mutation paths only** (create / update /
object rename). Reads consume the stored `field.name` and never call
this. |

## Test plan

- [x] Typecheck and lint (front, server, shared)
- [x] Existing unit tests pass
- [ ] CI green
2026-05-07 09:53:00 +02:00
martmull 617f571400 20215 convert application variable to a syncable entity (#20269)
##  Summary

- Converts applicationVariable from a bespoke sync path to a proper
SyncableEntity,
unifying it with the workspace migration pipeline used by all other
manifest-managed
  entities (agent, skill, frontComponent, webhook, etc.)
- Removes the upsertManyApplicationVariableEntities method and its
direct-DB-mutation
approach in favor of the standard validate → build → run action handler
pipeline
- Adds universalIdentifier, deletedAt columns and makes applicationId
NOT NULL via an
  instance command migration

##  Motivation

Before this change, applicationVariable was the only manifest-managed
entity that bypassed
ApplicationManifestMigrationService.syncMetadataFromManifest(). It used
a bespoke service
method called directly from syncApplication(), creating two mental
models, two validation
styles, and two cache invalidation patterns. Now there's one unified
pipeline for all
  manifest entities.

##  What changed

###  Entity refactor:
- ApplicationVariableEntity now extends SyncableEntity (gains
universalIdentifier,
  non-nullable applicationId with CASCADE, soft-delete via deletedAt)

###  New flat entity layer (flat-application-variable/):
- Type, maps type, editable properties constant, entity-to-flat
converter, cache service,
  module

###  New migration pipeline wiring:
- Manifest converter
(fromApplicationVariableManifestToUniversalFlatApplicationVariable)
  - Validator service (FlatApplicationVariableValidatorService)
- Builder service
(WorkspaceMigrationApplicationVariableActionsBuilderService)
  - Create/Update/Delete action handlers with secret encryption hooks
- Registered in orchestrator, builder module, runner module, and all
type registries

###  Removed bespoke path:
- Deleted upsertManyApplicationVariableEntities from
ApplicationVariableEntityService
  - Removed its call from ApplicationSyncService.syncApplication()
- Kept update() (operator-set value at runtime) and getDisplayValue()
(runtime display)

###  Database migration:
- Instance command to add columns, backfill universalIdentifier, enforce
NOT NULL
  constraints, and update indexes

##  Test plan

  - npx nx typecheck twenty-server passes (0 errors)
- Unit tests pass (application-variable.service.spec.ts,
build-env-var.spec.ts)
- Install an app with applicationVariables in its manifest → variables
appear with correct
  universalIdentifier
- Update app manifest (add/remove/modify a variable) → migration
pipeline handles diff
  correctly
- Operator-set value via update endpoint persists correctly with
encryption
  - Uninstall app → variables cascade-deleted
  - app dev --once on example app syncs without errors
2026-05-06 08:23:53 +00:00
Charles Bochet 7c4302d02a fix: show empty cell instead of 'Not shared' for soft-deleted related records (#20260)
## Summary

Fixes #20076 (supersedes #20250)

When a related record is soft-deleted, the frontend displays "Not
shared" (lock icon) because it sees a populated FK but a null relation
object. This is misleading -- the record was deleted, not
permission-restricted.

**Backend fix** (`process-nested-relations-v2.helper.ts`):
- For MANY_TO_ONE relations, widen the relation query with
`.withDeleted()` and include `deletedAt` in the select
- In `assignRelationResults`, if the matched record has `deletedAt` set,
nullify both the FK and the relation object in the API response
- Records filtered by RLS are still not returned (even with
`withDeleted()`), so they correctly continue to show "Not shared"
- Strip `deletedAt` from relation results before returning to the client

**Frontend fix** (`RelationFromManyFieldDisplay.tsx`):
- For ONE_TO_MANY junction relations, return `null` instead of
`<ForbiddenFieldDisplay />` when junction records exist but target
records are unavailable

### Three cases now handled correctly:

| Scenario | FK in response | Relation object | Frontend display |
|---|---|---|---|
| **Live record** | `"abc"` | `{ id: "abc", ... }` | Record chip |
| **Soft-deleted record** | `null` | `null` | Empty cell |
| **RLS-hidden record** | `"abc"` | `null` | "Not shared" |

## Test plan

- [ ] Create a record with a MANY_TO_ONE relation (e.g., a person linked
to a company)
- [ ] Soft-delete the related record (the company)
- [ ] Verify the relation field shows an empty cell, not "Not shared"
- [ ] Restore the related record and verify the relation reappears
- [ ] Verify that RLS-hidden relations still show "Not shared"

Made with [Cursor](https://cursor.com)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 09:32:16 +00:00
Kartik Pant e6399b180e Fix/workspace member avatars 20193 (#20200)
Fixes #20193

**Bug Description:**
Previously, workspace member avatars failed to render correctly in table
views and relation chips (such as the Account Owner field). While the
avatar picker dropdown correctly fetched fresh GraphQL data, table views
and chips relied on the cached defaultAvatarUrl or avatarUrl fields,
which were frequently resolving to empty strings or failing to parse
external OAuth URLs correctly.

**Root Cause:**

- Empty String Defaults: Deleting an avatar or failing to retrieve one
defaulted the database state to an empty string ("") instead of null,
which caused frontend image components to break rather than render their
fallback states.

- Missing Permanent URLs: The WorkspaceMemberTranspiler was strictly
expecting internal signed URLs. If an avatar was an external OAuth URL,
it incorrectly returned an empty string, breaking SSO profile pictures.

- Missing Fallbacks: New users lacked a proper Gravatar fallback
assignment upon workspace creation.

**Changes Made:**

- user-workspace.service.ts: Updated the avatar computation logic during
user creation to implement a reliable Gravatar fallback and correctly
set missing avatars to null instead of empty strings. Updated the
storage to use permanent file URLs.
- file-url.service.ts: Implemented a getRawFileUrl method to support
rendering permanent, non-expiring file URLs for avatars.
- workspace-member-transpiler.service.ts: Refactored the URL
transpilation logic to gracefully pass through external OAuth URLs
(e.g., Google/Microsoft profile pictures) instead of stripping them.
- WorkspaceMemberPictureUploader.tsx: Fixed the frontend removal logic
so that deleting a profile picture sets the avatarUrl to null
(consistent with the backend) rather than an empty string.

**Testing:**

- Verified that avatars correctly display in relation chips and table
views.
- Verified that external OAuth avatars load properly.
- Verified that deleting an avatar correctly resets the UI to the
fallback initials component.

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 19:42:12 +00:00
Sri Hari Haran Sharma 281eaa3721 fix rest filter default conjunction detection (#20133)
Fixes #20128 

## Summary

Fix REST API filter parsing when bare filters are mixed with explicit
conjunctions.

## What changed

- Replaced the loose parentheses check in
`addDefaultConjunctionIfMissing` with proper root conjunction detection.
- Shared the root conjunction regex with `parseFilter`.
- Added regression tests for mixed filters like
`status[eq]:'TODO',and(title[ilike]:'%test%')`.

## Validation

- `npx nx test twenty-server
--testPathPatterns=add-default-conjunction.util.spec.ts --runInBand
--coverage=false`
- `npx prettier --check ...`
2026-05-04 16:18:56 +00:00
Ratish jain 3290bf3ab1 fix(rest-api): prevent silent pagination failures and include valid options in enum validation errors (#20092)
# Summary 
(fixes #20044)

This PR implements two fixes for the REST API to enforce stricter
validation and provide better error messages.

Issue 1: Cursor parameter silently ignored
Problem: When users provided common cursor aliases (e.g., cursor, after,
before) instead of the correct parameter names (starting_after,
ending_before), the API silently ignored them and returned page 1 on
every request.
Solution: Added strict validation to detect common cursor aliases and
throw a clear error directing users to use the correct parameter names.
Files modified:
-
packages/twenty-server/src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception.ts
-
packages/twenty-server/src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-rest-request.util.ts
-
packages/twenty-server/src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util.ts
- Test files for both parsers
Example error:
Invalid cursor parameter 'cursor'. Use 'starting_after' for pagination.
---
Issue 2: OpportunityStageEnum not validated on REST
Problem: When creating or updating opportunities via REST with an
invalid stage value, the API either silently dropped the value or
returned a generic error without listing valid options.
Solution: Updated the SELECT field validation to include valid options
in the error message.
Files modified:
-
packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-rating-and-select-field-or-throw.util.ts
- Test file
Example error:
Invalid value "BAD_VALUE" for field "stage". Valid values are: NEW,
SCREENING, MEETING, PROPOSAL, CUSTOMER
---
### Testing
- Added 5 new test cases for `parse-starting-after-rest-request.util.ts`
- Added 6 new test cases for `parse-ending-before-rest-request.util.ts`
- Added 1 new test case for
`validate-rating-and-select-field-or-throw.util.ts`
- All 21 tests passing
---
Breaking Changes
None - correct usage is unaffected.

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-04-28 14:31:18 +00:00
nitin 6c1c0737b0 Clarify registry tools vs native model tool binding (#20022)
## Intent

This is a small foundation cleanup for the tool architecture.

The main decision is: registry tools and native SDK/model tools are
different things.

- Registry tools have descriptors, schemas, catalog entries, and execute
through `ToolExecutorService`
- Native model tools are opaque AI SDK objects, bound directly into the
model `ToolSet`
- Surfaces still own their policy: chat, MCP, and workflow agents decide
what they expose

## What changed

- Removed `NATIVE_MODEL` from `ToolCategory`
- Kept `ToolRegistryService` focused on registry-backed tools only
- Moved native model tool binding through `NativeToolBinderService`
- Reused native binding from chat instead of duplicating
provider-specific web-search logic
- Kept MCP local execution exclusions in a dedicated constant
- Moved surface-specific constants into dedicated constant files

## What comes next

- Move hardcoded chat app preloads, like Exa web search, into
app/manifest metadata
- Decide a clearer policy for local runtime tools like code interpreter
and HTTP request
- Gradually document the three tool shapes: registry tools, native model
tools, and local runtime tools

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-04-24 17:18:19 +02:00
sonarly[bot] d51d982a5f Fix: Database query on opportunity table (#20017)
## Automated fix for [bug
30463](https://sonarly.com/issue/30463?type=bug)

**Severity:** `critical`

### Summary
When createMany is called with upsert:true and records lack pre-assigned
IDs, findExistingRecords() executes a SELECT with no WHERE clause,
scanning the entire table. This caused an 11-second transaction on the
opportunity table (2.9s query + 7.7s JS processing).

### Root Cause
1. WHY was the transaction 11 seconds? Because a SELECT on the
opportunity table took 2.9s and its result processing took 7.7s.
2. WHY did the SELECT take 2.9s? Because it was a full table scan with
NO WHERE clause, fetching every record (including soft-deleted).
3. WHY was there no WHERE clause? Because findExistingRecords() in
common-create-many-query-runner.service.ts calls buildWhereConditions()
which returned an empty array, so no .orWhere() was applied to the query
builder before .getMany() was called.
4. WHY did buildWhereConditions return empty? Because the input records
had no pre-assigned IDs and the opportunity object has no other unique
fields, so there were no conflicting field values to build conditions
from.
5. WHY wasn't the empty-conditions case guarded? The
findExistingRecords() method was written without an early-return check
for empty whereConditions — it always executes the query regardless.

**Introduced by:** etiennejouan on 2025-10-15 in commit
[`4ae2999`](https://github.com/twentyhq/twenty/commit/4ae299973bcea3470252c4c68540d33a4df59cc7)
> Common Api - createOne/Many (#15083)

### Suggested Fix
Added an early return in findExistingRecords() when
buildWhereConditions() returns an empty array. When no WHERE conditions
exist (because input records lack values for any unique/conflicting
field), the method now returns an empty array immediately instead of
executing a SELECT with no WHERE clause that scans the entire table.
This prevents the O(n) full table scan + O(n) JS result processing that
caused the 11-second transaction. The downstream categorizeRecords()
correctly handles empty existingRecords by classifying all input records
as recordsToInsert.

---
*Generated by [Sonarly](https://sonarly.com)*

Co-authored-by: Sonarly Claude Code <claude-code@sonarly.com>
2026-04-24 06:18:57 +00:00
Félix Malfait 2a5d5b36db refactor(tool-provider): kill execute_tool's dual dispatch (#19962)
**Stacked on top of #19960.**

## Summary

- `execute_tool` used to check `directTools[toolName]` first, falling
back to the registry. Same tool name, different wrapping: preloaded went
through `wrapToolsWithOutputSerialization`, fallback didn't. Silent
divergence — a model calling a CRUD tool via
\`learn_tools\`/\`execute_tool\` got raw output, while calling it as a
preloaded direct tool got compacted output.
- Now: `execute_tool` always routes through
`toolRegistry.resolveAndExecute`. One path, no fast-path.
- Output serialization (`compactToolOutput`) moves into the registry,
gated by a new `serializeOutput` flag on `hydrateToolSet` /
`resolveAndExecute` / `getToolsByName` / `getToolsByCategories` /
`ToolRetrievalOptions`. Chat passes `true`, MCP and workflow pass
`false`.

## Key changes

**Registry (`tool-registry.service.ts`)**
- `hydrateToolSet` options gain `serializeOutput?: boolean`; when true
the execute closure wraps dispatch result with `compactToolOutput`.
- `resolveAndExecute` signature: replaces unused \`_options:
ToolExecutionOptions\` with `{ serializeOutput?: boolean }`.
- `getToolsByName` and `getToolsByCategories` thread `serializeOutput`
through to `hydrateToolSet`.

**Meta-tool (`execute-tool.tool.ts`)**
- API changes from positional `(toolRegistry, context, directTools?,
excludeTools?)` to `(toolRegistry, context, options?: { excludeTools?,
serializeOutput? })`.
- `directTools` fallback removed. All invocations go to the registry.

**Chat (`chat-execution.service.ts`)**
- Passes `serializeOutput: true` to `getToolsByName` — preloaded tools
get compacted output from the hydrator, no external wrap needed.
- Drops the external `wrapToolsWithOutputSerialization(preloadedTools)`
call.
- `createExecuteToolTool` call now passes `{ serializeOutput: true }`.
Direct-tool and `execute_tool` paths produce identical output shape.

**MCP (`mcp-protocol.service.ts`)**
- `createExecuteToolTool` call updated to new options shape with `{
excludeTools: MCP_EXCLUDED_TOOLS }`. No `serializeOutput` flag → raw
output as today.

**Deletes**
- `output-serialization/wrap-tools-with-output-serialization.util.ts` —
sole caller removed.

## Behavior changes

- **Chat, `execute_tool` fallback path**: now produces compacted output
(matches direct path). Net effect: fewer tokens for CRUD results reached
via discovery. Intended improvement.
- **Chat, `execute_tool({toolName: 'web_search'})` edge**: today
silently hits the native tool via `directTools`; now returns \"tool not
found, use get_tool_catalog\". Self-correcting, rare — native tools are
always directly available to the model.
- **MCP**: no change. No `serializeOutput` flag → identical raw output.
- **Workflow agent**: no change. Doesn't use `execute_tool`.

## Test plan

- [ ] `npx nx typecheck twenty-server` passes (verified: 7 pre-existing
unrelated errors, zero new)
- [ ] \`npx jest
packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts\`
passes in CI
- [ ] AI chat: call a preloaded tool (e.g. \`search_help_center\`)
directly → compacted output
- [ ] AI chat: call a non-preloaded CRUD tool via
\`learn_tools\`/\`execute_tool\` → compacted output (this is the
behavior change)
- [ ] AI chat: native \`web_search\` still works when model calls it
directly
- [ ] MCP: \`tools/call\` on a registry tool → raw output (nulls
preserved)
- [ ] Workflow AI agent: tool dispatch unchanged

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:49:35 +02:00
Félix Malfait 13afef5d1d fix(server): scope loadingMessage wrap/strip to AI-chat callers (#19896)
## Summary

MCP tool execution crashed with \`Cannot destructure property
'loadingMessage' of 'parameters' as it is undefined\` whenever
\`execute_tool\` was called without an inner \`arguments\` field. Root
cause: \`loadingMessage\` is an AI-chat UX affordance (lets the LLM
narrate progress so the chat UI can show "Sending email…") but it was
being wrapped into **every** tool schema — including those advertised to
external MCP clients — and \`dispatch\` unconditionally stripped it,
crashing on \`undefined\` args.

The fix scopes the wrap/strip pair to AI-chat callers only:

- Pair wrap and strip inside \`hydrateToolSet\` (they belong together).
- New \`includeLoadingMessage\` option on \`hydrateToolSet\` /
\`getToolsByName\` / \`getToolsByCategories\` (default \`true\` so
AI-chat behavior is unchanged).
- MCP opts out → external clients see clean inputSchemas without a
required \`loadingMessage\` field.
- \`dispatch\` no longer strips; args default to \`{}\` defensively.
- \`execute_tool\` defaults \`arguments\` to \`{}\` at the LLM boundary.

## Test plan

- [x] \`npx nx typecheck twenty-server\` passes
- [x] \`npx oxlint\` clean on changed files
- [x] \`npx jest mcp-protocol mcp-tool-executor\` — 23/23 tests pass
- [ ] Manually: call \`execute_tool\` via MCP with and without inner
\`arguments\` — verify no crash, endpoints execute
- [ ] Manually: inspect MCP \`tools/list\` response — verify
\`search_help_center\` schema no longer contains \`loadingMessage\`
- [ ] Regression: AI chat still streams loading messages as the LLM
calls tools

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:43:16 +02:00
Félix Malfait 75848ff8ea feat: move admin panel to dedicated /admin-panel GraphQL endpoint (#19852)
## Summary

Splits admin-panel resolvers off the shared `/metadata` GraphQL endpoint
onto a dedicated `/admin-panel` endpoint. The backend plumbing mirrors
the existing `metadata` / `core` pattern (new scope, decorator, module,
factory), and admin types now live in their own
`generated-admin/graphql.ts` on the frontend — dropping 877 lines of
admin noise from `generated-metadata`.

## Why

- **Smaller attack surface on `/metadata`** — every authenticated user
hits that endpoint; admin ops don't belong there.
- **Independent complexity limits and monitoring** per endpoint.
- **Cleaner module boundaries** — admin is a cross-cutting concern that
doesn't match the "shared-schema configuration" meaning of `/metadata`.
- **Deploy / blast-radius isolation** — a broken admin query can't
affect `/metadata`.

Runtime behavior, auth, and authorization are unchanged — this is a
relocation, not a re-permissioning. All existing guards
(`WorkspaceAuthGuard`, `UserAuthGuard`,
`SettingsPermissionGuard(SECURITY)` at class level; `AdminPanelGuard` /
`ServerLevelImpersonateGuard` at method level) remain on
`AdminPanelResolver`.

## What changed

### Backend
- `@AdminResolver()` decorator with scope `'admin'`, naming parallels
`CoreResolver` / `MetadataResolver`.
- `AdminPanelGraphQLApiModule` + `adminPanelModuleFactory` registered at
`/admin-panel`, same Yoga hook set as the metadata factory (Sentry
tracing, error handler, introspection-disabling in prod, complexity
validation).
- Middleware chain on `/admin-panel` is identical to `/metadata`.
- `@nestjs/graphql` patch extended: `resolverSchemaScope?: 'core' |
'metadata' | 'admin'`.
- `AdminPanelResolver` class decorator swapped from
`@MetadataResolver()` to `@AdminResolver()` — no other changes.

### Frontend
- `codegen-admin.cjs` → `src/generated-admin/graphql.ts` (982 lines).
- `codegen-metadata.cjs` excludes admin paths; metadata file shrinks by
877 lines.
- `ApolloAdminProvider` / `useApolloAdminClient` follow the existing
`ApolloCoreProvider` / `useApolloCoreClient` pattern, wired inside
`AppRouterProviders` alongside the core provider.
- 37 admin consumer files migrated: imports switched to
`~/generated-admin/graphql` and `client: useApolloAdminClient()` is
passed to `useQuery` / `useMutation`.
- Three files intentionally kept on `generated-metadata` because they
consume non-admin Documents: `useHandleImpersonate.ts`,
`SettingsAdminApplicationRegistrationDangerZone.tsx`,
`SettingsAdminApplicationRegistrationGeneralToggles.tsx`.

### CI
- `ci-server.yaml` runs all three `graphql:generate` configurations and
diff-checks all three generated dirs.

## Authorization (unchanged, but audited while reviewing)

Every one of the 38 methods on `AdminPanelResolver` has a method-level
guard:
- `AdminPanelGuard` (32 methods) — requires `canAccessFullAdminPanel ===
true`
- `ServerLevelImpersonateGuard` (6 methods: user/workspace lookup + chat
thread views) — requires `canImpersonate === true`

On top of the class-level guards above. No resolver method is accessible
without these flags + `SECURITY` permission in the workspace.

## Test plan

- [ ] Dev server boots; `/graphql`, `/metadata`, `/admin-panel` all
mapped as separate GraphQL routes (confirmed locally during
development).
- [ ] `nx typecheck twenty-server` passes.
- [ ] `nx typecheck twenty-front` passes.
- [ ] `nx lint:diff-with-main twenty-server` and `twenty-front` both
clean.
- [ ] Manual smoke test: log in with a user who has
`canAccessFullAdminPanel=true`, open the admin panel at
`/settings/admin-panel`, verify each tab loads (General, Health, Config
variables, AI, Apps, Workspace details, User details, chat threads).
- [ ] Manual smoke test: log in with a user who has
`canImpersonate=false` and `canAccessFullAdminPanel=false`, hit
`/admin-panel` directly with a raw GraphQL request, confirm permission
error on every operation.
- [ ] Production deploy note: reverse proxy / ingress must route the new
`/admin-panel` path to the Nest server. If the proxy has an explicit
allowlist, infra change required before cutover.

## Follow-ups (out of scope here)

- Consider cutting over the three
`SettingsAdminApplicationRegistration*` components to admin-scope
versions of the app-registration operations so the admin page is fully
on the admin endpoint.
- The `renderGraphiQL` double-assignment in
`admin-panel.module-factory.ts` is copied from
`metadata.module-factory.ts` — worth cleaning up in both.
2026-04-19 20:55:10 +02:00
Félix Malfait 5223c4771d fix(server): align OAuth discovery metadata with MCP / RFC 9728 spec (#19838)
## Summary

Three small spec-compliance fixes called out in an audit against the
[MCP authorization spec
(draft)](https://modelcontextprotocol.io/specification/draft/basic/authorization)
and RFC 9728 / RFC 9207.

### 1. Split Protected Resource Metadata by path (RFC 9728 §3.2)

> The `resource` value returned MUST be identical to the protected
resource's resource identifier value into which the well-known URI path
suffix was inserted.

Today a single handler serves both
\`/.well-known/oauth-protected-resource\` and
\`/.well-known/oauth-protected-resource/mcp\` and returns \`resource:
<origin>/mcp\` from both. That's wrong for the root form — per RFC 9728
the root URL corresponds to the **origin as resource**, and only the
\`/mcp\`-suffixed URL corresponds to \`<origin>/mcp\`.

After this PR:

| Request | `resource` field |
|---|---|
| `GET /.well-known/oauth-protected-resource` | `https://<host>` |
| `GET /.well-known/oauth-protected-resource/mcp` | `https://<host>/mcp`
|

Both still return the same `authorization_servers`, `scopes_supported`,
and `bearer_methods_supported`.

Claude's current flow happens to work because our WWW-Authenticate
points at the root form and Claude compares `resource` against what it
connected to. Strict clients probing the path-aware URL first were
rejecting us.

### 2. Advertise `authorization_response_iss_parameter_supported: true`
(RFC 9207)

Defense against OAuth mix-up attacks. Required by the [OAuth 2.1
security
BCP](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1).
Signals that clients receiving an authorization response will find the
issuer in the `iss` parameter and can validate it.

### 3. Fix `WWW-Authenticate` challenge: point at path-aware PRM URL,
add `scope` param

- Was: `Bearer
resource_metadata=\"https://<host>/.well-known/oauth-protected-resource\"`
- Now: `Bearer
resource_metadata=\"https://<host>/.well-known/oauth-protected-resource/mcp\",
scope=\"api profile\"`

After change (1), only the path-aware URL returns a PRM document whose
`resource` matches what the MCP client connected to (\`<host>/mcp\`).
Pointing clients at the right URL keeps discovery consistent.

The `scope` parameter is a SHOULD in RFC 6750 and lets clients ask for
least-privilege scopes on first authorization.

## Not in this PR (queued separately)

From the same audit:

- **Audit JWT `aud` (audience) validation** — the spec requires the
server to reject tokens whose audience doesn't match this resource. Need
a read-only code review to confirm; filing as a follow-up.
- **Audit PKCE enforcement** — we advertise
`code_challenge_methods_supported: [\"S256\"]`; need to confirm the
\`/authorize\` flow actually rejects requests missing `code_challenge`.
- **403 `insufficient_scope` challenge format** for step-up auth.
- **CIMD (Client ID Metadata Documents)** support — newer spec
alternative to DCR.

## Test plan

- [x] \`yarn jest
--testPathPatterns=\"mcp-auth.guard|oauth-discovery.controller\"\` → 4/4
passing
- [x] \`tsc --noEmit\` clean on touched files
- [ ] After deploy:
  \`\`\`bash
curl -s https://<host>/.well-known/oauth-protected-resource | jq
.resource
  # expect: \"https://<host>\"
curl -s https://<host>/.well-known/oauth-protected-resource/mcp | jq
.resource
  # expect: \"https://<host>/mcp\"
  curl -sI -X POST https://<host>/mcp | grep -i www-authenticate
# expect: Bearer resource_metadata=\"…/oauth-protected-resource/mcp\",
scope=\"api profile\"
  \`\`\`

## Related

- #19836 — CORS exposes `WWW-Authenticate` + `MCP-Protocol-Version` so
browser clients can read them. Pairs with this PR.
- #19755 / #19766 / #19824 — the earlier chain that got host-aware
discovery and \`TRUST_PROXY\` working.

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

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 21:28:44 +02: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
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
Etienne f738961127 Add gql operationName metadata in sentry (#19564) 2026-04-14 08:55:54 +00:00
Thomas Trompette 353d1e89d5 Fix merge with null value + reset data virtualization before init load (#19633)
**Merge records fix:**

selectPriorityFieldValue throws when merging records if the priority
record has no value for a field (e.g., null/empty) but 2+ other records
do. The recordsWithValues array is pre-filtered to only records with
non-empty values, so the priority record isn't in the list. The fix:
instead of throwing, fall back to null since this is the priority record
actual value


**Duplicated IDs fix**


https://github.com/user-attachments/assets/bd6d7d08-d079-49a5-aad4-740b59a3c246


When applying a filter that reduces the record count, the virtualized
table's record ID array keeps stale entries from the previous larger
result set. loadRecordsToVirtualRows clones the old array (e.g., 60
entries) and only overwrites the first N positions (e.g., 9) with the
new filtered results, leaving positions 9-59 with old IDs. If any old ID
matches a new one, it appears twice in the selection, causing "-> 2
selected" for a single click and a duplicate ID in the merge mutation
payload. The fix: clear the record IDs array in
useTriggerInitialRecordTableDataLoad before repopulating it with fresh
data.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-13 15:50:58 +00:00
nitin f13e7e01fe [AI] Add group_by_* database tools and centralize groupBy validation (#19406)
closes
https://discord.com/channels/1130383047699738754/1488990242873806868



https://github.com/user-attachments/assets/2b2bbfba-3fa6-4114-9a26-96a61599d748

<img width="729" height="1283" alt="CleanShot 2026-04-07 at 20 43 06"
src="https://github.com/user-attachments/assets/815efb97-81a0-44ea-8d79-b3ce7d5b00b6"
/>


<img width="708" height="1266" alt="CleanShot 2026-04-07 at 20 40 13"
src="https://github.com/user-attachments/assets/692366bc-b629-4d9f-b6b8-ab670d5ad046"
/>

<img width="665" height="3524" alt="CleanShot 2026-04-07 at 20 42 00"
src="https://github.com/user-attachments/assets/5e844e0f-7835-47a8-9d20-a5baddc0992d"
/>
2026-04-10 08:45:18 +00:00
Félix Malfait cbefd42c4c Add SSE streaming support on POST /mcp (Phase 2) (#19528)
## Summary

- Add SSE (`text/event-stream`) as an alternative response format on
`POST /mcp` per the MCP streamable-http spec
- When clients send `Accept: text/event-stream`, the server responds
with SSE wire format; otherwise returns JSON as before (fully backwards
compatible)
- Emit a `notifications/progress` SSE event before tool execution to
signal long-running operations
- No sessions, no GET SSE, no protocol version bump — this is
transport-level only (Phase 2)

## Changes

- **New**: `write-sse-event.util.ts` — writes correctly formatted SSE
events to Express Response
- **New**: `mcp-progress-notification.const.ts` — constants for progress
notification method and token prefix
- **Modified**: `mcp-core.controller.ts` — checks `Accept` header,
branches into SSE vs JSON response path
- **Modified**: `mcp-protocol.service.ts` — passes optional `sseWriter`
callback to tool executor
- **Modified**: `mcp-tool-executor.service.ts` — emits progress
notification via `sseWriter` before tool execution
- **Tests**: Unit tests for SSE utility, controller SSE/JSON paths, tool
executor progress notifications, and integration tests for SSE streaming

## Test plan

- [x] Unit tests: `writeSseEvent` utility produces correct SSE wire
format
- [x] Unit tests: Controller returns SSE headers and writes events when
`Accept: text/event-stream`
- [x] Unit tests: Controller returns JSON when `Accept:
application/json` only
- [x] Unit tests: Notifications (no `id`) return 202 regardless of
Accept header
- [x] Unit tests: Tool executor emits progress notification via
sseWriter
- [x] Unit tests: Tool executor works without sseWriter (backwards
compatible)
- [x] Integration tests: SSE response for ping, JSON fallback, progress
notification before tool call
- [x] All 503 test suites pass, typecheck clean

https://claude.ai/code/session_01QrqjBUXePJkPMd6gBAoWaR

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-10 09:56:17 +02:00
Charles Bochet f6423f5925 Remove DataSourceService and clean up datasource migration logic (#19532)
## Summary

- **Drop the `objectMetadata.dataSourceId` foreign key and index** via a
1-22 fast instance command — column kept nullable for data preservation
- **Delete `DataSourceService`, `DataSourceModule`, and
`DataSourceException`** — all code now uses `workspace.databaseSchema`
directly
- **Remove `IS_DATASOURCE_MIGRATED` feature flag** from default flags
and all branching logic
- **Simplify workspace/object creation pipelines** —
`WorkspaceManagerService`, `DevSeederService`, and the object creation
action handler no longer route through `DataSourceService`
- **Keep `DataSourceEntity` and the `dataSource` table** for historical
data — entity stripped of all ORM relations
2026-04-10 07:34:05 +00:00
Sri Hari Haran Sharma 0fa7beba44 fix mcp streamable-http method handling (#19496)
## Summary

Fix MCP `/mcp` transport handling for clients using `streamable-http`.

## Changes

- add explicit `GET /mcp` and `DELETE /mcp` handlers
- return `405 Method Not Allowed` with `Allow: POST`
- keep `POST /mcp` protected by MCP auth guards
- mark `GET` and `DELETE` as intentionally public with
`PublicEndpointGuard` + `NoPermissionGuard`
- update the advertised MCP protocol version to `2025-03-26`
- add unit and integration coverage for the new behavior

## Why

The frontend advertises the MCP server as `streamable-http`, but the
backend only effectively handled `POST /mcp`. Some MCP clients probe
`GET /mcp` during connection setup, so unsupported methods need explicit
method-level responses instead of falling through or being blocked
before the handler.

## Validation

- verified locally:
  - `GET /mcp` -> `405`
  - `DELETE /mcp` -> `405`
  - unauthenticated `POST /mcp` -> `401`
- passed controller unit tests
- added integration assertions for `GET` and `DELETE`

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-04-09 20:38:28 +00:00
Félix Malfait 7ef80dd238 Add standard skills backfill and improve skill availability messaging (#19523)
## Summary
This PR adds a database migration command to backfill standard skills
for existing workspaces and improves the skill loading tool to provide
dynamic, workspace-specific skill availability information instead of
hardcoded skill names.

## Key Changes

- **New Migration Command**: Added `BackfillStandardSkillsCommand`
(v1.22.0) that:
  - Identifies missing standard skills in existing workspaces
  - Compares workspace skills against the standard skill definitions
  - Creates missing skills using the workspace migration service
  - Supports dry-run mode for safe testing
  - Properly logs all operations and handles failures

- **Enhanced Skill Loading Tool**: Updated `createLoadSkillTool` to:
  - Accept a new `listAvailableSkillNames` function parameter
- Dynamically fetch available skills from the workspace instead of using
hardcoded skill names
- Provide accurate, context-aware error messages when skills are not
found
  - Gracefully handle workspaces with no available skills

- **Service Updates**: Modified skill tool implementations in:
- `McpProtocolService`: Integrated `findAllFlatSkills` to list available
skills
- `ChatExecutionService`: Integrated `findAllFlatSkills` to list
available skills

- **Module Registration**: Added `BackfillStandardSkillsCommand` to the
v1.22 upgrade module

- **Test Updates**: Updated `McpProtocolService` tests to mock the new
`findAllFlatSkills` method

## Implementation Details

The backfill command uses the existing workspace migration
infrastructure to safely create skills, ensuring consistency with other
metadata operations. The skill availability messaging now reflects the
actual skills present in each workspace, improving user experience when
skills are not found.

https://claude.ai/code/session_012fXeP3bysaEgWsbkyu4ism

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-09 21:28:23 +02:00
Etienne caac791421 Cleaning - Remove logs (#19498) 2026-04-09 14:19:53 +00:00
Etienne 3306d66f5b cleaning - remove logs (#19445) 2026-04-08 13:05:47 +00:00
Etienne ac1ec91f25 Direct execution - Remove conditional schema (#19383) 2026-04-07 10:22:56 +00:00
oniani1 3f87d27d5d fix: use AND instead of OR in neq filter for null-equivalent values (#19071)
Fixes #19070

The `neq` operator in `compute-where-condition-parts.ts` uses `OR` where
it should use `AND` when handling null-equivalent values.

Currently generates:
```sql
field != '' OR field IS NOT NULL
```

For a row where `field = ''`:
- `'' != ''` = false
- `'' IS NOT NULL` = true
- `false OR true` = true -- row incorrectly passes the filter

The `eq` operator correctly uses `OR field IS NULL` because it's
additive (match value or its null equivalent). By De Morgan's law, the
negation `neq` needs `AND field IS NOT NULL` -- exclude if the value
doesn't match AND is not a null equivalent.

With the fix:
```sql
field != '' AND field IS NOT NULL
```
- `'' != ''` = false, `'' IS NOT NULL` = true, `false AND true` = false
-- correctly excluded
- `NULL != ''` = NULL, `NULL IS NOT NULL` = false, `NULL AND false` =
false -- correctly excluded
- `'Alice' != ''` = true, `'Alice' IS NOT NULL` = true, `true AND true`
= true -- correctly included

Affects `neq` filters on TEXT fields and all composite sub-fields
(firstName, lastName, primaryEmail, primaryPhoneNumber, address
sub-fields, etc.) when filtering against null-equivalent values.

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-04-07 07:36:41 +00:00
neo773 d3f0162cf5 Remove connected account feature flag (#19286)
Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-06 08:13:41 +00:00
Félix Malfait 8da69e0f77 Fix stored XSS via unsafe URL protocols in href attributes (#19282)
## Summary

- Fixes **GHSA-7w89-7q26-gj7q**: stored XSS via `javascript:` URIs in
BlockNote `FileBlock` `props.url`, rendered as a clickable `<a href>`.
- Audited the full codebase and hardened **all** surfaces where
user-controlled URLs are rendered as `href` or passed to `window.open`.
- Applies defense-in-depth: server-side input validation + client-side
render-time checks + lint rules to prevent regressions.

### Changes

**New utility** — `isSafeUrl` (`~/utils/isSafeUrl.ts`):  
Allowlists `http:`, `https:`, `mailto:`, `tel:` protocols and relative
paths (`/`). Returns `false` for `javascript:`, `data:`, `vbscript:`,
etc.

**Server-side** — `validateBlocknoteFieldOrThrow`:  
- Recursively walks all blocks and validates `props.url` and inline link
`href` values
- Rejects payloads with unsafe URL protocols at save time (before data
is stored)

**Client-side** — 8 components hardened:
| Component | Fix |
|-----------|-----|
| `FileBlock` (reported vuln) | `isSafeUrl` gate, fixed
`target="__blank"` → `_blank`, added `rel="noopener noreferrer"` |
| `LazyMarkdownRenderer` | `isSafeUrl` gate on markdown `<a href>`,
added `target`/`rel` |
| `EditLinkPopover` (TipTap) | Validates + auto-prefixes `https://`,
rejects unsafe URLs |
| `LinkBubbleMenu` (TipTap) | `isSafeUrl` gate on `window.open`, added
`noopener,noreferrer` |
| `AttachmentRow` | `isSafeUrl` gate on file attachment `href` |
| `URLDisplay` / `LinkDisplay` | `isSafeUrl` as second check after
`startsWith('http')` |
| `IframeWidget` | `isSafeUrl` gate on `src`, shows error state for
unsafe URLs |
| `InformationBannerMaintenance` | `isSafeUrl` gate on `window.open` |

**Lint rules** — `.oxlintrc.json`:
- `no-script-url: error` — catches `javascript:` string literals
- `react/jsx-no-script-url: error` — catches `javascript:` in JSX href
attributes

## Test plan

- [ ] Create a note via GraphQL mutation with `"url":
"javascript:void(alert(1))"` in a file block — should be rejected by
server validation
- [ ] Verify existing file attachments in notes still render and are
clickable
- [ ] Verify TipTap link insertion works for normal `https://` URLs
- [ ] Verify TipTap link insertion rejects `javascript:` URIs
- [ ] Verify markdown links in AI chat render correctly for safe URLs
- [ ] Verify URL/Link field displays still work for normal URLs
- [ ] Verify iframe widget rejects non-http(s) URLs


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 08:05:27 +02:00
Etienne d562a384c2 Remove direct execution feature flag - WIP (#19254)
Bug fixes exposed by always-on direct execution
1. GraphQL spec compliance — data[field] = null on resolver error
direct-execution.service.ts — Changed from Promise.allSettled (which
lost the responseKey on rejection) to Promise.all with per-field
try/catch; errors now set data[responseKey] = null per spec
2. Empty object arguments skipped (extractArgumentsFromAst)
extract-arguments-from-ast.util.ts — Removed isEmptyObject check;
filter: {}, data: {} now correctly passed to resolvers instead of
silently dropped (which caused permissions to never be checked)
3. orderBy: {} factory default treated as "no ordering"
direct-execution.service.ts — Before calling the resolver, strips
orderBy: {} and orderByForRecords: {} (empty-object factory defaults
that mean "no ordering")
assert-find-many-args.util.ts / assert-group-by-args.util.ts — Accept {}
for orderBy without throwing
4. orderBy: { field: '...' } object auto-coerced to [{ field: '...' }]
array
direct-execution.service.ts — Applies GraphQL list coercion: a
non-array, non-empty orderBy object is wrapped in an array before
assertion and resolver call
5. totalCount and aggregate fields returned as strings from PostgreSQL
graphql-format-result-from-selected-fields.util.ts — Added
coerceAggregateValue that parses numeric strings to numbers for
totalCount, sum*, avg*, min*, max*, count*, percentageOf* fields
Test updates
nested-relation-queries.integration-spec.ts — Updated expected error
message from Yoga schema-validation message to direct execution resolver
message
~30 snapshot files — Updated to reflect direct execution's error
messages (different from Yoga schema-validation messages for input type
errors)
2026-04-03 15:23:01 +00:00
Etienne a303d9ca1b Gql direct execution - Handle introspection queries (#19219)
### Context
GraphQL introspection queries (__schema, __type) were going through the
full Yoga server pipeline, which forces a complete workspace schema
build, loading flat metadata maps, building all GraphQL types, wiring
all resolver factories, and calling makeExecutableSchema. This is
expensive, even though introspection only needs the type structure and
zero resolver execution.

A new WorkspaceGraphqlSchemaSDLService extracts the SDL computation that
was previously embedded inside WorkspaceSchemaFactory.

From `direct-execution.service.ts` : 
- `buildSchema(sdl)` reconstructs a resolver-free GraphQLSchema from the
SDL
     - pure CPU, not cached, not sure it worths it ?

- `execute({ schema, document, variableValues })` from graphql-js,
introspection is answered entirely by the graphql-js runtime from type
metadata, no resolver execution needed


#### Nice to do ?
- Use new cache service for typeDefs ?


### Renaming bonus: `typeDefs` → `sdl`

`typeDefs` is an Apollo/graphql-tools convention. It's the parameter
name in
`makeExecutableSchema({ typeDefs, resolvers })`, not a native GraphQL
spec term.

In proper GraphQL semantics, what this service produces is the **SDL**
(Schema
Definition Language): the official term for the string representation of
a schema.
`printSchema()` produces it, `buildSchema()` consumes it.

##### Why the distinction matters

- **Type definitions** implies partial type declarations (objects,
scalars, enums…)
- **Schema SDL** conveys a *complete* schema document: all types
**plus** the root
operation types (`Query`, `Mutation`) which is exactly what
`printSchema(schema)`
  produces
2026-04-02 11:50:18 +00:00
Etienne 579714b62f Investigate memory leak (#19213)
In search for cache leak + Remove old instrumentation
2026-04-02 11:23:10 +00:00
martmull 16e3e38b79 Improve getting started doc (#19138)
- improves
`packages/twenty-docs/developers/extend/apps/getting-started.mdx`

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-04-01 20:39:44 +00:00
Etienne 887e0283c5 Direct execution - Follow up (#19177)
Feedbacks from https://github.com/twentyhq/twenty/pull/18972
2026-03-31 17:38:24 +00:00
Etienne ecf8161d0e Fix - Remove signFileUrl method (#19121)
`signFileUrl` is the old way to resolve file url. Replaced by
`signFileByIdUrl` which needs `FileFolder` and `fileId`.

Fix also avatar for person and workspaceMember. To be continued with
imageIdentifier refactor.


Fix : 
- https://discord.com/channels/1130383047699738754/1486723854910099576
- https://discord.com/channels/1130383047699738754/1484566445584285870
- https://discord.com/channels/1130383047699738754/1487124612889313420

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-30 15:14:54 +00:00
Etienne 3d1c53ec9d Gql direct execution - Improvements (#18972)
#### Direct Execution  __typename & null backfill
##### __typename filling
The direct execution path now correctly derives __typename at every
level of the GraphQL response:
Connection types — CompanyConnection, CompanyEdge, Company, PageInfo
GroupBy types — TaskGroupByConnection (was incorrectly producing
TaskConnection)
Composite fields — Links, FullName, Currency, etc. handled by a
dedicated formatter (was inheriting the parent object's typename)
Previously, __typename was derived from the object's universal
identifier (a UUID), producing broken values like
20202020B3744779A56180086Cb2E17FConnection.
##### Null backfill
Selected fields missing from the resolver result are backfilled with
null, matching the standard Yoga schema behavior.
##### Integration test
A new test runs the same findMany query (with __typename at all
structural levels) through both paths — standard Yoga schema and direct
execution — and asserts identical output via toStrictEqual.
2026-03-30 17:20:25 +02:00
Charles Bochet c407341912 feat: optimize hot database queries with multi-layer caching (#19068)
## Summary

Introduces multi-layer caching for the 5 most frequent database queries
identified in production (Sentry data), targeting the JWT authentication
hot path and cron job logic.

### Problem
Our database is under heavy load from uncached queries on the auth hot
path:
- `WorkspaceEntity` lookups: **638 queries/min**
- `ApiKeyEntity` lookups: **491 queries/min**
- `UserEntity` lookups: **147 queries/min**
- `UserWorkspaceEntity` lookups: **143 queries/min**
- `LogicFunctionEntity` lookups: **1800 queries/min** (cron job)

### Solution

**1. New `CoreEntityCacheService`** for non-workspace-scoped entities
(Workspace, User, UserWorkspace):
- Mirrors `WorkspaceCacheService` architecture (in-process Map + Redis
with hash validation)
- Provider pattern with `@CoreEntityCache` decorator
- Keyed by entity primary key (not workspaceId)
- 100ms local TTL, Redis-backed hash validation for cross-instance
consistency
- Three providers: `WorkspaceEntityCacheProviderService`,
`UserEntityCacheProviderService`,
`UserWorkspaceEntityCacheProviderService`

**2. New `apiKeyMap` WorkspaceCache** for workspace-scoped API key
lookups:
- `WorkspaceApiKeyMapCacheService` loads all API keys for a workspace
into a map by ID
- Leverages existing `WorkspaceCacheService` infrastructure
- Cache invalidation on API key create/update/revoke

**3. `CronTriggerCronJob` refactored** to use existing
`flatLogicFunctionMaps` workspace cache:
- Eliminates per-workspace `LogicFunctionEntity` repository queries
(~1800/min)
- Filters cached data in-memory instead

**4. `JwtAuthStrategy` refactored** to use caches for all entity
lookups:
- Workspace, User, UserWorkspace → `CoreEntityCacheService`
- ApiKey → `WorkspaceCacheService` (`apiKeyMap`)
- Impersonation queries kept as direct DB queries (rare path, requires
relations)

**5. Cache invalidation** wired into mutation paths:
- `WorkspaceService` → invalidates `workspaceEntity` on
save/update/delete
- `ApiKeyService` → invalidates `apiKeyMap` on create/update/revoke

### Architecture

```
Request → JwtAuthStrategy
  ├── Workspace lookup → CoreEntityCacheService (in-process → Redis → DB)
  ├── User lookup → CoreEntityCacheService (in-process → Redis → DB)
  ├── UserWorkspace lookup → CoreEntityCacheService (in-process → Redis → DB)
  └── ApiKey lookup → WorkspaceCacheService (in-process → Redis → DB)

CronTriggerCronJob
  └── LogicFunction lookup → WorkspaceCacheService (flatLogicFunctionMaps)
```

### Expected Impact
| Query | Before | After |
|-------|--------|-------|
| WorkspaceEntity | 638/min | ~0 (cached) |
| ApiKeyEntity | 491/min | ~0 (cached) |
| UserEntity | 147/min | ~0 (cached) |
| UserWorkspaceEntity | 143/min | ~0 (cached) |
| LogicFunctionEntity | 1800/min | ~0 (cached) |

### Not included (ongoing separately)
- DataSourceEntity query optimization (IS_DATASOURCE_MIGRATED migration)
- ObjectMetadataEntity query optimization (already partially cached)
2026-03-28 22:53:34 +01:00
Charles Bochet 81fc960712 Deprecate dataSource table with dual-write to workspace.databaseSchema (#19059)
## Summary
- Starts deprecation of the `core.dataSource` table by introducing a
dual-write system: `DataSourceService.createDataSourceMetadata` now
writes to both `core.dataSource` and `core.workspace.databaseSchema`
- Migrates read sites (`WorkspaceDataSourceService.checkSchemaExists`,
`WorkspaceSchemaFactory`, `MiddlewareService`,
`WorkspacesMigrationCommandRunner`) to read from
`workspace.databaseSchema` instead of querying the `dataSource` table
- Removes the unused `databaseUrl` field from `WorkspaceEntity` and
drops the column via migration
- Adds a 1.20 upgrade command to backfill `workspace.databaseSchema`
from `dataSource.schema` for existing workspaces
2026-03-28 11:29:19 +01:00
Paul Rastoin 281bb6d783 Guard yarn database:migrate:prod (#19008)
## Motivations
A lot of self hosters hands up using the `yarn database:migrated:prod`
either manually or through AI assisted debug while they try to upgrade
an instance while their workspace is still blocked in a previous one
Leading to their whole database permanent corruption

## What happened
Replaced the direct call the the typeorm cli to a command calling it
programmatically, adding a layer of security in case a workspace seems
to be blocked in a previous version than the one just before the one
being installed ( e.g 1.0 when you try to upgrade from 1.1 to 1.2 )

For our cloud we still need a way to bypass this security explaining the
-f flag

## Remark
Centralized this logic and refactored creating new services
`WorkspaceVersionService` and `CoreEngineVersionService` that will
become useful for the upcoming upgrade refactor

Related to https://github.com/twentyhq/twenty-infra/pull/529
2026-03-27 14:39:18 +00:00