Commit Graph

14134 Commits

Author SHA1 Message Date
Félix Malfait f8e3fd110d Bound an application by its own role as well as the user's (#23680)
## Why

When an application acts on someone's behalf its token carries `userId`
and `userWorkspaceId` alongside `applicationId`, and the application
then received **that person's permissions in full**. The role it
installs with was never consulted, so it was not a bound on what the
application could do for them. It was meant to be an intersection.

`permissions.service.ts` made this visible: the branches are `apiKeyId →
userWorkspaceId → applicationId` and each returns early, so with both
present the user branch won and `application.defaultRoleId` was never
read. The same was true on the object and row-level paths, for different
reasons.

## What had to change

Three independent causes, all of which blocked the intersection from
existing or from being enforced.

**The application was thrown away before anything could use it.**
`workspace-auth-context.middleware.ts` built a `type: 'user'` context
when both principals were present, and `UserWorkspaceAuthContext` had no
slot for an application. It now carries an optional one.

Additive rather than a new union member on purpose. Nothing in the
server exhaustively checks this union (no `assertUnreachable`, one
`switch`, in Sentry tagging), so a sixth member would have compiled fine
and then fallen through actor attribution, that switch, and
`metadata-event-emitter.ts` silently. The additive change leaves all
four type guards returning identical booleans.

**Role resolution returned a single id.** The rule itself now lives in
one place, `resolveRoleIdsForUser`: a user's role, narrowed by the
application's if it declared one, never the same id twice.
`resolveRoleIdsFromAuthContext` and `resolveRolePermissionConfig` build
`{ intersectionOf: [...] }` from it, which `getRepository` already
applied over N roles. A user with no role still resolves to nothing, so
an application can never stand in for a missing user role.

**Row-level security ignored all of it.** RLS was re-derived from a
single role at query time, so it would have been unaffected by any
intersection. Each role is now compiled on its own and the resulting
filters are ANDed.

That last choice matters. Merging the raw predicates and groups first
would have been wrong: `computeRecordGqlOperationFilter` honours only
the first parentless group, so concatenating two roles' groups makes one
role's predicates vanish, **widening** access. Compiling per role and
ANDing needs no synthetic groups, no re-parenting and no `twenty-shared`
type change, and reuses the single-role logic untouched.

Subscriptions go through the same rule. An event stream resolved only
the subscriber's role, so a stream opened by an application acting for
someone was filtered by that person's role alone. The stream now records
the application it was opened by and the publisher intersects both roles
for object permissions, restricted fields and RLS, exactly as a query
does.

Two smaller fixes fall out:

- `getObjectsPermissionsFromRolePermissionConfig` had a `// Multi-role
union/intersection is not ready — use the first assigned role only`
shortcut and now intersects.
- `computePermissionIntersection` hardcoded empty row-level predicate
arrays, which is why RLS-constrained fields were not exempted from the
field-permission check on insert and could fail spuriously. It now
reports the fields **every** role constrains. Reporting fields
constrained by only one role would be worse than the original bug: the
insert guard waives a field-update deny on them, so one role's row-level
rule would cancel another role's deny.

## Behaviour on the edges

**An application that declares no role adds no bound.** `defaultRoleId`
stays null whenever a manifest omits `defaultRoleUniversalIdentifier`,
which is the common case, so denying would have broken a lot of
installed applications. Behaviour changes only for applications that
actually declared a role.

To stop that being permanent, `defaultRoleUniversalIdentifier` should
become required for new applications. Hard-requiring it needs a backfill
for existing installs, so it is not in this PR.

**An application that cannot be found denies.** That is not the same as
one that declared no role, and treating it as such would have let a
token naming a deleted application fall back to the full permissions of
the user it acts for.

**A role that cannot be resolved denies.** `application.defaultRoleId`
is a plain uuid column with no foreign key, and role deletion does not
clear it, so it can dangle. A bound we cannot apply must not let the
remaining roles decide on their own, so the ORM path,
`getObjectsPermissionsFromRolePermissionConfig` and the subscription
publisher all return no permissions in that case rather than falling
back.

## Testing

- Full `twenty-server` unit suite green (896 suites, 7353 tests)
- New spec for `resolveRoleIdsFromAuthContext`: both roles, application
with no declared role, application holding the user's own role, user
with no role, api key, application-only, system
- New spec for multi-role RLS, including the case this fixes (a
restricted role intersected with an unrestricted one keeps the
restriction) and two restricted roles ANDing
- `permissions.service.spec.ts` had **no coverage of the application
branch at all** (`ApplicationEntity` was mocked as `{}`); it now has a
real mock plus user-grants/application-denies, the reverse, both-grant,
the null-role fallback, a shared role, and a missing application
- First coverage of non-empty row-level predicates through
`computePermissionIntersection`, including a field constrained by one
role only
- Subscription publisher: application role denies, both allow,
application role dangling, and both roles reaching the RLS filter
- Updated the two specs that asserted the old behaviour: the middleware
dropping the application, and "use the first role when multiple are
provided"

No schema, cache or GraphQL change: `rolesPermissions` is keyed by role
id alone and the intersection is computed per request from cached
per-role entries.

## Not in this PR

`workflow-execution-context.service.ts` falls back to the **admin** role
when an application has no `defaultRoleId`, and to
`shouldBypassPermissionChecks: true` if admin is not found. That is the
inverse of the rule here and an escalation in its own right, but
workflow execution is sensitive, so it is tracked separately in
twentyhq/core-team-issues#2753.

Three resolvers still carry their own principal precedence and do not
use this seam: `rest-api-base.handler.ts`, `mcp-protocol.service.ts`
(which never builds a user or application context at all), and actor
attribution in `actor-from-auth-context.service.ts`.

Separately, `computeRecordGqlOperationFilter` silently discards
predicates under any parentless group after the first, with no test
coverage. That is a latent bug independent of this work and lives in
`twenty-shared`, shared with the front-end filter system.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01C6nCVbcb5ZZrz67uvqvMWF)_



<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23680?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>
2026-08-03 17:01:21 +00:00
Thomas Trompette 9e2c870574 fix(workflow): clear nextStepIds when converting a step to If/Else (#23714)
## Context

Fixes #22947.

A workflow with If/Else branches could fail at runtime with `Step not
found` (and no detail in the Runs panel) because of dangling
`nextStepIds` in the workflow graph — references to steps that no longer
exist.

## Root cause

An If/Else routes only through `settings.input.branches[].nextStepIds`;
its top-level `nextStepIds` is never read by the executor and must stay
empty. But converting an existing step into an If/Else copied the
previous step's `nextStepIds` onto the new If/Else, leaving a stray
top-level reference. That reference is invisible to the executor, and
when steps around it are later deleted it becomes dangling and
propagates into a normal step's `nextStepIds`, which the executor then
tries to follow — `Step not found`.

## Fix

When a step's type is changed to If/Else, don't carry over the previous
step's `nextStepIds`. One change in
`workflow-version-step-update.workspace-service.ts`.

## Verification

Reproduced on a local instance via the editor's GraphQL mutations (build
`trigger → P → X → D`, convert X to If/Else, delete D then X):
- Before: converting X produced a stray `nextStepIds: [D]`, and after
the deletes P was left with a dangling reference.
- After: converting X yields `nextStepIds: []`, and P stays clean — no
dangling reference.
2026-08-03 16:17:02 +00:00
Thomas Trompette 62c1cf78af fix(server): scope personal favorite SSE events to their owner (#23712)
## Problem

Fixes #20483.

In a multi-user workspace, when a user creates/updates/deletes a
**personal favorite** (a `navigationMenuItem` with a non-null
`userWorkspaceId`), the metadata SSE event is broadcast workspace-wide.
Every other connected user receives it and the favorite pops into their
own sidebar in real time. Cross-user data-isolation leak.

## Root cause

The delivery filter in `WorkspaceEventBroadcaster` already supports
per-user scoping via `recipientUserWorkspaceIds`, but treats an
**undefined** list as workspace-wide (delivered to every stream).
`MetadataEventPublisher.publish` never set that field, so every favorite
event fell into the workspace-wide default. The `agentChatThread` path
already sets `recipientUserWorkspaceIds` explicitly and is scoped
correctly; favorites simply never opted in.

## Fix

In `MetadataEventPublisher`, resolve the owning `userWorkspaceId` for
`navigationMenuItem` events (from `properties.after` on create/update,
`properties.before` on delete) and set `recipientUserWorkspaceIds:
[userWorkspaceId]` when present. Workspace-level items (`userWorkspaceId
=== null`) leave it unset and keep broadcasting to everyone. This
mirrors the existing `agentChatThread` precedent and touches only the
producer, not the broadcaster or any consumer.

## Testing

Unit test (`metadata-event-publisher.spec.ts`) covers personal
create/update/delete (scoped to owner), workspace-level (unscoped), and
an unrelated metadata entity carrying a user id (unscoped).

Also verified end to end against a local multi-user workspace (Tim and
Jane, same workspace): each opened a live SSE stream (`/metadata`
`onEventSubscription`) and Tim created favorites.

| Case | Before fix | After fix |
|------|-----------|-----------|
| Personal favorite -> owner (Tim) | receives | receives |
| Personal favorite -> other user (Jane) | **receives (leak)** | not
received |
| Workspace-level favorite -> other user (Jane) | receives | receives |

- `nx typecheck twenty-server`: pass
- oxlint + oxfmt on changed files: clean

## Scope / follow-up

Favorites only. Two related items are intentionally out of scope and
worth tracking separately: scoping other user-owned metadata (`view` via
its visibility rules, `roleTarget`), and making the broadcaster's "no
recipient list = everyone" default explicit rather than fail-open.
2026-08-03 16:07:54 +00:00
nitin d359496b8b Keep chart palette colors stable when series order changes (#23638)
https://discord.com/channels/1130383047699738754/1522812783140540538

Chart palette colors were assigned by array position, so changing the
sort order (or any reordering of the data) reshuffled every series color
on line, bar and pie charts. Grouping by a select field was unaffected
since options carry their own colors — this only hit groupings without
intrinsic colors (relations, text fields, etc).

Colors are now assigned by the alphabetical rank of the series key, so a
key keeps its color no matter what order the data arrives in. Side
effect: existing palette-colored charts get a one-time color
reassignment.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23638?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-08-03 14:03:50 +00:00
github-actions[bot] b7724bbbee i18n - translations (#23713)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-03 16:01:05 +02:00
Raphaël Bosi 2389b4f807 Add captcha and throttling to the password reset link (#23372)
The public `emailPasswordResetLink` mutation was the only email-taking
auth mutation without `CaptchaGuard`, so bots could drive reset email
spam against arbitrary addresses.

- Adds `CaptchaGuard` and a `captchaToken` argument (no-op when no
captcha provider is configured). The frontend sends it like sign-in
does, and `/settings/profile` joins the captcha-protected paths so the
Change Password button keeps working
- Throttles reset emails per address, 3 per 15 minutes, and surfaces a
rate limit error once the bucket is empty
- Acknowledges the request as soon as the throttle passes and generates
the link off the request path, so the response time no longer depends on
whether the address is registered
- Returns a generic success instead of distinguishing found from
not-found, with matching frontend copy
- Rotates the reset token in a single transaction, so a failed write can
no longer revoke a still valid link

This does not close user enumeration on its own: `checkUserExists`
exposes `exists` on the same unauthenticated surface, and sign-in
returns distinguishable errors. Tracked in #23711.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23372?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-08-03 13:52:05 +00:00
neo773 96ea1e1ffc Track connected account webhook subscription lifecycle metrics (#23710)
Emits created/renewed/deleted counters and their failure counterparts
from the messaging and calendar webhook subscription services.

Each counter carries channel_type and provider attributes so the Grafana
panels can break them down. Infra side: twentyhq/twenty-infra#841

Co-authored-by: neo773 <huzef@twenty.com>
2026-08-03 13:51:17 +00:00
Priyanshu Bartwal 22d83c75e6 fix(twenty-front): Scrolling and Dragging conflict on mobile devices (#23677)
Fixes: #23675 



https://github.com/user-attachments/assets/1f0d4f0f-0dd6-4731-8359-6da13a5e11b3



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23677?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-08-03 13:43:09 +00:00
Raphaël Bosi 4a5c623ece Improve the workspace setup kickoff prompt (#23594)
Rewrites the workspace setup chat kickoff prompt for conversion: the
goal is a real workspace the team keeps using, with the setup doubling
as a tour of what Twenty can do.

- Replaces the arbitrary bands (2-4 custom objects, 3-6 fields, 250
words) with admission tests, favoring custom fields on standard objects
over custom objects.
- Opens by sharing what we already know about the company and the user's
role, then lets them steer: propose a model right away, or hear their
use case first.
- After the data model there is no fixed script. The agent proposes the
single next capability worth building (workflow, dashboard, role) based
on what the user actually said, and names the ones it did not build
before closing so nothing stays hidden.
- Introduces each capability in one plain sentence where it comes up,
and drops the view-field step that #23585 made redundant.

Also passes the workspace member job title into the AI chat user
context, so the agent can shape the setup around what the user does.
This applies to every chat, not just onboarding.

Example: Creating an Apple workspace

<img width="2584" height="5022" alt="CleanShot 2026-08-03 at 15 33
48@2x"
src="https://github.com/user-attachments/assets/bc12ec95-e29d-42fd-8757-38ab3a8a5705"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23594?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-08-03 13:40:57 +00:00
Charles Bochet a9ca1eae95 ci(pr-review): dispatch on PR open (standard review only) (#23708)
## Why

A PR opened directly as non-draft (the normal member flow: push branch →
`gh pr create`) fires no dispatcher trigger — the initial commits
arrived before the PR existed, so they're an `opened` event, not
`synchronize`. With no `opened` trigger, such a PR gets **no review at
all** unless it's later pushed to or manually labelled. This is live
today: #23697 and #23707 are core-team PRs sitting with the bot's `-PR:
draft` label but zero "PR Review" status.

## Change

Add `opened` back to the dispatcher, and forward the triggering PR event
to the orchestrator:

```yaml
types: [opened, ready_for_review, synchronize, labeled]
# ...
-f pr_number="$PR_NUMBER" -f event="$EVENT"
```

The orchestrator (twentyhq/ci-privileged#65) maps **`opened` → standard
review only**; `security` + `triage` stay on pushes / ready-for-review.
So opening a PR gives core-team authors the standard (architectural)
review early, without firing the full gate on open, and the "opened and
never pushed again" hole is closed.

No author-role logic lives here — the dispatcher just forwards
`pr_number` + `event`; all who-gets-what policy is resolved in the
orchestrator.

## Merge order

Depends on **twentyhq/ci-privileged#65** (adds the `event` input). Merge
that first — it's backward-compatible (empty `event` = today's auto-gate
behaviour), so nothing breaks in between.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23708?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-08-03 15:05:02 +02:00
BOHEUS 495bd193a3 Messaging archived account fix (#23553)
Fix case where workspace admin wants to reconnect inherited connected
account

Case:
- workspace admin inherits team accounts from other workspace member who
left the workspace
- admin wants to reconnect inherited channels but it's not possible as
there's no path to make archived connected account active

Expected outcome: admin, who has credentials to archived connected
accounts, can reconnect said accounts

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23553?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: neo773 <huzef@twenty.com>
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
2026-08-03 12:34:31 +00:00
Thomas Trompette e81fdbcc7a feat(workflow): variable pickers for Search Records limit, offset and date filters (#23696)
## Summary

<img width="491" height="390" alt="Capture d’écran 2026-08-03 à 11 30
40"
src="https://github.com/user-attachments/assets/6db59b8a-3e8e-41b8-80b3-c736e56b7f7f"
/>

Adds workflow variable pickers to the **Search Records** action for
fields that previously only accepted static values:

- **Limit** and **Offset** number inputs now expose the
`WorkflowVariablePicker`, so they can be bound to a variable from a
previous step. The stored value can be a standalone variable string; the
backend coerces the resolved value back to a number.
- **Date filters** using the `Is before` (`IS_BEFORE`) and `Is after or
equal` (`IS_AFTER`) operands now expose the variable picker in the
advanced filter side panel (previously disabled for all date filters).

The backend already resolves these inputs via `resolveInput`; the only
backend change is a small numeric coercion of the resolved limit/offset.

## Changes

- `WorkflowEditActionFindRecords.tsx` — pass `WorkflowVariablePicker` to
the Limit/Offset inputs; make `onChange` and form state variable-aware
(`number | string`).
- `AdvancedFilterSidePanelValueFormInput.tsx` — enable the date
`VariablePicker` only for `IS_BEFORE` / `IS_AFTER`.
- `useGetRecordFilterDisplayValue.ts` — return the raw variable for a
standalone `{{variable}}` value so date filters don't crash
`Temporal.*.from`.
- `find-records-action-settings-schema.ts` — allow a string (variable)
for `limit` / `offset`.
- `find-records.workflow-action.ts` — coerce resolved `limit` / `offset`
to numbers before querying.

## Testing

Built a workflow locally (Manual trigger → Code step returning `{ limit:
2, offset: 1, sinceDate }` → Search Records) with all three fields bound
to those variables. The run completed successfully; the Search Records
step returned exactly 2 records (limit applied) filtered by `createdAt
>= sinceDate`, confirming the backend resolves each variable.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23696?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-08-03 12:32:06 +00:00
Paul Rastoin 9e25121616 Fix ReconcileIndexViewUniversalIdentifier failing on occupied derived identifiers (#23647)
## Context

The 2.26 upgrade failed on 93 workspaces at
`ReconcileIndexViewUniversalIdentifier` with `duplicate key value
violates unique constraint "IDX_552aa6908966e980099b3e5ebf"`. The
colliding identifiers decode to standard objects' INDEX views (task in
91 of 93 cases, plus person, company, opportunity, messageThread).

## Root cause (verified against production data)

A fleet-wide scan of live INDEX views whose `applicationId` differs from
their object's `applicationId` returned one row per failing workspace,
matching the failure list one-to-one including every anomaly. The shape
is always the same: a **legacy caller-created view with `key: INDEX`**
(predating the flat view validator rejecting caller-provided INDEX
keys), **attributed to the workspace-custom application but sitting on a
standard object** (usually task), with a random v4 `universalIdentifier`
and `isSystemSideEffect: false` — coexisting with the object's real
INDEX view, which already holds the derived identifier. These were
minted by the old UI default-view bootstrap: views are attributed to the
caller's application context (workspace-custom) regardless of the object
they sit on. Both producer paths are closed by 2.26 itself (validator
rejects caller INDEX keys, side-effect engine provisions INDEX views at
object creation), so this cleans a closed wound.

The command selected candidates by the view's application (engine-owned)
but derived the identifier from the object's application, so it tried to
UPDATE the legacy view onto the exact identifier the real INDEX view
already holds, violating the unique index on `("workspaceId",
"universalIdentifier")`.

## Fix

The command stays updates-only (no inserts, no deletes), with one
decision per candidate view:

- **View's application differs from its object's application** → demote:
`key: null`, plus `isSystemSideEffect: false` if it was stamped as
system-owned. An INDEX view belongs to the application of its object;
the demoted row becomes a plain caller-owned view (same id, name,
fields, filters). This resolves the 93 failures.
- **View already holds its derived identifier** → at most flip
`isSystemSideEffect` to `true`.
- **Derived identifier free** → claim it. **Held by any other row** —
active or soft-deleted (the unique index is not partial on `deletedAt`,
flat maps are loaded `withDeleted`) — or claimed earlier in the same run
→ skip with a warning instead of crashing the workspace upgrade. Same
resolution for view fields.

## Deliberate non-goals

- **Demotion does not touch `universalIdentifier`**: releasing a held
derived identifier would let the backfill command insert a bare
duplicate INDEX view next to the user's customized one; the identifier
stays put and drifted holders are repaired at the data level instead.
- **A skipped view keeps its view fields untouched**: derived view-field
identifiers encode "field F on view D", so a view that cannot claim D
must not stamp its fields with D-derived identifiers (they belong to the
actual holder's field space). Fields converge only when their parent
view converges.
- **No tombstone deletion**: no production failure traced to a
soft-deleted holder; an occupied identifier is skipped, not
destructively freed.

## Release sequencing

The 2 workspaces failing at `DemoteAndBackfillApplicationIndexView`
(Sales, Synergentic) carry the same cross-attribution on objects of
installed applications, but their July reconcile run committed and
stamped the drifted views with the derived identifiers. **Before
re-running the upgrade**: repair their 5 drifted views by re-pointing
`view."applicationId"` at the object's application (SQL in the internal
runbook), then flush the workspace metadata cache (`cache:flush`) so the
commands don't read the stale attribution. After that, both commands are
no-ops there, and the 93 converge on the re-run.

## Test plan

- 7 new unit tests: cross-application demotion (external-app object,
system-flag reset on a stamped drifted view, and the production shape of
a workspace-custom view next to the standard INDEX view), skip on active
and on soft-deleted holders, first-claim-wins on same-object duplicates.
- All 14 pre-existing tests pass unchanged; lint and typecheck pass.
2026-08-03 12:23:15 +00:00
neo773 00ad1544d8 Classify OAuth refresh errors by reason instead of status code (#23705)
Both provider parsers treated unrecognised failures as permanent, so a
single transient error marked a working account as needing reconnection
and it never recovered on its own. Permanence is now decided by the
provider's OAuth error code, everything else is temporary and retries.

Checked against prod: 15 connected accounts currently flagged
auth-failed still return a valid token when refreshed, and 12 of those
were flagged in bursts across unrelated workspaces (five within 90
seconds on 2026-01-13), which points at a transient blip rather than
users revoking access.

---------

Co-authored-by: neo773 <huzef@twenty.com>
2026-08-03 11:43:00 +00:00
github-actions[bot] 37f1fe17ab i18n - docs translations (#23701)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-03 11:49:08 +02:00
github-actions[bot] 5fa54abd79 i18n - translations (#23700)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-03 11:47:29 +02:00
neo773 7c797ff0c9 Filter connected account webhook renewal to exclude accounts with failed authentication errors (#23694)
/closes TWENTY-SERVER-J1F

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23694?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

Co-authored-by: neo773 <huzef@twenty.com>
2026-08-03 09:43:54 +00:00
github-actions[bot] e2d71d2635 i18n - translations (#23698)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23698?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-03 11:40:00 +02:00
Thomas Trompette 9731921983 fix(server): catch application job enqueue throttle instead of failing the batch job (#23684)
## Context

Sentry issue
[TWENTY-SERVER-GXJ](https://twenty-v7.sentry.io/issues/7495161692?project=4507072499810304)
(`Application job enqueue limit reached`, 10k+ events, 33 workspaces) is
the application-job enqueue throttle firing as designed, but being
reported as an error.

## Root cause

`CallDatabaseEventTriggerJobsJob.handle` calls `throttleOrThrow` inside
its per-application loop with no `try/catch`. When an application
exceeds its enqueue budget, the `ThrottlerException` propagates out of
the bullmq job handler, where `shouldCaptureException` captures it (it
has no `statusCode < 500`, so it is not filtered) and the whole batch
job fails.

Two consequences:
- Expected throttling shows up in Sentry as an error (noise). A metric
(`JobEnqueueApplicationRateLimited`) already tracks it.
- The batch job fails and is retried (`retryLimit: 3`), re-running the
loop from the top and re-enqueuing logic-function jobs for applications
that already succeeded before the throttled one (duplicate triggers);
after retries are exhausted the remaining applications' triggers are
dropped.

The workflow hard-throttle uses the same `ThrottlerException` but does
not show up in Sentry because its call site (`checkHardThrottleLimit`)
catches it and turns it into a graceful signal. This PR applies the same
pattern to the application enqueue path.

## Change

- Wrap the `throttleOrThrow` call: on `ThrottlerException`, `continue`
to the next application instead of failing the job; rethrow anything
else.
- Add a unit test covering the skip-throttled-application and
rethrow-other-errors behavior.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23684?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-08-03 09:38:45 +00:00
Thomas des Francs 536b91c1dd Update record page layout card (#23654)
## Summary

- Redesign the data-model Layout card to match the record-page
customization design.
- Add dedicated light and dark cover assets and preserve the existing
customization workflow.
- Reuse a dedicated discovery-hero footer across the workspace and
record layout cards.
- Match the Figma cover, footer, icon, typography, and spacing metrics.

## Before/After

<img width="1588" height="720" alt="before-after"
src="https://github.com/user-attachments/assets/28be577c-9f1b-40f1-99e2-66eeafbac75b"
/>
2026-08-03 09:31:25 +00:00
Paul Rastoin 6afc3d33a4 fix: provision INDEX view fields for relations created in the same batch as their object (#23665)
## Context

Fixes twentyhq/core-team-issues#2749: when an app manifest creates an
object and its relation fields in a single sync, the engine-owned INDEX
view ended up with no viewField at all for RELATION / MORPH_RELATION
fields, not even a hidden one. Adding the same relation to a
pre-existing object in a second sync produced a visible viewField.

## Root cause

`fieldIndexViewFieldOnCreate` is the sole owner of caller-field view
fields on the INDEX view (`objectSystemFieldsAndIndexViewOnCreate` only
emits view fields for displayable system fields). Its same-batch branch
gated on `isFlatFieldMetadataDisplayableInDefaultView`, which excludes
RELATION / MORPH_RELATION, so relations were dropped and nothing else
picked them up.

The guard's other exclusions (reserved names like `id`/`deletedAt`,
system-only types TS_VECTOR / POSITION) are unreachable there: side
effect handlers only trigger on caller-authored entities (the engine
reads triggers from the pre-expansion matrix), and the flat field
validators reject those names/types for caller fields. The guard could
only ever drop relations.

## Fix

- Remove the displayability guard from
`buildViewFieldForObjectCreatedInSameBatch`.
- Remove the `displayableOnly` filter from
`computeCallerFlatFieldMetadatasForObject`: every caller field now gets
a view field, and both handlers keep deriving positions from the same
list, so the interleaved layout stays consistent by construction (label
identifier, caller fields in input order with relations, then
displayable system fields).
- Build the view field literal through a single
`buildIndexFlatViewFieldToCreate` helper on all handler branches instead
of `computeFlatViewFieldsToCreate`, whose internal displayability filter
would have dropped relations again. That util keeps its semantics for
its remaining callers (system-field view fields, object creation via
API, committed upgrade commands). Both identifier derivations are the
same deterministic uuid (asserted by an existing twenty-shared spec), so
emitted identifiers are unchanged.
- `isFlatFieldMetadataDisplayableInDefaultView` itself is untouched: the
committed 2-26 upgrade command and the system-field filtering still rely
on its current semantics.

## Tests

- New manifest-sync integration test (first commit, TDD red then green):
a single sync creating two objects and a MANY_TO_ONE / ONE_TO_MANY
relation pair asserts each object's INDEX view has a visible view field
for its relation, plus a control case adding the same relations to
pre-existing objects in a second sync.
- Unit spec: the test that locked in the noop now asserts a visible view
field at the expected position for RELATION and MORPH_RELATION.
- Verified locally: all 62 metadata-side-effect unit tests, the new
integration spec, `successful-sync-application-workspace-migration` (4
snapshots), `relabel-onto-new-field-manifest-sync`,
`create-one-field-metadata-relation`, plus twenty-server typecheck and
lint.
2026-08-03 09:23:12 +00:00
BOHEUS c0efc1d897 Docs update - Microsoft integration (#23671)
Based on
https://discord.com/channels/1130383047699738754/1526558939221856276/1532723206194991194

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23671?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-08-03 08:43:45 +00:00
Thomas Trompette ad8830ecbf fix(server): stop FIND_RECORDS from silently ignoring its filter (#23640)
## Problem

A FIND_RECORDS workflow step with a filter configured could silently
return every record (and thus the first row of the table) instead of
applying the filter. Ways to hit it:

- `recordFilters` set but `recordFilterGroups` omitted (e.g. an
API/agent caller, or any non-UI config).
- A grouped filter (carrying `recordFilterGroupId`) whose
`recordFilterGroups` is missing.
- A filter referencing an unknown `fieldMetadataId` or an unresolvable
relation (`turnRecordFilterIntoRecordGqlOperationFilter` returns
`undefined`, silently dropped).
- `gqlOperationFilter` set, which passed validation but was never read.

In every case the computed filter collapses to `{}`,
`FindRecordsService` returns all records ordered by `id ASC`, and
`records[0]` is the first row. This is fail-open: the step reports
success and returns wrong records rather than erroring.

## Fix

- Compute the filter whenever `recordFilters` is non-empty, defaulting
`recordFilterGroups` to `[]`. `computeRecordGqlOperationFilter` handles
ungrouped filters independently of groups.
- **Fail closed**: if `recordFilters` is non-empty but the computed
`gqlOperationFilter` is empty, throw `INVALID_STEP_INPUT` instead of
running an unfiltered query. This covers grouped-without-groups and
unknown-field/unresolvable-relation cases raised in review. An absent or
empty `recordFilters` still legitimately means "find all".
- Remove the unused `gqlOperationFilter` field from the find-records
input type and settings schema so it is no longer advertised as a filter
option (it has been dead since #16147, when the action moved to
computing the filter at runtime from `recordFilters`).
2026-08-03 08:24:44 +00:00
github-actions[bot] 9df893ead1 chore: sync AI model catalog from models.dev (#23682)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.

**Please review before merging** — verify no critical models were
incorrectly deprecated.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23682?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-08-03 09:01:12 +02:00
github-actions[bot] b754e15331 i18n - docs translations (#23670)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-01 18:48:55 +02:00
Paul Rastoin ebe816abb5 Remove outdated index view pitfall from app agent docs (#23667)
## Context

Index views are now auto-generated by a metadata side effect when an
object is created, so the "Common Pitfalls" entry warning against
creating an object without an associated index view is obsolete.

## What changed

Removed the line "Creating an object without an index view associated.
Unless this is a technical object, user will need to visualize it."
from:

- the `create-twenty-app` template
(`packages/create-twenty-app/src/constants/template/AGENTS.md`), used
for newly scaffolded apps
- the 14 existing copies across `packages/twenty-apps` (`AGENT.md` /
`AGENTS.md` / `CLAUDE.md` / `LLMS.md` in `examples`, `public`, and
`internal` apps)

The other pitfalls (navigationMenuItem, front-component scroll) are
unchanged since they still apply.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01YYzo4dY3V7QXdDZtvk5FwY)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23667?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-08-01 15:04:30 +00:00
github-actions[bot] d77b1017aa chore: sync AI model catalog from models.dev (#23666)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.

**Please review before merging** — verify no critical models were
incorrectly deprecated.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23666?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-08-01 08:47:11 +02:00
github-actions[bot] 0773311fa8 i18n - docs translations (#23662)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-31 19:04:46 +02:00
github-actions[bot] b419805baf i18n - translations (#23660)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23660?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-31 18:44:31 +02:00
github-actions[bot] 503e40d51b i18n - translations (#23659)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-31 18:37:10 +02:00
Paul Rastoin 4f8aaeaab0 refactor(navigation-menu-item): validate universal properties instead of ids (#23566)
Follow-up to the discussion on #23485 and closes
https://github.com/twentyhq/twenty/issues/23484.

`FlatNavigationMenuItemValidatorService` receives
`UniversalFlatEntityValidationArgs<'navigationMenuItem'>`, so the entity
it validates is a `UniversalFlatNavigationMenuItem`: `viewId`,
`pageLayoutId` and `targetObjectMetadataId` do not exist at that scope.
The validator read the right universal keys but passed them through a
bag of booleans named after the ids (`hasViewId`, `hasPageLayoutId`,
...) and then reported the id names in its errors. Nothing tied a
message to the property it checked, so fixing one message string leaves
the other five wrong.

## Changes

- Replace the private `validateNavigationMenuItemType` boolean bag with
`validateNavigationMenuItemTypeRequiredProperties({
flatNavigationMenuItem })` under
`flat-navigation-menu-item/validators/utils/`, in line with
`validateAgentRequiredProperties` and
`validateNavigationMenuItemPageLayoutReferenceCrossEntity`. It takes the
universal entity, so a message can only name a property that exists at
that scope.
- The util is an explicit `switch` on `NavigationMenuItemType` closed by
`assertUnreachable`, so adding a type fails to compile until its
contract is declared.
- Each case validates its own properties instead of checking presence
generically:
  - `FOLDER`: non blank `name`
- `OBJECT`, `VIEW`, `PAGE_LAYOUT`:
`targetObjectMetadataUniversalIdentifier` / `viewUniversalIdentifier` /
`pageLayoutUniversalIdentifier` must be valid uuids
- `RECORD`: `targetRecordId` and
`targetObjectMetadataUniversalIdentifier`, both uuids, reported
separately
  - `LINK`: `link` must pass `isValidUrl`
- Both call sites spread the result; the update path passes the merged
`{ ...from, ...update }` entity, which removes the redundant `name`
re-merge.

`targetRecordId` stays an id: it points at workspace record data rather
than metadata, so it has no universal counterpart.

## Behaviour

- Errors name the universal property (`viewUniversalIdentifier`) instead
of the id (`viewId`).
- Blank strings are now uniformly treated as missing; creation
previously accepted `link: " "`.
- `RECORD` reports each missing property separately instead of one
merged error.
- Values that are present but malformed are now rejected: non uuid
identifiers and links that are not urls. Standard application
identifiers are all v4 uuids and the create/update inputs already carry
`@IsUUID`, so this only tightens the app manifest path.

## Verification

- Unit tests for the util cover each type valid and invalid, blank
names, non url links and non uuid identifiers (23 tests pass alongside
the sibling suite)
- `nx typecheck twenty-server` clean
- oxlint (type-aware) and oxfmt clean on the changed files

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23566?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-31 16:35:16 +00:00
Félix Malfait f663cd3c68 Move open-record-in to object metadata and member preference (#23614)
Replaces the per-view "Open in" setting with a two-level model,
following up on #23422 / #23424 and superseding the closed #23446 and
#23457:

- `objectMetadata.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` |
`USER_CHOICE` (default `USER_CHOICE`)
- `workspaceMember.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` (default
`SIDE_PANEL`), editable in Settings > Experience

The rule: records open where the member prefers, unless the object pins
them, and never in a panel there is no room for (mobile always resolves
to the record page).

## Why

Having the setting on views, objects and members at once was heavy, and
view-level resolution was fragile: a chip rendered outside a view
(notes, front components, kanban cards pointing at another object) had
no view to read from, which is the class of bug behind #23422.
Resolution is now context-free: it needs only the object, the current
member and the viewport, so chips behave identically everywhere by
construction.

## Changes

**Object level**
- New `openRecordIn` enum column on `objectMetadata`, editable through
`updateOneObject` and surfaced in Settings > Data model > Object >
Layout ("Open records in": Member preference / Side Panel / Record Page)
- Standard definitions pin `workflow`, `workflowVersion`, `dashboard`
and `messageCampaign` to the record page (matching the previously
hardcoded list) and `calendarEvent` to the side panel (it has no curated
record page); everything else, including `workflowRun`, follows the
member preference
- Apps can set it in `defineObject()` via the object manifest

**Member level**
- New `openRecordIn` standard field on `workspaceMember`, persisted
through the existing settings path (same as `colorScheme`) and exposed
in Settings > Experience

**View level (deprecated)**
- `view.openRecordIn` is no longer read or written by the frontend; the
"Open in" entry is gone from the view options dropdown
- The column, DTO field and inputs are kept for one release for API
compatibility: the output field carries a `deprecationReason`, the
inputs keep accepting the value with a `Deprecated:` description (NestJS
silently drops input fields that have a `deprecationReason`, which would
have been a breaking change)

**Upgrade (2.27)**
- Fast instance command adds the `objectMetadata.openRecordIn` column
defaulting to `USER_CHOICE`
- Workspace command adds the `workspaceMember.openRecordIn` field
- Workspace command seeds the object column from the standard
definitions (any non-`USER_CHOICE` value), then lifts deliberate
per-view record page choices onto objects the definitions don't pin

**Debt removed**
- `canOpenObjectInSidePanel` hardcoded object list and its test
- `ObjectOptionsDropdownLayoutOpenInContent` and the `layoutOpenIn`
dropdown wiring
- `DefaultViewOpenRecordIn`
- Context-store/view-based resolution in `useResolveOpenRecordIn` (now
reads object metadata + member + viewport)
- Front components no longer guess from the current view: an explicit
side-panel call honours a pinned object and the viewport, nothing else

## Verification

- Ran the three upgrade commands against a live database: column
created, the pinned standard objects seeded per workspace (record page
pins plus calendarEvent to side panel), member field backfilled to
`SIDE_PANEL`; seed rerun is a no-op
- Seed command verified on a simulated pre-upgrade workspace (index view
set to record page on company): pins the standard objects plus company,
idempotent on rerun
- Both packages typecheck and lint clean; affected unit suites and the
application sync, view creation and metadata cache integration specs
pass

---------

Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com>
2026-07-31 18:28:55 +02:00
martmull 6f361a9bf2 refactor(last-contact): drive backfill with enqueued jobs (#23646)
## What

Reworks the last-contact backfill, which previously ran an orchestrator
that called its own HTTP route in a recursive loop with blocking
`sleep`s, into a single upfront fan-out of enqueued jobs.

On install, `backfill-last-contact` counts people, opportunities and
companies, then enqueues one job per record batch (`ceil(count /
batchSize)`) for each. Each batch job receives its `batchId` in its
payload and processes the matching record window via offset pagination
(`first`/`offset` with a stable `createdAt, id` ordering). No
self-calling loop, no blocking sleeps, no per-batch chaining, no kv
progress state.

Phases don't need to run in sequence: each phase recomputes last-contact
from source interactions (message and calendar participants), not from
the person's stored field, so the jobs are independent and safe to run
concurrently.

## Changes

- `backfill-last-contact` (post-install): counts each phase and fans out
all batch jobs via `enqueueJob`; timeout raised to 300s. Enqueues are
concurrency-limited and staggered with `delayMs` (from
`LAST_CONTACT_BACKFILL_SLEEP_MS`) so thousands of jobs don't all become
eligible at once.
- `src/utils/enqueue-backfill-jobs.ts` (new): counts a phase via the
connection `totalCount`, builds the batch plan, and enqueues the jobs.
- `src/utils/backfill-batch-args.ts` (new): `first`/`offset`/`orderBy`
window for a given `batchId`, ordered by `createdAt, id` so offsets stay
stable while the backfill runs.
- `backfill-{people,opportunities,companies}-last-contact`: handlers now
take `{ batchId }`, query their offset window, process it, and return `{
batchId, count }`. No HTTP route triggers, no cursor/kv state.
- Removed the chaining util (`advance-backfill.ts`) and the
now-purposeless kv-state debug helper (`backfill-get-state.ts`).
- Bumped `twenty-sdk`/`twenty-client-sdk` to `^2.26.0` (first published
version exporting `enqueueJob`), app version to `1.2.3`, plus changelog.
Updated the server-variable copy.

## Testing

- `yarn typecheck` clean
- `yarn lint` clean (0 warnings, 0 errors)
- `yarn test:unit` green (38 passed)
2026-07-31 15:53:14 +00:00
Rashad Karanouh 0f8c227105 v1.6.1 — fix(partners): scope Application RLS to the partner's own partnerUser (#23597)
**Version:** twenty-partners `1.6.1` (`on-application-created` gains
behaviour; no schema change).

## The bug

On https://partners.twenty.com every partner could list **all**
applications, not just their own.

The Partner role's row-level predicate on `Application` was:

```
(partnerUser IS the current member)  OR  (lastActivityAt IS EMPTY)
```

The `IS EMPTY` branch was an insert escape hatch: the Apply workflow
creates the row before `on-application-created` can stamp it, and RLS
validates an insert against the row as submitted.

Only that self-apply path ever writes `lastActivityAt`
(`resolve-candidacy.service.ts`). Every other creation — admin invite,
TFT import, seed — returned early, so `lastActivityAt` stayed `null`
forever and the hatch never closed. All 7 applications in production had
`lastActivityAt = null`, which made the whole table readable by every
partner.

## The fix

1. **Drop the OR group.** `application` becomes a plain `partnerUser IS
the current member` predicate, like the other partner-scoped objects.
The upsert reconciles per (role, object), so the stale group and
predicate are soft-deleted on re-run — a leaking workspace self-heals.
2. **Keep admin invites visible.** The OR branch was also the only
reason an admin-created invite reached its recipient.
`resolve-candidacy` now stamps `partnerUser` from the partner on the
admin path, instead of returning early.
3. **Backfill the rows created before the narrowing.** Neither writer
covers an application an admin created for an already-linked partner;
those existed only behind the leak. `stampPartnerUserFromPartner` now
covers `application` (its three copy-pasted branches collapsed into an
accessor map routed through `shared/graphql/`), and the walk runs from
the app's post-install logic function, gated on `previousVersion <
1.6.1`. No manual step.
4. **Preserve the insert path.** The Apply workflow must map exactly
Opportunity + Partner User. `partnerUser` is writable at insert only
because the server exempts RLS predicate fields there
(`permissions.utils.ts`, insert case only); every other Application
field is locked, so mapping `State` fails the insert — and `state`
already defaults to `APPLIED`.

## Order of operations, per workspace

1. Publish the Apply workflow with the Partner User mapping (edit it if
it already exists).
2. `yarn rls:configure` (`:prod`).

`app:install` stamps the pre-existing rows before step 2 runs, so no
window exists where a partner reads nothing. The script prints these
steps before and after its writes, because the deploy path never opens
the runbook.

## Verification (local bundle, real Partner-role account)

| Case | Result |
|---|---|
| Another partner's application | not visible |
| Own application (`lastActivityAt` null) | visible |
| Admin invite created with `partnerUser` null | stamped from the
partner within seconds |
| All applications stripped of `partnerUser`, then upgraded from 1.6.0 |
post-install returns `{ stamped: 3 }`; the 4th belongs to a partner with
no member |
| Upgrade from 1.6.1 | post-install returns `{ skipped: true }` |
| Insert without `partnerUser` | rejected — *Record does not satisfy
row-level security constraints of your current role* |
| Insert with `partnerUser` = self | accepted |
| Insert with `state` mapped | rejected — *no permission to write field
"state"* |

Lint 0, typecheck 0, 221 unit tests.

## Production notes

- The fix lands on prod by running `yarn rls:configure:prod`. Installing
this version alone does not narrow the predicate.
- The 7 production applications were already backfilled by hand; the
post-install hook makes that reproducible for any other workspace.

## Out of scope

- Creating an OR predicate group fails on server 2.23.2 in a fresh
workspace (`Migration action 'create' for
'rowLevelPermissionPredicateGroup' failed`). Pre-existing and unrelated;
production is unaffected because its `opportunity` group already exists.
It does block `rls:configure` on newly provisioned local bundles.
- Partners still see the `Matching Admin Workspace` navigation folder.
Navigation menu items cannot be scoped by role in the SDK; the views are
row-filtered.
- `configure-partner-rls.ts` should not exist. #21919 made
`rowLevelPermissionPredicates` declarable on the role manifest, and the
SDK we depend on already ships it, so the predicates belong in
`partner.role.ts`. The predicates on the workspace today were written
through the metadata API and are not app-owned, so adopting them needs
its own migration and test pass. Follow-up.
- Deferred cleanups are listed in the thermo review comments below.
2026-07-31 15:35:21 +00:00
github-actions[bot] d2085e60c8 i18n - docs translations (#23653)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-31 17:19:26 +02:00
Weiko 9a7658391b Remove noisy actor context injection logs (#23649)
## Context

`ActorFromAuthContextService` runs while preparing records for create
and update operations. Every actor-field injection logged the complete
list of field names found in the object's metadata. It also logged when
an object did not contain the actor field, even though skipping
injection is an expected control-flow path.

These messages are not actionable in normal operation. On a frequently
used path, building and emitting them adds avoidable string allocation,
serialization, output, and log-ingestion work.

## What changed

- Remove the metadata field-list info log.
- Remove the info log for the expected missing-field path.
- Remove the now-unused NestJS logger instance.

## Safety

This only removes routine informational logging. Actor metadata lookup,
missing-field handling, record cloning, and `createdBy` / `updatedBy`
injection are unchanged. Errors are not suppressed, including the
existing error for an unsupported authentication context.

## Expected impact

This reduces application log volume and avoids repeated formatting of
metadata field-name arrays on record mutations. It is a small hot-path
cleanup intended to reduce allocation and logging overhead, not a
standalone fix for API tail latency.

## Validation

- Oxfmt check on the changed service
- Oxlint on the changed service
- `ActorFromAuthContextService` Jest suite, 4 tests passing
- `git diff --check`
2026-07-31 14:57:46 +00:00
martmull 2068bb65b4 Update slack app naming (#23650)
as title

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23650?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-31 14:38:33 +00:00
Rashad Karanouh 4f429a3976 v1.6.0 — Partners: let partners set the regions they serve on My Profile (#23605)
**Partners app version: `1.6.0`** (minor — new backwards-compatible
field, no schema change)

## Problem

`Partner.region` is a `MULTI_SELECT` (`EUROPE`, `US`, `LATAM`, `MENA`,
`APAC`, `AFRICA`) that was **readable everywhere but writable nowhere**:

- the public partner marketplace **filters** on it
(`filter-partners.ts`) and **displays** it (`PartnerProfile.tsx`)
- the self-service loader already selected and mapped it
(`find-my-partner-profile.ts`, `get-my-partner-profile.mapper.ts`)
- but the **My Profile form never rendered it**, and
`save-my-partner-profile.mapper.ts` uses a `.strict()` zod schema that
rejected the key outright

The only way a partner got a region was `deriveRegion(country)` at
application intake — a single region, derived from their home country,
set once. So a partner serving several regions could not say so, and
anyone who applied without a mapped country had a null region and was
invisible to every region filter on the marketplace.

## Change

"Regions served" becomes partner-editable, in the Location section of My
Profile under Country/City.

| File | |
|---|---|
| `self-service/constants/my-profile.constants.ts` | six region options,
mirroring `partner.object.ts` |
| `self-service/mappers/save-my-partner-profile.mapper.ts` | schema key,
value validation, mapping onto the update payload |
| `front-components/my-profile/profile-form.ts` | **new** — the form's
pure helpers, extracted so they can be unit-tested |
| `front-components/my-profile/profile-form.test.ts` | **new** |
| `front-components/my-profile.front-component.tsx` | the
`ChipMultiSelect` field; ~110 lines lighter after the extraction |

The extraction follows the pattern already used by
`my-case-studies/case-study-rows.ts` and its co-located test. It is a
separate, behaviour-free commit (`321f2c42`) to keep it reviewable apart
from the feature.

## Deliberately out of scope

- **No country → region coupling.** Country is where you are; region is
where you sell. Changing Country does not re-derive, seed, or clear
Region. `deriveRegion` stays intake-only.
- **No backfill.** Existing partners with a null region keep it until
they edit their profile.
- **No marketplace or `completenessScore` change** — both already handle
`region` correctly.
- **No object/schema change.** `yarn twenty plan` reports `0 to add, 3
to change, 0 to destroy` (two logic-function checksums plus the
front-component checksum).

## Permissions

This does not widen partner privileges. `region`'s field UUID is absent
from the locked-field list in `src/roles/partner.role.ts`, so the
partner role already permitted region writes on the partner's own record
via the CRM page — this only surfaces it in the self-service form. The
save path resolves `partnerId` from the request JWT
(`resolve-partner-from-request.service.ts`); the body never supplies a
record id, and `.strict()` rejects one if sent.

## Verification

- `yarn test:unit` — 217/217
- `yarn lint` (oxlint) — 0 warnings, 0 errors
- SDK build typecheck — passes
- Verified end to end against a local workspace: the field renders with
six chips, pre-selects from the stored value, saving two regions
persists `["EUROPE","MENA"]`, and deselecting all persists `[]` while
leaving name/city/country untouched.

## Known, pre-existing, not addressed here

`toMoneyField` round-trips the stored `currencyCode`, but
`saveProfileSchema` pins `currencyCode: z.literal('USD')`. A partner
whose `hourlyRate` was set to a non-USD currency in the CRM therefore
has **every** profile save rejected, with no currency picker in the UI
to correct it. Surfaced while reviewing this branch; it predates it and
is left for a separate fix.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23605?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-31 13:42:32 +00:00
Thomas Trompette cb338962f5 fix(server): preserve null values in record update util (#23639)
## Problem

Record updates through the MCP server silently drop `null` values.
Setting a field to `null` (to clear it) returns success but changes
nothing.

## Root cause

`removeUndefinedFromRecord` is called by `UpdateRecordService.execute`
before the DB write. It used `isDefined(value)` to decide what to strip,
but `isDefined` returns false for **both** `undefined` and `null`:

```ts
export const isDefined = (value) => !isUndefined(value) && !isNull(value);
```

So despite the name (and the comment stating the validation layer
expects "a value or null"), the util also discarded `null`. The MCP
`update_one` path deliberately keeps nulls (it filters only `!==
undefined`), but this util then removed them one layer down.

The workflow UPDATE_RECORD action was less affected because it passes an
explicit `fieldsToUpdate` list, but the value-level strip sits on both
paths.

## Fix

Strip only `undefined`; preserve `null` so a field can be explicitly
cleared. Added unit tests covering undefined stripping, null
preservation, and nested composite fields.

## Test

```
npx jest remove-undefined-from-record
# 5 passed
```

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23639?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-31 13:36:49 +00:00
Thomas Trompette 706d72e53e fix(front): reload record board groups when view groups change (#23637)
## Problem

Fixes #23462

On a Kanban (record board) view grouped by a SELECT field, adding a new
option to that field creates the column, but dragging a record into the
new column silently fails (no move, no error) until a hard page reload.

## Root cause

`RecordIndexLoadBaseOnContextStoreEffect` builds its load key from the
view id and the calendar-week flag only:

```
`${contextStoreCurrentViewId}-${isCalendarWeekViewEnabled}`
```

The effect early-returns when `loadedViewKey === currentViewLoadKey`.
When a new `ViewGroup` is created from the added SELECT option, the view
id does not change, so the key is unchanged and `loadRecordIndexStates`
never re-runs. The record group state (`recordGroupIdsComponentState` /
`recordGroupDefinitionFamilyState`) stays stale, so the drop handler
cannot resolve the new group's field value and the move no-ops. A hard
reload fixes it because the view then loads with the new group present
from the start.

## Fix

Include a signature of `view.viewGroups` (ordered
`id:position:isVisible`) in the load key so the effect re-runs
`loadRecordIndexStates` whenever the view's groups change, not only when
the view id changes. The calendar-week flag is kept in the key.

## Testing

Verified end to end on a local instance against an Opportunities "By
Stage" Kanban, with the record's stage change confirmed in the database:

- **Before the fix:** add a new Stage option in-session, then drag a
record into the new column. Dragging into an existing column persists
the move; dragging into the newly created column does nothing (record's
stage unchanged in DB).
- **After the fix:** same flow, dragging a record into the newly created
column moves it and persists the new stage in DB, with no reload.

`nx typecheck twenty-front` and `oxlint` pass.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23637?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-31 13:32:13 +00:00
Raphaël Bosi 2aa118cb0d Add a roles standard skill for the AI chat agent (#23636)
Role management tools shipped in #23613, but no skill was added, so the
agent could call them with no guidance.

This adds a `roles` standard skill covering the traps that are easy to
get wrong: `upsert_object_permissions` replaces the role's entire
override list (so omitted objects silently revert to global
permissions), granting write without read is rejected, system-managed
roles like Admin cannot be modified, and the lockout guard rejects
mutations that would strip the acting admin's own access. It also tells
the agent to call `list_roles` first, since every workspace already
ships with Admin and Member, and reuses the confirmation-gate pattern
from `dashboard-building` before any create/update/delete.

New workspaces get the skill from the standard application. There is no
generic sync for existing ones, so this also adds a `2-27` backfill
command that diffs computed standard skills against existing ones and
creates the missing ones.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23636?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-31 13:20:43 +00:00
Raphaël Bosi 0f06fccdee Make page layout tab layoutMode diffable and default standalone pages to vertical list (#23596)
Reported on Discord: an app declared a `STANDALONE_PAGE` layout with one
`FRONT_COMPONENT` widget and got a small bordered card instead of a
full-bleed page, and setting `layoutMode` afterwards changed nothing.

Two bugs:

- `pageLayoutTab.layoutMode` was `toCompare: false`, so it was written
once at create and never diffed again. Changing it in a manifest and
redeploying was a silent no-op, and `updatePageLayoutTab(layoutMode:)`
was accepted by the API then dropped by the runner's update sanitizer.
- A manifest tab that omits `layoutMode` defaulted to `GRID` regardless
of page layout type, and a `GRID` tab always renders its widgets as
cards on a 12-column grid. Standalone pages now default to
`VERTICAL_LIST`, where a lone widget owns the tab.

Also fixes the SDK scaffolder (`twenty add page-layout` emitted a tab
with no `position`, which does not typecheck) and the docs claim that a
single widget is always full-bleed.

Worth knowing for review: this does not migrate workspaces holding
legacy `CANVAS` tabs. The standard-app sync only runs against a fresh
schema, so those rows stay `CANVAS` and keep rendering correctly through
the derived presentation.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23596?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-31 13:12:19 +00:00
martmull 50adf4fa8c Remove shouldRunOnVersionUpgrade (#23641)
remove shouldRunOnVersionUpgrade 

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23641?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-31 13:09:28 +00:00
nitin 4019b990c5 Unwrap settings front components in the front component build (#23631)
## Summary

The front-component build transform only unwrapped
`defineFrontComponent`, so a file using `defineSettingsFrontComponent`
skipped the transform and built its ValidationResult object as the
default export. The front-component renderer then crashed at load with
"componentModule.default is not a function".

Broadens the unwrap patterns to cover both define functions and adds a
spec case. Introduced in #23256 alongside the new define function; no
app exercises the path yet.

## Validation

```
cd packages/twenty-sdk && npx vitest run --config vitest.unit.config.ts src/cli/utilities/build/common/front-component-build/utils/__tests__/unwrap-define-front-component-to-direct-export.spec.ts
```

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23631?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-31 12:54:15 +00:00
avonian d02332834b Fix record title cell reopening on every page refresh (#23551)
## What

`location.state.isNewRecord` is set when navigating to a freshly created
record so the title cell opens for naming. But router state lives in
**browser history state, which survives page refreshes** — so every
refresh of that record's page re-opens the title cell with an empty
draft and a blinking cursor.

Strip the flag after its one intended consumption in `PageChangeEffect`
(react-router keeps user state under `history.state.usr`).

## Repro (on current main, any view set to open records in record page —
or on mobile)

1. Create a record from a table; you land on its record page with the
title focused (intended).
2. Name it, click away, then refresh the page.
3. The title cell re-opens, empty, focused — on every refresh, forever.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23551?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
2026-07-31 12:48:23 +00:00
Félix Malfait 57ecab8571 Fix TOTP validation ignoring the configured tolerance window (#23632)
## Problem

Entering a 2FA code right after it rotates fails with "Invalid OTP". A
tolerance window was configured (`TOTP_DEFAULT_CONFIGURATION.window`)
but never applied: `TotpStrategy` validated its options with Zod and
then discarded them, and `validate()` called the global otplib
`authenticator`, which defaults to `window: 0`. Only the current
30-second code was ever accepted, and clock drift between the device and
the server made it feel even stricter.

## Changes

- `TotpStrategy` now clones the otplib authenticator with the validated
options (window, step, digits, algorithm, encoding, epoch) and uses that
instance for both `initiate` and `validate`, so the configured tolerance
actually applies.
- Default window set to 1: the previous and next codes are accepted
alongside the current one, so a code stays valid for up to 30 extra
seconds after rotation and forward clock skew is tolerated. This follows
the RFC 6238 recommendation of one time step, kept deliberately tight
since `getAuthTokensFromOTP` has no rate limiting beyond the optional
captcha guard.
- Replaced the placeholder strategy tests with deterministic assertions
using fixed epochs: previous and next tokens accepted within the window,
a token two steps old rejected, and no-window strategies still reject
the previous token.

## Testing

- All 2FA module unit tests pass (105), including 19 for the strategy.
- `lint:diff-with-main` and `typecheck` pass for twenty-server.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Va3ESUmkp14Wu65sAXkL7k)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23632?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-31 14:11:06 +02:00
Félix Malfait fec266a5ae test(server): strengthen MCP catalog and gating coverage (#23630)
The three MCP testing improvements discussed in #23613 (now merged; this
branch has been rebased onto main).

## What

**1. Name-set assertions in `mcp-protocol.service.spec.ts`.** The two
&#34;exactly 6 tools&#34; tests only used `expect.objectContaining`
subset matches, so the size claim in the titles was never enforced and a
new meta-tool would pass silently. They now assert the exact sorted key
set of the ToolSet handed to the executor. This immediately caught real
drift: the toolset has seven tools, and `get_tool_catalog` was missing
from the expected list.

**2. Catalog contract integration test**
(`test/integration/ai/suites/mcp-tool-catalog.integration-spec.ts`).
Calls `get_tool_catalog` over real HTTP with an API-key bearer, then for
every advertised category dispatches one read-only tool
(`find_/list_/get_/search_` prefixed, up to 3 candidates) through
`execute_tool` and asserts a success envelope. Any newly registered
provider is covered the moment it appears in the catalog, with no new
test code. Categories with no read-only tool are compared exactly
against a deliberate exception list, currently empty since every
advertised category ships a read-only tool, so drift in either direction
fails loudly.

**3. Permission gating integration test** (same suite). Creates two API
keys: one bound to Admin, one bound to a freshly created role with
`canUpdateAllSettings: false` and no settings flags. Asserts the ROLE
category (from #23613) is present in the admin catalog and absent from
the restricted one, while the restricted key still sees DATABASE_CRUD
read tools, proving it is gating rather than a broken catalog. This
locks the provider `isAvailable` contract at the real HTTP boundary,
which the unit mocks cannot.

## Testing

- `npx jest src/engine/api/mcp` — 43 tests pass
- `test/integration/ai/suites` — 4 suites, 24 tests pass locally against
a reset DB
- `npx nx typecheck twenty-server` clean; oxlint and oxfmt clean on the
touched files


---
_Generated by [Claude
Code](https://claude.ai/code/session_0131sLKVsRuaDoaKCxFM8g4Z)_


<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23630?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>
2026-07-31 14:10:30 +02:00
github-actions[bot] f74f71785e i18n - translations (#23635)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-31 13:57:23 +02:00
Félix Malfait 5d88cf7f2f feat(server): add role management tools for AI chat (#23613)
Adds role management tools to the AI chat so the agent can create and
configure roles, including row-level permissions.

## What

A `RoleToolProvider` in `tool-provider/providers/`, mirroring
`webhook-tool.provider.ts`, registered in `tool-provider.module.ts` and
gated behind `PermissionFlagType.ROLES` via
`PermissionsService.checkRolesPermissions` (same pattern as the
VIEWS/WORKFLOWS gating). Tools:

- `list_roles` — global record permissions, settings access, per-object
overrides, permission flags, assignability; optionally includes
row-level rules
- `create_role`, `update_role`, `delete_role`
- `assign_role_to_workspace_member` — via
`UserRoleService.assignRoleToManyUserWorkspace`, which goes through
role-target
- `upsert_object_permissions` — per-object overrides, e.g. read-only on
a given object
- `upsert_row_level_permission_rules` — reuses
`RowLevelPermissionPredicateService.upsertRowLevelPermissionPredicates`
and the predicate-group service, so the agent can express rules like
"members with this role only see records where the owner field matches
the current user" (a predicate with `workspaceMemberFieldMetadataId`
pointing at the workspaceMember `id` field, resolved to the current user
at query time)

Everything routes through the existing role services and DTOs
(`RoleService`, `ObjectPermissionService`, `UserRoleService`, the
row-level predicate services) rather than reimplementing them. A new
`ToolCategory.ROLE` is added to `twenty-shared`, along with its label in
the exhaustive switch in `build-tool-catalog-section.util.ts`.

## Safeguards

- Any mutation on a role with `isEditable: false` is rejected. That
covers the Admin role, which is created non-editable, and matches what
Settings blocks.
- Deleting the role the caller is currently acting under is rejected,
since deletion would rebind them to the workspace default role.
- Setting `canUpdateAllSettings: false` on the caller's own role is
rejected unless that role keeps an explicit ROLES permission flag.
- Changing your own role via `assign_role_to_workspace_member` is
rejected, checked both by workspace member id and by resolved user
workspace id.

The tool-layer checks are deliberate pre-checks: the migration
validators and services enforce the same rules downstream
(`validate-role-is-editable.util.ts`, default-role deletion, last-admin
unassignment, write-without-read consistency), but catching them early
gives the model a named, actionable message instead of a build failure
report. Where the deeper layer does reject, `formatValidationErrors`
expands the migration exception so the underlying per-entity errors
reach the model rather than a generic summary.

Worth flagging for reviewers: the self-lockout protection currently
lives only at the tool layer. A human admin can still strip settings
access from their own role through Settings/GraphQL. Closing that would
mean changing `RoleService`/`UserRoleService` behavior for the human
path, which felt like a separate decision than what this change is
scoped to.

## Notes

`ToolCategory.ROLE` is intentionally left out of
`WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES`, so workflow agents don't get
these tools; only the chat surface and the MCP/tool-index paths that
share the registry do.

## Testing

- 24 unit tests in `providers/__tests__/role-tool.provider.spec.ts`,
covering permission gating, descriptor exposure, each safeguard, the
N+1-free list path, and validation-error surfacing
- 333 tests pass across the tool-provider, role, object-permission and
ai suites
- `npx nx lint:diff-with-main twenty-server` and `npx nx typecheck
twenty-server` are clean


---
_Generated by [Claude
Code](https://claude.ai/code/session_0131sLKVsRuaDoaKCxFM8g4Z)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23613?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-31 11:49:23 +00:00
nitin f8ed432e2a Harden Fireflies call synchronization lifecycle (#23610)
Makes Fireflies call syncing (webhook and manual) resilient and
lifecycle-correct.

- Keeps a call recording `PROCESSING` until both transcript and summary
are filled, then marks it `COMPLETED`
- Looks up existing call recording field state first so only missing
fields are fetched from Fireflies
- Makes the call recording write race-safe: deterministic-id create with
a concurrent-create fallback
- Adds bounded retries with rate-limit handling to Fireflies API
requests
- Bumps twenty-sdk to 2.25.0 and validates query results with zod

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23610?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-31 11:26:46 +00:00