Commit Graph

14254 Commits

Author SHA1 Message Date
github-actions[bot] 02a187d065 i18n - docs translations (#23924)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-08 00:35:30 +02:00
github-actions[bot] e479d7538d i18n - translations (#23922)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-08 00:04:00 +02:00
neo773 d464065044 Add a list view type (#23829)
https://github.com/user-attachments/assets/45c11b5b-8da6-43ee-89de-f3fca0b64038



https://github.com/user-attachments/assets/a1b22a00-f6c8-4074-ae98-00a9a960e62f



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23829?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: Félix Malfait <felix@twenty.com>
2026-08-07 23:55:51 +02:00
Félix Malfait 845b48f243 Update terms of service (#23906)
## Summary

Replaces the terms of service page content, which came from a generic
template, with terms written for how Twenty actually works: an
open-source project, a hosted cloud service, and paid self-hosted plans.

Main changes:
- Data section rewritten around workspace privacy: customers own their
data, we only use it to run the service, and it leaves the workspace
only through explicit sharing or publishing features (shared content,
apps that publish pages or API endpoints)
- Explicit open-source scope: the software licenses govern self-hosting;
these terms cover the site, the cloud service, and paid license keys,
with the commercial self-hosted license spelled out (grant, per-user
seats with true-up, key verification, expiry behavior)
- Sections added that the template lacked: security and DPA reference,
connected accounts, AI features, confidentiality, publicity, suspension,
mutual indemnification, warranty disclaimer, data retention and export
after a subscription ends (aligned with the actual suspension and
deletion pipeline: 14-day minimum retention, warning email, reactivation
or support-provided export)
- Clearer mechanics: 30-day notice for material changes to the terms,
price changes only at renewal, refunds when the fault is ours,
cancellation effective at the end of the billing period, business and
professional use only
- Removes template sections that never applied (contests and
sweepstakes, shipping information) and trims the marketing-email clause
down to transactional emails plus opt-out product updates

## Notes

- Content-only change to `TermsDocument.tsx`; no route, styling, or i18n
changes (legal pages are not translated)
- The effective date in the document is a placeholder to set when this
ships
- Legal review is planned before merge
2026-08-07 21:35:42 +02:00
github-actions[bot] 728cc078c8 i18n - docs translations (#23919)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23919?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-07 18:38:51 +02:00
neo773 43f11cdd3a Expire webhook subscriptions whose refresh token is dead (#23907)
Follow-up to #23707. Token errors thrown while building the OAuth client
never reach the driver-exception mapping, so they landed in
`handleUnknownException`: channel marked FAILED, captured to Sentry,
rethrown (captured again by the queue explorer). The renewal cron
re-selects FAILED channels every tick, so a dead refresh token looped
forever.

Routes REFRESH_TOKEN_NOT_FOUND and INVALID_REFRESH_TOKEN to the existing
expiry path, matching the message and calendar import handlers. Sampled
160 events across
[TWENTY-SERVER-J1F](https://twenty-v7.sentry.io/issues/7603992092/)
(~5.9k/day) and
[TWENTY-SERVER-JBZ](https://twenty-v7.sentry.io/issues/7617075015/)
(~1.5k/day): 100% originate here.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23907?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-07 15:40:57 +00:00
github-actions[bot] ff6e5847ab i18n - docs translations (#23917)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-07 16:53:48 +02:00
A 72e6710545 fix(twenty-front): resolve the command menu item i18n rendering issue (#23913)
The most command menus item labels are `string`. The original logic
prevent it form being translated.

Additionally, the `i18n._()` can handle both `string` and
`MessageDescriptor` type, so it's safe to remove this logic.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23913?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-07 16:16:32 +02:00
github-actions[bot] 6d4f03505b i18n - translations (#23916)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-07 16:16:28 +02:00
Abdul Rahman 9e3c3131f7 feat(run-agent): let apps run an agent on behalf of a workspace member (#23470)
## Why

`runAgent()` always runs with the agent's own role, so an app has no way
to scope a run to the person who triggered it. Chat-style apps (Slack,
Discord, Teams) need the opposite: the agent should never be able to do
more than the member who asked.

This is the server/SDK prerequisite for per-user permissions in the
Slack app (#22984). It is self-contained and reviewable without any
Slack context.

> **Scope note.** Review surfaced two authorization problems adjacent to
this code that are not part of the original feature — a cross-app agent
hole (#21157) and two fail-open branches in application-token auth. Both
are fixed here rather than deferred, since they sit directly on the path
this PR changes. They are called out separately below so they can be
reviewed on their own terms.

## The feature

- **`runAsWorkspaceMemberId` (optional) on `RunAgentInput`** — shared
type, DTO, and the generated GraphQL artifacts.
- **`AgentActorContextService.buildRunAsWorkspaceMemberContext`**
resolves member → userWorkspace → role and returns an actor context, a
*user* auth context, and the role id. Mirrors what
`WorkflowExecutionContextService` already does for acting on behalf of a
user.
- **`AgentRunService`** swaps the application auth context for the
member's, passes their actor context, and attributes AI credit usage to
them.
- **`buildAgentRolePermissionConfig`** (new util) returns
`intersectionOf: [agentRoleId, runAsRoleId]`, agent role first —
explicit object grants in `database-tool.provider` resolve against the
first role, so it defines which objects are in scope at all and later
roles only narrow permissions on them. Collapses to a single entry when
the member already holds the agent role, because the permission-flag
checks reject an intersection listing the same role twice.
- **`ToolContext`** gains an optional `rolePermissionConfig`. The lazy
tool path resolved permissions from a single `roleId`, so without this
the narrowing would not reach the tool catalog or call-time
`execute_tool` — and lazy is the strategy `runAgent` uses. Falls back to
the previous `unionOf: [roleId]` default when absent.
- Docs: a "Running on behalf of a workspace member" section in
`skills-and-agents.mdx`.

Omitting the field preserves today's behavior exactly, which is what
autonomous runs (scheduled jobs, database-event triggers) need. **Fails
closed:** an unresolvable member errors rather than falling back to the
agent role, which would grant more than the caller asked for.

## Who may name a member

`runAsWorkspaceMemberId` names another person, so it needs an
authorization rule of its own. An application token is not sufficient on
its own: `frontComponent(id)` is guarded by `UserAuthGuard,
NoPermissionGuard` and mints an `APPLICATION_ACCESS` token pair for the
requesting user, so any authenticated user can obtain one for an
installed app.

Those tokens record who they were minted for, and the caller cannot
strip that. The rule keys on that binding:

| Token | May name |
| --- | --- |
| No application token | nothing — rejected |
| Application token **with** a user binding | only that user's own
member |
| Application token with **no** user binding | any member |

The third row is unattended app code — a database-event-triggered logic
function is the Slack worker's path, and `client_credentials` or
API-key-minted tokens land here too.

## Adjacent fixes

**Cross-app agents** (pre-existing, #21157). The agent lookup was not
scoped to the caller, so any app token could run any agent in the
workspace, including one belonging to an app with wider permissions —
while `skills-and-agents.mdx` promised an app can only run its own. Now
rejected with `RUN_AGENT_NOT_ALLOWED`. Guarded on
`isDefined(callerApplication)`, so callers without an app token are
unaffected; `twenty-front` never calls `runAgent`.

**Two fail-open branches in `validateApplicationToken`.** Both populated
the auth context conditionally instead of failing closed, and both are
now asserted, making the application path structurally identical to
`validateAccessToken`:

1. An unresolvable user left the token presenting as *unbound*, so
removing someone from a workspace widened their live token instead of
revoking it, until it expired.
2. A missing workspace member let the token carry on with the
application's own permissions after that member was removed or
deactivated.

Both mirror `validateAccessToken`, down to its `PENDING_CREATION` /
`ONGOING_CREATION` escape hatch. **Behaviour change beyond this PR:** an
application token whose user has been removed now 401s where it
previously degraded to app-only. That is the point, and it matches
access-token semantics, but it is shared auth and worth a careful look.

## Known limitation

If the app's own agent role declares row-level predicates, those are not
applied in run-as mode, because the query builders resolve row-level
rules from a single role via the auth context. The member's own
row-level rules do apply, which is the direction that matters here.
Multi-role row-level support does not exist anywhere in the codebase
today.

## Tests

| Check | Result |
| --- | --- |
| ai-agent-execution, tool-provider, record-crud, user-workspace, full
auth tree | 79 suites, 666 passed |
| `nx typecheck twenty-server` | clean |
| oxlint + oxfmt on the changed server files | clean |

Both auth regression tests were verified against the pre-fix code — each
fails when the fix is reverted, so they guard the behaviour rather than
passing incidentally.

## Note for reviewers

Rebased onto `main`, then merged `main` in once more after #23395
landed. The `getObjectsPermissionsFromRolePermissionConfig` intersection
fix this PR originally carried has since landed on main independently,
and main's version is stricter — it denies when an intersected role is
missing from the cache rather than treating it as empty — so this PR
takes main's and no longer touches that file.

`RunAgentInput` now composes with the `prompt` | `messages` XOR from
#23395: `runAsWorkspaceMemberId` sits on the base object, so it is
available to both variants.
2026-08-07 14:08:39 +00:00
twenty-pr[bot] 569e178dbc chore: bump version to 2.30.0 (#23914)
## Summary

- Moves current version to previous versions array
- Sets TWENTY_CURRENT_VERSION to the new version
- Updates TWENTY_NEXT_VERSIONS with the next minor version
- Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same
version

## Checklist

- [ ] Verify version constants are correct
- [ ] Verify npm package versions match

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23914?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 Action Deploy <github-action-deploy@twenty.com>
2026-08-07 13:53:41 +00:00
github-actions[bot] 838fb90749 i18n - translations (#23912)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-07 15:17:00 +02:00
Raphaël Bosi 7f0bae5b5f Add global admin panel chat list with onboarding filter and enriched transcript (#23757)
https://github.com/user-attachments/assets/ee22d0d7-6ea0-4d49-a3d2-41ce19089943


Adds a cross-workspace chat list to the admin panel (admin-panel/chats,
linked from the AI tab) so we can analyze onboarding AI chats and
improve the workspace-setup prompts.

- Filters: onboarding only, has error, no user reply; search by
workspace, user email or thread id; server-side sort by message count,
replies, created or updated, with pagination. The list opens unfiltered
so every chat is visible by default.
- Onboarding threads are detected by fingerprint (hidden kickoff message
OR deterministic uuid v5 id), so all existing setup chats are covered
retroactively. The allowImpersonation gate is enforced in the query.
- Replies count answered `ask_questions` cards as well as user messages:
answering one writes no message row, only an in-place toolOutput update,
so those chats used to look abandoned.
- The admin transcript now returns the hidden kickoff prompt (collapsed
in the UI) and enriched message parts: reasoning, tool input/output
rendered as JSON trees, and errors. Reference chips are not navigable
there since they would link into the reader's own workspace.
- Fixes the workspace detail "Messages" column which displayed
conversationSize (tokens) instead of the message count.
2026-08-07 13:08:02 +00:00
github-actions[bot] abd973ab4a i18n - translations (#23905)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-07 12:36:22 +02:00
Marie 4dbaafc65d Revert "Make subdomain minimum length configurable via env var" (#23871)
Instead, reduce the subdomain minimum length to 1 char

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23871?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-07 10:27:00 +00:00
Raphaël Bosi 47f82b9121 Gate the workspace setup AI chat to workspace creators (#23881)
The server already refuses to start a workspace setup chat for anyone
who isn't the workspace creator (`workspace-setup-chat.service.ts`, via
`userWorkspaceService.isWorkspaceCreator`), but nothing on the client
checked that. An invitee finishing onboarding was still routed to
`/workspace-setup`, where the kickoff mutation returned `UNAVAILABLE`
and the effect silently returned — leaving them on a dead-end page with
the onboarding header and an empty chat that never starts.

Exposes `isWorkspaceCreator` on `User` as a resolve field next to
`onboardingStatus`, reusing the existing service method, and gates both
the post-onboarding redirect and the page itself on it. Invitees now
land on the default home page instead.
2026-08-07 09:15:35 +00:00
Thomas Trompette 00b6d651f4 fix(front): preserve currency decimals when opening the field editor (#23874)
Fixes #23828

## Problem

Opening and closing a currency field editor corrupts the stored amount
when the value has more decimals than the field's `decimals` setting
(which defaults to 0). No keystroke is needed.

- With the `1.234,56` number format, `458.64` is persisted back as
`45864`.
- With the `1,234.56` number format, `458.64` is persisted back as
`458`.

## Root cause

The draft amount is serialized with a dot decimal separator
(`amountMicros / 1000000).toString()`), and `CurrencyInput` hands that
string to the IMask `Number` mask with `scale={decimals}` alongside the
workspace `thousandsSeparator` and `radix`. With `scale` 0 and a dot
thousands separator, imask reads `458.64` as `45864`; with a dot radix
it drops the fraction and yields `458`.

That misreading is only half of it. `react-imask` re-emits `accept`
while it formats the value it was given, so **merely mounting the
editor** pushed the mask's own reading of the amount into `internalText`
and into the draft value. From there every exit path persisted it,
escape included. That is why the corruption needs no keystroke.

## Fix

Two layers, smallest first:

1. `CurrencyInput` ignores `accept` events that carry no originating
input event. In imask, `_inputEvent` is set only inside `_onInput` and
deleted right after, so a user keystroke (including the reformat emitted
within the same turn) always carries it, while mount and programmatic
updates never do. The draft can now only change because someone typed.
2. The exit handlers pass `skipPersist` when the resulting value already
matches the stored one, so opening and closing a field writes nothing at
all - no redundant update, no timeline entry.

`getSafeScaleForCurrencyInput` is kept as well: the mask scale is what
makes the editor *display and edit* the right number. Without it, a
`458.64` amount still opens as `45.864`, and a genuine edit would then
build on the wrong base and persist legitimately.

## Tests

Unit tests on both utils, plus an imask round-trip of `458.64` with
`decimals` 0 across all four number formats, which fails on `main` for
both dot-separator formats.

Verified in a local instance against the reported case (`458.64`,
`decimals` 0):

| scenario | result |
|---|---|
| open + close, `1.234,56` | value unchanged, `updatedAt` untouched (no
write at all) |
| open + close, `1,234.56` | value unchanged |
| type a new amount | persists normally |
| type `12.5` on a `decimals` 0 field | yields `125`, separator still
rejected |
2026-08-07 08:48:32 +00:00
Félix Malfait 10ee130eb9 Show only the record title in the mobile page header (#23902)
On mobile there isn't enough room for the full breadcrumb, so the object
type and the pagination count were pushed under the action button.

## Changes

**`ObjectRecordShowPageBreadcrumb`** — on mobile, hide the object-type
prefix (icon + plural label + `/`) and the pagination count. Only the
record title remains, still editable and still ellipsized.

**`PageCardHeader`** — the header grid was `minmax(0, auto) minmax(0,
1fr)`. The left (breadcrumb) track grew toward its max-content size and
consumed the whole row, collapsing the action-button track to 0, which
is why the button ended up drawn over the text. It's now `minmax(0,
auto) minmax(min-content, 1fr)`, with `min-width: 0` dropped from the
right container so its min-content is meaningful: the right column still
takes leftover space, but can never shrink below what the buttons need.

Note on why the right column isn't just `auto`: the pinned action bar
measures its own container width (`NodeDimension`) to decide how many
buttons fit inline, so a content-sized track gives it a 0 basis and it
collapses to the overflow `⋮` alone.

## Verification

Checked in a browser at an iPhone 13 viewport and at 1280px, with a
record renamed to a long title to reproduce the overflow.

- Mobile before: breadcrumb right edge 24px past the button's left edge,
`(1/599)` under the `⋮`.
- Mobile after: title only, ellipsized, right edge 8px clear of the
button.
- Desktop after: unchanged — object type, title, count, and the full
action bar (Send Email, favorite, prev/next, `⋮`) all present.

Also checked the record index, people index, and settings headers at
both widths for horizontal overflow: none.

`lint:diff-with-main` and `typecheck` pass on twenty-front.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23902?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-07 09:49:27 +02:00
Abdul Rahman 4dbc5a567e feat(slack): strip the bot's own mention anywhere in the request text (#23837)
## Context

`parseSlackAssistantRequest` only stripped a bot mention at the very
start of the message. A message like "hey @twenty, who owns ACME?" sent
the raw `<@U0123ABC>` token into the agent prompt, where the agent sees
an opaque id.

**Stacked on #23835**: this PR's base is
`feat/slack-no-silent-dead-ends`, since both touch
`parse-slack-assistant-request.ts`. Merge #23835 first; this PR then
retargets to main with only its own changes.

## What this does

Only the bot's own mention may be stripped: mentions of other members
are part of the request ("ask <@UALICE> about the ACME deal" must keep
`<@UALICE>`), so blindly removing every `<@…>` token is wrong and the
parser needs the bot's actual user id.

- The Slack `event_callback` envelope already carries that id in its
`authorizations` field, so `getSlackBotUserIdFromEventBody` reads it
straight from the request body. The parser stays pure and synchronous,
no `auth.test` call and no KV involved, and this does not collide with
the connect-time id cache #23726 is introducing.
- `stripSlackBotMention` removes every occurrence of that mention
(leading, mid-text, `<@U…|label>` form), in app_mention events and DMs
alike. When the mention sits directly before punctuation, the preceding
whitespace is consumed too, so "hey @twenty, who owns ACME?" becomes
"hey, who owns ACME?" rather than "hey , who owns ACME?". Everything
else is covered by the parser's existing whitespace collapsing.
- Fallback when `authorizations` is absent: on app_mention events the
bot id is derived from the leading mention and other occurrences of that
same id are stripped, which matches the old behaviour on the old inputs.
Without any way to identify the bot (a DM without `authorizations`),
mentions are left untouched rather than guessed at.
- A side effect of knowing the real bot id: a message that *starts* with
another member's mention no longer has that mention wrongly stripped as
if it were the bot's.

## Tests

New parser cases: bot mention mid-text, at start plus mid-text, repeated
mention via the leading-mention fallback, other-user mentions preserved
next to a stripped bot mention, leading other-user mention preserved, DM
containing the bot mention, and DM with an unknown bot id keeping
mentions intact. `yarn test:unit` (61 tests), `yarn typecheck` and `yarn
lint` all pass in the app.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23837?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-07 01:28:21 +00:00
Abdul Rahman fd85e64fe3 slack: answer empty requests and nudge lapsed threads (#23835)
## Context

Two assistant paths ended with no visible reaction for the member:

- a mention with no request text (a bare `@twenty`) or an empty DM was
skipped silently
- a follow-up in a thread whose 24-hour subscription window had lapsed
was dropped silently, with no hint that the bot "forgot" the thread

## What this does

**Empty requests get answered.** `parseSlackAssistantRequest` flags an
empty mention/DM with its channel and thread target, and the enqueue
function answers in-thread. A bare mention that starts a fresh thread
(or an empty DM) gets a short markdown list of example asks; a bare
mention inside an existing thread gets a context-aware one-liner
instead. There is no request record to dedupe on for these, so a KV
claim (`slack-empty-request-reply:{channel}:{ts}`, 1-hour expiry) keeps
Slack event redeliveries from double-posting, and the claim is released
if the hint cannot be posted so a redelivery can retry. The hint's
thread is subscribed like any answered thread, so a follow-up question
there is picked up without a re-mention. Empty messages in unmentioned
thread follow-ups still skip silently on purpose.

**Lapsed threads get a nudge.** `is-slack-thread-active.ts` becomes
`get-slack-thread-subscription-state.ts`, a pure read returning `active`
/ `expired` / `none`: an expired-but-present KV key proves the bot used
to follow that thread, while `none` means it never did, so random
threads stay untouched. On `expired` the author of the follow-up gets an
ephemeral (only they see it) asking them to re-mention the bot. The
lapsed key is cleared only after the nudge actually posted, via
`clearLapsedSlackThreadSubscription`, which re-checks that the
subscription was not renewed in the meantime; a failed nudge is retried
on the next follow-up.

Supporting changes: the `slack-post-ephemeral-message` step gains an
optional `parentMessageTimestamp` (type, schema, handler) so the nudge
can post inside the thread, mirroring `slack-post-message`; the enqueue
result shape is extracted into a shared `SlackEventsEnqueueResult` type;
nullish checks use `isDefined` from `twenty-sdk/utils`. README and
SETUP.md behaviour notes describe the new behaviours.

## Demo



https://github.com/user-attachments/assets/03add964-92fb-46e5-aa49-06faad85e18c

<img width="406" height="531" alt="Screenshot 2026-08-06 at 6 34 57 AM"
src="https://github.com/user-attachments/assets/bc001057-8998-4d1f-ac7e-e1ed8b5f3770"
/>
2026-08-07 01:08:57 +00:00
Félix Malfait 712e5ece7e Strip template elements from the HTML block preview (#23866)
Follow-up to a code scanner alert on `HtmlNodeView`. The alert itself is
not a live vulnerability, but auditing the hand-rolled sanitizer behind
it turned up one gap worth closing.

## What I checked

`sanitizeHtmlPreview` is a blocklist sanitizer feeding
`dangerouslySetInnerHTML` in the editor, so I ran ~30 payloads through
the real implementation in headless Chromium, inserting the sanitized
output into a live document. A deliberately unsanitized control payload
fired, so the harness was actually detecting execution.

Nothing executed. The parser-differential classes it already survives:
`noscript`, `noembed`, `noframes`, `xmp`, `listing`, `title` and
`textarea` raw-text handling; `svg`/`math`/`mglyph`/`foreignObject`
namespace confusion; comment breakouts; table foster parenting; `<body
onload>`; `<iframe srcdoc>`; `data:text/html`; and `javascript:` with
entity and control-character obfuscation.

## The gap

`document.body.querySelectorAll('*')` does not descend into a
`<template>`'s content, which lives in a separate document fragment. So
this:

```html
<template>``&lt;img src=x onerror="alert(1)"&gt;``</template>
```

came back out of the sanitizer with its handler intact.

It does not execute as currently used — template content is inert when
assigned through `innerHTML`, and I confirmed that. But a live event
handler sitting inside a string the sanitizer just declared clean is a
footgun for anything that later clones, re-parses or forwards it.

The outbound email sanitizer already drops template content, so blocking
the element here also stops the preview from showing something the sent
email would not contain.

## Scope

One word in the blocklist plus tests. Inline styles are deliberately
untouched, since dropping them would make the preview diverge from the
rendered email.

An earlier revision also added `contain: paint` to the preview, to stop
a block from painting over the editor chrome. That was dropped after
testing showed it clips box-shadows bleeding past the block edge and
negative-margin full-bleed layouts, both normal in email design. Details
in the review thread. The overlay it guarded against needs an
authenticated member who can already edit the template, which does not
justify constraining newsletter design.

## Testing

Re-ran the full payload corpus with the new blocklist. The template
payload's handler is gone, every other result is byte-identical, and the
control still fires. The two new unit tests were verified to pass under
jsdom, the environment jest actually uses.
2026-08-06 18:01:14 +02:00
Félix Malfait 8774bf8604 Self-host every font instead of loading them from Google (#23859)
Google Fonts logs the IP and user agent of everyone who loads a font
from it. Any page of ours that links to `fonts.googleapis.com` hands our
users (and every self-hoster's users) to a third party for nothing in
return, since we can serve the same bytes ourselves.

After this PR there is no reference to `fonts.googleapis.com`,
`fonts.gstatic.com` or `next/font/google` left in the repo.

## What changed

**twenty-front, PDF export.** `exportBlockNoteEditorToPdf` registered
Inter by URL against `fonts.gstatic.com`, so exporting a note made the
browser fetch three TTFs from Google. The registration turned out to be
unnecessary altogether: `@blocknote/xl-pdf-exporter` already registers
an `Inter` family for its PDF schema, shipped inlined in the package as
a base64 TTF with the same 2849-codepoint coverage. Deleting our
`Font.register` means no font request leaves the browser, with 41 fewer
lines and nothing vendored.

Only weights 400 and 700 were ever used, and 700 already resolved to
blocknote's `Inter18pt-Bold` before this branch, so the custom 500/600
registrations were dead. The only rendering change is body text going
from `Inter` to `Inter18pt`, the same typeface at its 18pt optical size.

**twenty-sdk, OAuth callback page.** The local "you can close this tab"
page linked to Google Fonts, which meant running `twenty auth` phoned
Google from the developer's browser. Replaced with a system font stack;
a transient callback page did not justify a webfont round trip in the
first place.

**twenty-ui, Storybook.** `preview-head.html` loaded Inter from Google.
It now imports `@fontsource/inter` in `preview.tsx`, matching what
twenty-front's Storybook already does.

**twenty-website.** Host Grotesk, Aleo, Azeret Mono and VT323 came
through `next/font/google`. Next self-hosts those at runtime, so this
was not a visitor-facing leak, but the build still had to reach Google,
which makes builds non-hermetic and fails in an air-gapped environment.
The latin subsets are now vendored in `src/fonts/`, next to the Inter
files that were already there, and loaded with `next/font/local`. All
four are OFL 1.1; `src/fonts/README.md` records each file's upstream and
license. Total added weight is ~78 KB, and these are the exact files
Next was downloading at build time anyway.

Host Grotesk and Azeret Mono ship as single variable files, so they are
declared once over their full `wght` axis rather than as one face per
weight.

## Also removed

Both Storybooks pulled `iframeResizer.contentWindow.min.js` from
`cdnjs.cloudflare.com`. Storybook has not needed it since v7 and nothing
in either package references `iframeResizer` or `parentIFrame`, so it
was a third-party script executing in the preview iframe for no reason.
Argos does not screenshot through the manager iframe either:
`@argos-ci/storybook` hooks Vitest browser mode and calls
`server.commands.argosScreenshot`, so Playwright drives the page
directly.

## Verification

Not just typecheck. The interesting parts were tested end to end, which
caught two bugs an earlier revision of this PR had introduced.

**PDF export** — production Vite build, served over HTTP, real Chromium,
exporting through the actual `exportBlockNoteEditorToPdf`, then
extracting the PDF's text back out:

```
Latin heading  Cyrillic: Привет мир  Greek: Ελληνικά κείμενο
Latin-ext: Zażółć gęślą jaźń, Český  Vietnamese: Tiếng Việt

PASS Latin / Cyrillic / Greek / Polish / Czech / Vietnamese
```

Embedded fonts are `Inter18pt-Regular` / `Inter18pt-Bold`, no Helvetica
fallback, zero requests off-origin.

**Website** — built it, audited the build output (12 `@font-face` rules,
all `/_next/static/media/`, weights `300 800` / `100 900` / `300` /
`400` / `400,500,600`, `display: swap` preserved), then loaded it in
Chromium: 136 requests, zero to Google. The deployed preview was checked
too: no Google references in the served HTML or across all 21 CSS
chunks, every font file returns `200 font/woff2` and parses to the
expected family, and the asset hashes match a local build byte for byte.

**Two bugs this caught**, both in earlier commits on this branch, both
now fixed:

1. Registering `@fontsource/inter`'s latin file dropped coverage from
2849 codepoints to 230, silently removing Cyrillic, Greek, Vietnamese
and extended-Latin from every export. fontsource splits Inter into seven
per-script files chosen by `unicode-range`, but `Font.register` binds
one file per weight with no equivalent.
2. Any woff2 aborts the export outright with `RangeError: Offset is
outside the bounds of the DataView`. fontkit parses woff2, but
`@react-pdf`'s subsetter chokes on the transformed `glyf` table.
Confirmed format was the only variable by running identical content
through local TTF, WOFF and WOFF2 files.

Both are moot now that the registration is gone, but they are why this
is worth a careful look rather than a rubber stamp.

## Left alone, but worth knowing about

More third-party calls exist. None are font-related and each is a
separate decision:

- `twenty-website` loads `dotlottie-player.wasm` from **unpkg.com** at
runtime on the homepage, via `@lottiefiles/dotlottie-react`. This is a
live third-party CDN request on every visit, the same class of problem
as the fonts, and looks like a small config change to self-host.
- The halftone studio loads the Draco decoder from `www.gstatic.com`
and, in exported scenes, three.js from `unpkg.com`.
- The partners marketplace fixtures hotlink logos from
`cdn.simpleicons.org` and `upload.wikimedia.org`.
- reCAPTCHA and the Front support chat are config-gated and off unless
an admin configures them, which seems right.
- `APP_REGISTRY_CDN_URL` defaults to `https://unpkg.com`.
- `twenty-front/index.html` points its `og:image` at
`raw.githubusercontent.com`. Only social crawlers fetch it, so this is
cosmetic.
2026-08-06 17:58:55 +02:00
github-actions[bot] 435bb54b97 i18n - docs translations (#23878)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-06 17:20:43 +02:00
nitin 3cbcf999d6 Add call recording transcript and summary widget types (#23864)
Registers two record-page widget types for call recordings:
CALL_RECORDING_SUMMARY and CALL_RECORDING_TRANSCRIPT.

- Widget type and configuration type enums, configuration DTOs, and
creation/update validators
- Core pageLayoutWidget_type_enum migration (2.29 fast instance command)
- Shared configuration types and regenerated metadata client schemas

Widget components and their placement on the callRecording record page
layout come in a follow-up PR.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23864?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-06 15:10:53 +00:00
Charles Bochet a47f566eb1 fix(twenty-server): notify SSE subscribers when an update leaves their filtered view (#23858)
## Context

Record tables subscribe to SSE with their query signature (object +
filter) and the server pushes only matching DB events.
`isQueryMatchingObjectRecordEvent` evaluated `after ?? before` — for
UPDATED events that is always `after`, so an update moving a record
**out** of a filtered view never matched: no event, and the open table
keeps the stale row until a manual reload.

Symptom on twenty-internal: a Sales Action Item marked Done by an
AI-chat tool (or any API/workflow write) stays visible in the `status =
OPEN` view. Records *entering* a view appear live; records *leaving*
never disappear. UI edits mask the bug via the local Apollo cache.

## Fix

For UPDATED events, view membership now matches on either snapshot (a
before-only match = the record just left the view). Row-level
authorization is unchanged: still evaluated against the delivered
snapshot, so a before-state match cannot authorize a payload the
subscriber lost RLS access to.

A pre-existing, unrelated payload leak spotted during review (before
values delivered when a record enters RLS scope) is fixed separately in
the stacked #23870.

## Test plan

- Unit: leave-view update publishes (fails on the old matcher —
verified), neither-state-matches does not, RLS-failing delivered state
does not even when before matched. 36/36 on the spec, full server suite
green.
- End-to-end on a local stack: companies table filtered `Name contains
'Open'`, `updateCompany` renamed a row out of the filter via the API →
row disappeared from the open table within seconds, no reload.
2026-08-06 15:02:26 +00:00
Charles Bochet f5a42cdbae Give AWS SDK clients an explicit request timeout (#23857)
## Why

The AWS SDK defaults `requestTimeout` to `0`, which means *no timeout* —
see `DEFAULT_REQUEST_TIMEOUT` in `@smithy/node-http-handler`. None of
our clients overrode it, so a request that never completes holds its
socket forever. Once that happens to `maxSockets` requests (default 50),
the client's connection pool is permanently exhausted and every
subsequent call on that process queues indefinitely rather than failing.
To the caller it is indistinguishable from a hang.

This caused a production incident on 2026-08-06. A single
`twenty-server` pod reached:

```
@smithy/node-http-handler:WARN - socket usage at capacity=50 and 1004 additional requests are enqueued.
```

and never recovered — the queue grew monotonically and the pod completed
**zero** Lambda invocations over 12 hours while its six siblings
completed dozens each. Egress was fine (a direct HTTPS call to the
Lambda API returned in 53ms) and the pod was never OOM-killed or
restarted, so nothing surfaced as an error anywhere.

The user-visible effect was that workspace creation hung. Activation
reached `synchronizeTwentyStandardApplicationOrThrow`, blocked on a
Lambda call, and never returned or threw — so the `catch` in
`activateWorkspace` that resets a workspace to `PENDING_CREATION` never
ran, and the workspace was stranded in `ONGOING_CREATION`. Retrying
didn't help because the pod was still poisoned. With one bad pod out of
seven, roughly one signup in seven failed:

| pod | activations started | completed |
|---|---|---|
| healthy × 5 | 13 | 13 |
| poisoned | 3 | **0** |

Nothing appeared in Sentry, because nothing ever threw.

## What this changes

- **Every AWS SDK client now sets `connectionTimeout` and
`requestTimeout`** via a shared `buildAwsRequestHandlerOptions()`
helper. A saturated pool now surfaces as an ordinary error the caller
can catch and retry instead of hanging forever. This is the fix that
matters.
- **The Lambda client gets a higher ceiling and a larger socket pool.**
Synchronous invocations legitimately hold a socket for as long as the
function runs, so its `requestTimeout` clears
`EXECUTOR_LAMBDA_TIMEOUT_SECONDS` (900s) with a minute to spare, and
`maxSockets` goes to 200 so long invocations cannot starve the
control-plane calls (layer lookups, waiters) sharing that client.
- **`S3Client` and `STSClient` in `LambdaAwsClientService` are now
reused.** Both were constructed on every call and never destroyed, so
each leaked its own agent and socket pool. They are invalidated
alongside `lambdaClient` when assume-role credentials refresh.

No new dependency: `requestHandler` already accepts a plain
`NodeHttpHandlerOptions` object.

## Deliberately not in scope

- **A timeout around `activateWorkspace` itself.** A hung activation
still strands a workspace in `ONGOING_CREATION` until the 5-minute
stale-lock reclaim, and that only fires if the user happens to retry.
Worth fixing separately.
- **Alerting on `socket usage at capacity`.** That warning was the only
signal this was happening and nobody was watching it — an infra change
rather than a code one.

## Testing

- Unit tests for the helper, including that the timeout is always
non-zero.
- `tsc --noEmit` clean on the touched files; `oxlint` reports 0 warnings
and 0 errors.
- Not reproducible in a test environment — the leak needs a saturated
pool — so the mechanism above is evidenced from production logs and the
SDK's own defaults rather than from a regression test.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23857?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-06 15:02:05 +00:00
Abdul Rahman 577cd68cb5 feat(slack): release the Slack team claim when the connection goes away (#23753)
Closes the loop on
https://github.com/twentyhq/twenty/pull/22984#discussion_r3673946334,
where the Slack app claimed a `team_id` on connect and never released
it. The `onDisconnect` hook it waited on merged in #23538, and
`twenty-sdk` 2.27.0 ships the `onDisconnectLogicFunction` manifest
field, so this bumps the app to it.

## Disconnect

`slack-team-release` releases the server-scoped `slack-team:{teamId}`
claim, so another Twenty workspace can connect that Slack team
afterwards. `kv.delete` on a SERVER key only clears a claim the calling
workspace owns, and the result reports a team only when that delete
actually removed something.

The connection is already deleted when the hook runs, so `getConnection`
cannot resolve the `team_id` anymore. `claimSlackTeam` records `teamId`
under a workspace-scoped
`slack-connected-account-team:{connectedAccountId}` key, written before
the claim itself so a failure between the two cannot leave a claim
nothing can resolve, and the release reads it back from there. That also
retires the TODO the review comment pointed at.

The release is skipped when a live connection still maps to the same
team, excluding the disconnecting account explicitly. That covers a
reconnect that re-claimed the team while the job was queued, and a
second connection to the same Slack workspace, whose claim would
otherwise have been dropped along with the first. The recorded team is
cleared either way, so the disconnecting account leaves nothing behind.

## Uninstall

Uninstalling the app drops its connections through the
`connectionProvider` and `application` cascades, which never reach the
disconnect hook, and the same operation deletes the app&#39;s logic
functions so the hook could not run anyway. The app declares an
`uninstallLogicFunction`: it runs before the app&#39;s metadata is
deleted, lists the remaining Slack connections, and releases each claim
through the same `releaseSlackTeamClaim` util. It deliberately skips the
live-connection check, since at that point every connection is still
listed.

## Known limits

A disconnect immediately followed by a reconnect has a window where the
queued release can clear a claim the reconnect just took. It self-heals,
because the reconnect&#39;s own onConnect re-claims the same key moments
later. Closing it properly needs a compare-and-set on the claim, which
the key-value store does not expose.

No unit tests are added for the release path. Earlier revisions of this
branch had specs for the disconnect guard, the uninstall sweep and the
no-op-release result; they were dropped, so those branches carry no
regression coverage. The app&#39;s existing 34 tests still pass, along
with lint, typecheck and `twenty dev:build`, the last being what used to
fail with `Duplicate universal identifiers` on the older SDK.
2026-08-06 13:57:33 +00:00
github-actions[bot] 5f9419589e i18n - translations (#23873)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-06 15:55:49 +02:00
Charles Bochet a7324252fd fix(twenty-server): stop the yarn-install Lambda from running out of memory (#23805)
## Context

Sentry issue
[7438578272](https://twenty-v7.sentry.io/issues/7438578272/) (Logic
Function Layer Build Failed, 7.9K events over 3 months): the
yarn-install tool Lambda dies with `Runtime.OutOfMemory` / `signal:
killed` while building an application's dependency layer. Every layer
build for the affected application fails permanently, each
database-event trigger re-attempts it, and one workspace produced ~2.5K
events in the last week alone.

## Root cause

The offending application declares `twenty-ui@1.0.0-alpha.0` (181MB
unpacked, dragging in 141MB of `@tabler/icons*`) and dev tooling as
production `dependencies` of server-side logic functions. Installing
that tree needs just under 3GB during Yarn 4's fetch/link phase, so the
1024MB sandbox is OOM-killed. And even a successful install could never
ship: AWS caps a function plus all its layers at 250MB unzipped.

The user never sees any of this: the OOM is retried forever, and nothing
tells them their dependencies are the problem.

## Fix

1. **Raise the yarn-install Lambda to 4096MB** so legitimate dependency
trees install. Tool function names now include the
memory/timeout/ephemeral-storage constants in their content hash, so a
config change rotates the function name and the ensure path creates a
fresh function with the new configuration — without this, the constant
change would never reach already-deployed functions (their config is
only applied at creation, and the ensure path early-returns when the
function exists).
2. **Propagate Lambda's own errors to the user.** The install OOM
(`Runtime.OutOfMemory` on the invoke) and the layer size rejection
(`InvalidParameterValueException` at `PublishLayerVersion`) map to a new
`LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED` code telling the user to
move packages their logic functions don't import out of `dependencies`.
Surfacing per API boundary:
- **Sync / install (CLI)**: the workspace migration interceptor formats
it into the same metadata validation error shape the SDK already
renders, as one `logicFunction` entry carrying the remedy and the
underlying AWS detail — no SDK rendering changes needed.
- **`executeOneLogicFunction`**: mapped to `UserInputError` in the
GraphQL handler.
- **Route triggers**: HTTP 422 with the user-facing message, no Sentry
capture.
- **Background triggers**: skip instead of retrying, since no retry can
succeed until the user changes their application.

## Test

The error originates in AWS behavior, which CI (local driver, no AWS)
cannot reproduce — so the chain is verified link by link:

- **Real AWS, manual (not in CI)**: reproduced with the offending
application's actual package.json against real Lambdas in the dev
account — OOM-killed at 1024MB and 2048MB (exact prod error signature),
install succeeds at 4096MB (~4min), and the resulting 292MB layer is
rejected by `PublishLayerVersion` with the exact
`InvalidParameterValueException` this PR matches. Same matrix reproduced
in local cgroups beforehand.
- **Server unit specs**: AWS error payload → exception mapping
(`build-yarn-install-failure-exception`), exception → validation payload
formatting (interceptor handler), `executeOneLogicFunction` GraphQL
mapping, route filter 422 mapping, tool-function/layer name hashing.
- **SDK integration spec (mocked server)**: runs the real `app dev`
orchestrator on the minimal app with `syncApplication` mocked to return
the validation-shaped failure, and asserts the CLI report renders the
error code and remedy. It covers CLI rendering only — no test installs
actual oversized dependencies, by design.
- Docs updated (dependency size limits, sync failure taxonomy, route
platform error responses).
2026-08-06 13:45:37 +00:00
Charles Bochet c7cfd143c7 fix(twenty-front): keep auto-select model preselection instead of discarding it (#23854)
## Context

Opening the Ask AI panel with a FAST model preselection
(`useOpenAskAiPageWithPreprompt`) sets the chat's model to the
workspace's `fastModel`. For every workspace on default settings that
value is the auto-select sentinel `default-fast-model`.

## Bug

`useAgentChatModelId` validates the selected model against the
enabled-models list — and `useWorkspaceAiModelAvailability` deliberately
filters sentinel ids out of that list. The preselection is therefore
silently discarded (`selectedModelId = null`), the request is sent with
no model, and the server falls back to its default — the **smart**
model. Net effect: FAST preselection no-ops on default-configured
workspaces (observed on twenty-internal: a `model: 'FAST'` entry point
ran on gpt-5.6-sol instead of gpt-5.6-luna).

## Fix

Treat auto-select sentinel ids as always available in the check — the
server-side registry already resolves them (`getEffectiveModelConfig` →
`getDefaultSpeedModel`). One line + a regression test.

## Test

`useAgentChatModelId.test.tsx`: new case asserting a sentinel selection
survives to `modelIdForRequest`; all 4 pass.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23854?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-06 13:38:37 +00:00
Thomas Trompette b2684ce107 Clear to-one relation key instead of storing {id: null} in record workflow steps (#23869)
Deselecting a to-one relation in a Create Record / Upsert Record step
stored `{ "id": null }` instead of removing the field from the step
input. The step form then renders the field as empty, so nothing signals
that a value is still there, and the run fails later with:

```
Relation "idOpportunity" requires connect or disconnect operation
```

### Why

The relation picker fires `onChange(null)` when the current selection is
dropped (`FormSingleRecordPicker`, "No record" entry).
`handleFieldChange` wraps every to-one relation value as `{ id: value
}`, so `null` became `{ id: null }` and got persisted in `objectRecord`.

At runtime that shape is not a legacy `{ id: "<uuid>" }`, so it is left
untouched by `formatWorkflowRecordRelationFields` and reaches the common
API data arg processor, which rejects any relation value that is not a
`connect`/`disconnect` operation.

The field also reads as empty afterwards (`formData[field]?.id` is
`null`), so the poisoned state is indistinguishable from a clean one in
the UI, and the ✕ that would have cleared it properly is not rendered.

### Fix

Treat a cleared to-one relation as a field removal in both record forms,
matching what the chip's ✕ (`handleFieldClear`) already does. The logic
lives in `buildUpdatedRecordActionFormData`, shared by both components
along with the `RecordActionFormData` / `RelationManyToOneField` types
they each declared separately.

Update Record is unaffected: it stores relations under the join column
(`pointOfContactId`) with a raw value, where `null` is a valid
disconnect.

### Test

Manually, on a Companies Create Record and Create or Update Record step,
using the `Account Owner` relation: pick a record, then pick "No record"
in the same dropdown, and read the persisted step from
`workflowVersion.steps`.

| | `objectRecord` after "No record" |
|---|---|
| before | `{"accountOwner": {"id": null}}` |
| after | `{}` |

Selecting a record still stores `{"accountOwner": {"id": "<uuid>"}}`.
2026-08-06 13:36:26 +00:00
twenty-pr[bot] 8699766303 chore: bump version to 2.29.0 (#23820)
## Summary

- Moves current version to previous versions array
- Sets TWENTY_CURRENT_VERSION to the new version
- Updates TWENTY_NEXT_VERSIONS with the next minor version
- Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same
version

## Checklist

- [ ] Verify version constants are correct
- [ ] Verify npm package versions match

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23820?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 Action Deploy <github-action-deploy@twenty.com>
2026-08-06 12:49:37 +00:00
Félix Malfait c7f443662f Escape JSON-LD payloads before inlining them in a script tag (#23865)
Fixes the one code scanner alert of the three that turned out to be a
real vulnerability.

## The problem

`JsonLd` inlined `JSON.stringify` output straight into a `<script>`
body:

```tsx
<script dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} type="application/ld+json" />
```

The HTML parser scans a script body for `</script` and `<!--` before any
JSON parsing happens, so a string value containing `</script>` closes
the tag and everything after it is parsed as markup. `JSON.stringify`
does not escape it.

## Why it is reachable

The breadcrumb payloads are not all static. Two values come from outside
the repo:

- `app.name` on `/apps/[slug]` — served by the marketplace API, which
syncs it from the `displayName` field of an npm package manifest
(`marketplace-catalog-sync.service.ts`).
- `partner.name` on `/partners/profile/[slug]` — served by the partners
API from partner-submitted profiles.

Listing and vetting gate both, but that is a human review step, not an
escaping control. There is no CSP backstop either: `next.config.ts` only
sets `frame-ancestors 'none'`, no `script-src`.

Everywhere else these names render as React text and are escaped. This
was the only raw sink in `twenty-website`.

## Verification

Rendered the exact markup the component emits in headless Chromium with
a name of `Evil App</script>``&lt;img src=x onerror=...&gt;'``:

- before: the `ld+json` block is terminated early, an `<img>` element is
created, and the handler runs (page title changes).
- after: the script block stays intact, no element is created, and
`JSON.parse` of the payload deep-equals the input.

## The fix

Escape `<`, `>` and `&` as JSON unicode sequences (`<` and friends).
They parse back to the identical string, so consumers see unchanged
structured data, but nothing in the payload can start a tag or a
comment.

U+2028/U+2029 are deliberately not escaped: they matter when a payload
lands in a JavaScript context, and this one is parsed as JSON. Leaving
them out keeps the source pure ASCII rather than carrying invisible
separators.

Covered by unit tests for the breakout attempt, the comment-opening
case, round-trip equality, and the untouched-payload case.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23865?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-06 14:36:13 +02:00
Félix Malfait f4d5500fc4 fix(front): run a single Monaco instance across the app (#23855)
## Problem

Three Sentry issues, all `Missing requestHandler or method: <method>`,
first seen in v2.27.0:

| Method | Page | Events |
|---|---|---|
| `findDocumentColors` | `/settings/mcp-apis` | 170 |
| `resetSchema` | `/settings/mcp-apis` | 22 |
| `getCodeFixesAtPosition` | `/object/workflow/…` | 4 |

The first two are Monaco **JSON worker** methods, the third a
**TypeScript worker** method. All of them bottom out in
`monaco-editor/esm/vs/editor/common/services/editorSimpleWorker.js` at
`$fmr` — the foreign-module dispatcher — with `_foreignModule` still
`null`.

## Root cause

Two Monaco copies end up on the page at **different versions**:

1. **ESM `monaco-editor@0.52.2`**, bundled by Vite — what GraphiQL 5
uses.
2. **AMD `monaco-editor@0.55.1` from jsDelivr** — `@monaco-editor/react`
→ `@monaco-editor/loader@1.7.0`, whose default CDN path is hardcoded to
`monaco-editor@0.55.1/min/vs`. Nothing calls `loader.config({ monaco
})`, so it goes to the CDN.

`setupGraphiqlMonacoWorkers.ts` assigns
**`globalThis.MonacoEnvironment`**, a single global both instances read,
as a module side effect of the lazily-routed `GraphQLPlayground`. So
once the playground has been opened, the 0.55.1 CDN instance stops using
its own AMD workers and starts getting Vite-bundled 0.52.2 ones.

The two versions don't share a worker protocol: 0.52 routes
language-service calls through `$loadForeignModule` + `$fmr`, which
0.55's client never sends. `_foreignModule` stays `null`, and every call
rejects.

On `/settings/mcp-apis` the consumer is `SettingsMcpSetup.tsx` —
`<CodeEditor language="json">` for the MCP config. Monaco fires
`resetSchema` on `onWillDisposeModel` / `onDidChangeModelLanguage` and
`findDocumentColors` continuously, which is why one bug produces 170
events and 22.

There is a second, independent bug in the same file: the `switch` only
maps `json` and `graphql`, so `typescript` / `javascript` / `css` /
`html` fall through to the bare `EditorWorker`, which carries no
language service at all. That's the workflow-page
`getCodeFixesAtPosition`, and it would break even with matching
versions.

Impact is worse than the log noise suggests: after visiting the
playground, JSON validation/colors in the MCP config editor and TS
intellisense/quick-fixes in the workflow code editor silently stop
working for the rest of the session.

## Changes

- **`twenty-ui/src/input/CodeEditor/CodeEditor.tsx`** — configure
`@monaco-editor/react` with the bundled Monaco (`loader.config({ monaco
})`) instead of letting it fetch its own from jsDelivr. The import stays
dynamic so Monaco is still only downloaded when an editor actually
renders; the component shows its existing `Loader` until the loader is
configured.
- **`twenty-front/src/modules/app/utils/setupMonacoEnvironment.ts`**
(new, replaces
`settings/mcp-and-apis/utils/setupGraphiqlMonacoWorkers.ts`) — app-level
worker factory mapping every label Monaco can ask for: `json`,
`css`/`scss`/`less`, `html`/`handlebars`/`razor`,
`typescript`/`javascript`, `graphql`, and the generic editor worker as
the fallback.
- **`twenty-front/src/index.tsx`** and **`.storybook/preview.tsx`** —
set it up once for the app and for stories, rather than as a side effect
of one lazy route.

Dropping the CDN also means the code editors work in self-hosted and
air-gapped deployments, which today silently fall back to a broken
editor when jsDelivr is unreachable.

## Verification

- `nx build twenty-front` passes; `css.worker`, `html.worker` and
`ts.worker` chunks are now emitted alongside the existing
`editor`/`json`/`graphql` ones.
- Monaco stays lazy — `edcore.main` is not statically imported by the
entry chunk and is absent from `index.html`'s modulepreloads. Measured
against a baseline build of `main`, the entry chunk goes from 2,598,088
B to 2,599,040 B (**+952 B**).
- `oxlint` and `oxfmt --check` clean on all touched files; `tsc
--noEmit` clean for `twenty-ui` and reports nothing new for the touched
`twenty-front` files.

Not verified in a browser — worth a manual pass on the playground → MCP
tab → workflow code editor sequence that reproduced the original errors.

Fixes TWENTY-FRONT-8YV
Fixes TWENTY-FRONT-8YW
Fixes TWENTY-FRONT-ADE

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23855?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-06 14:26:54 +02:00
Raphaël Bosi 829ef9d8b9 Revert AI chat chips to the [[kind:...:label]] syntax (#23852)
Removes the `[[kind:...:label[[/kind]]` closing-tag syntax and goes back
to the simpler `[[kind:...:label]]` form for all four chip kinds
(record, object, field, view).

The parser is now a single regex pass instead of a two-pass scan with a
per-reference closing-tag search, a legacy fallback and surplus-bracket
handling. That removes 11 files. The label pattern excludes `[`, `]` and
newlines, which is what keeps an unclosed marker from swallowing the
text (and the marker) that follows it.

```mermaid
flowchart LR
    subgraph before ["Before — two passes"]
        O1["scan for marker openings"] --> O2["window each opening<br/>up to the next one"]
        O2 --> O3["find that kind's closing tag<br/>inside the window"]
        O3 --> O4["record only:<br/>bare-terminator fallback"]
        O4 --> O5["consume surplus<br/>closing brackets"]
    end
    subgraph after ["After — one pass"]
        N1["matchAll, one regex:<br/>object · field · view · record"] --> N2["map each match<br/>to a chip"]
    end
    before -.->|"11 files deleted"| after
```

Two things to know:
- Messages already stored with closing tags render as raw text instead
of chips.
- Malformed model output is no longer compensated for: a surplus `]`
after a chip stays in the text, and a display name containing brackets
does not chip. The system prompt tells the model to avoid both.

Rendering cost is unchanged for normal messages and noticeably lower on
long bracket runs, since the old opening pattern had to scan them.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23852?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-06 11:11:26 +00:00
github-actions[bot] 18a5121ab2 i18n - translations (#23860)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-06 13:12:09 +02:00
Marie d8b494d530 Make subdomain minimum length configurable via env var (#23209)
## What

Introduces a `SUBDOMAIN_MIN_LENGTH` environment variable (default `3`)
controlling the minimum number of characters allowed for a workspace
subdomain.

Until now the minimum was hardcoded (`3`), baked into the shared
`SUBDOMAIN_PATTERN` regex.

## How

- Added the `SUBDOMAIN_MIN_LENGTH` config variable (default `3`) in
`config-variables.ts`.
- Relaxed `SUBDOMAIN_PATTERN` in `twenty-shared` to validate format and
max length only, so the minimum length policy now lives with the caller
instead of being embedded in the regex.
- `isSubdomainValid` now takes a `minLength` argument (defaulting to
`3`) and enforces it explicitly.
- `SubdomainManagerService` reads `SUBDOMAIN_MIN_LENGTH` from config and
passes it to every validation call, making the server the authoritative
source.

## Scope

Server-side only. The frontend validation schema keeps its default
`.min(3)` UX check and is unchanged; the server remains the source of
truth for what subdomains are accepted.

## Tests

- Updated the shared `isValidTwentySubdomain` tests to reflect that the
pattern no longer enforces a minimum length.
- Added tests for the configurable minimum in
`is-subdomain-valid.util.spec.ts`.
- Updated the service spec config mock to return a numeric value for
`SUBDOMAIN_MIN_LENGTH`.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23209?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-06 11:03:18 +00:00
Charles Bochet d4c3759c70 ci(pr-review): stop a skipped label dispatch from cancelling the open dispatch (#23856)
## Problem

The standard review silently does not run on PRs that get labelled by a
bot right after opening. https://github.com/twentyhq/twenty/pull/23854
is an example: no `PR Review #23854` run exists in `ci-privileged` at
all.

| time | what |
|---|---|
| 10:08:35 | PR opened |
| 10:08:39 | `twenty-eng-sync[bot]` adds the `-PR: draft` label |
| 10:08:40 | dispatch run for `opened` starts, cancelled during "Set up
job" |
| 10:08:43 | dispatch run for `labeled` is skipped by the job `if` |

Concurrency is evaluated before the job-level `if`, so the `labeled` run
preempts and cancels the in-flight `opened` run and is then skipped
itself (`-PR: draft` does not start with `pr-review-`). The sync bot
labels within ~4 seconds of open, which is faster than the app-token
mint step, so the `opened` dispatch loses this race essentially every
time that label is applied.

`opened` is the only event that resolves to the `standard` check, so
with no later push the PR gets no review at all. Same class of gap as
the one #23708 closed, moved down a layer: the trigger exists now but
gets cancelled.

## Fix

Scope the concurrency group by event action, and only cancel in-progress
runs for `synchronize`. Rapid consecutive pushes still de-duplicate;
`opened`, `ready_for_review` and `labeled` no longer cancel each other.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23856?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-06 12:54:20 +02:00
github-actions[bot] d649baa3f0 i18n - translations (#23853)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23853?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-06 11:57:10 +02:00
Abdul Rahman 0bf4b53af3 feat(slack): cache the Slack bot user id at connect time (#23726)
## What

Caches the Slack bot user id in workspace kv so the channel welcome
stops calling `auth.test` on every channel-join event.

## Why

Slack fires `member_joined_channel` for **every** person joining **any**
channel the bot sits in, not just for the bot itself. The welcome path
had to answer "was that our bot?", and did it by resolving the Slack
connection and calling `auth.test` — a connection lookup plus an
external API call, on every event, to conclude "no, that was a human, do
nothing".

The bot user id never changes for a given connection, so asking Slack
repeatedly is the wrong shape.

## How

`registerSlackConnection` already calls `auth.test` in the `onConnect`
hook and had `user_id` in hand, so it now writes it to workspace kv.
`resolveSlackBotUserId` reads it, falling back to `auth.test` (and
backfilling) for connections created before this change.

Reconnecting is the only thing that can change the bot user id, and
reconnecting re-runs that hook — so the cache is self-correcting and
needs no TTL.

Because resolving the id no longer needs a Slack client, the bot check
moved ahead of the connection lookup:

| per join event | before | after |
|---|---|---|
| Twenty round trips | 1 | 1 |
| Slack API calls | 1 | 0 |

A secondary win: previously `getSlackClient()` ran before the bot check
and threw on failure, so a revoked Slack connection made **every
unrelated human join** fail its job and retry. Now a human join answers
from kv and returns cleanly; the connection is only touched when there
is genuinely something to post.

## Claim ordering

Moving the client lookup after the claim opened a window where the claim
is held but nothing was posted, so that path now releases the claim
before throwing. The invariant the file follows is unchanged: release on
any failure that produced no message, keep it once a message is out (a
retry must not repost the channel message).

## Renames

`claimSlackTeam` → `registerSlackConnection`, and the logic function
`slack-team-claim` → `slack-register-connection`, since it now does more
than claim the team and connect-time work will keep landing there.

**The universal identifier value is unchanged** (`a29ae15d-…`) — it is
the app's stable identity and what the connection provider binds
`onConnectLogicFunction` to. Only the constant's name moved. Worth a
second pair of eyes in review, since that is exactly the kind of thing a
rename sweep regenerates by reflex.

Note this changes `name` and `sourceHandlerPath`/`builtHandlerPath` in
the manifest. Both are updates keyed on the unchanged identifier, not a
delete-and-recreate, so installed apps re-sync cleanly — but the app
needs rebuilding so the bundle path matches.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23722?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. -->



## Cache correctness

A wrong cached id fails silently — the bot's own join reads as someone
else's and the welcome never fires — so the entry is bounded and
self-healing in three ways:

- **Failed write drops the key.** Leaving the previous id in place would
keep a superseded value authoritative. An absent cache is rebuilt from
`auth.test`; a wrong one is believed.
- **Entries expire after 7 days.** `registerSlackConnection` rewrites on
every connect, so the expiry only matters when that write never lands.
- **A kv outage falls through to `auth.test`** rather than throwing,
which keeps the human joins that make up nearly all these events from
failing their job.
2026-08-06 09:50:53 +00:00
Thomas Trompette 5278a47b55 refactor(front): move workflow run step logs into workflow-actions (#23841)
Follow-up on unapplied review feedback from #21142.

### Folder structure

@thomtrp:
> We now have a folder workflow-run/observability, but we have
workflow-run related components in workflow-actions folder already. I
would avoid that workflow-run/observability folder.

The `workflow-run/` folder held nothing but `observability/`, so it is
removed entirely and each step log detail component now sits with its
action:

| File | New location |
| --- | --- |
| `WorkflowRunStepLogsAiAgentDetail.tsx`,
`WorkflowRunStepLogsToolCallRow.tsx` |
`workflow-actions/ai-agent-action/components/` |
| `WorkflowRunStepLogsCodeDetail.tsx` |
`workflow-actions/code-action/components/` |
| `WorkflowRunStepLogsHttpRequestDetail.tsx` |
`workflow-actions/http-request-action/components/` |
| `WorkflowRunStepLogsDetail.tsx`, `WorkflowRunStepLogsEntries.tsx`,
`WorkflowRunStepLogsEmailDetail.tsx`, `workflowRunStepLogsStyles.ts` |
`workflow-actions/components/` |
| `formatDuration.ts`, `formatBytes.ts` | `workflow-actions/utils/` |

The email detail stays in the shared `components/` folder since there is
no dedicated send-email action folder on the front end (email editing
lives at the root as `WorkflowEditActionEmailBase.tsx`).
`workflowRunStepLogsStyles.ts` goes next to the shared components rather
than `utils/`, since it is styled components and not utils.

Also folds in @FelixMalfait's `2 export in 1 file` comment:
`workflowRunStepLogsFormatters.ts` is split into `formatDuration.ts` and
`formatBytes.ts`.

### AI comments

@thomtrp:
> same, let's not keep AI comments

Removes every comment #21142 introduced, across the front end, server
and shared packages:

- `WorkflowRunStepLogsEntries.tsx` - the `onlyLatestIteration` prop
block
- `workflow-run-step-log-schema.ts` - the transport-failure note and the
permissive-schema rationale
- `strip-ansi-escapes.util.spec.ts`,
`build-http-request-step-log.util.spec.ts`,
`truncate-string-to-utf8-byte-budget.spec.ts`,
`agent-async-executor.service.spec.ts` - the byte-vs-char and
pre-fix-behaviour commentary

No behaviour change. Locale catalogs are deliberately left untouched;
the next i18n run picks up the new source paths.

### Still open from #21142, not covered here

- `persistStepLog` try/catch duplicated across the code, tool-backed and
ai-agent actions
- `draft-email-tool` returning both `sanitizedHtmlBody` and
`plainTextBody`
- storing both `totalCostInDollars` and `creditsUsedMicro`
- the byte-budget truncation utilities being over-engineered
- `strip-ansi-escapes` being local to application logs
2026-08-06 09:48:36 +00:00
github-actions[bot] f3bc2325cb i18n - docs translations (#23851)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-06 11:35:30 +02:00
Félix Malfait 1267697b2c Fix missing border below the last record table row (#23846)
The bottom border of the last row of a record table is missing, except
under the sticky first columns.

<img width="600" alt="before"
src="https://github.com/user-attachments/assets/placeholder" />

## Cause

Two things combine.

**A 1px off-by-one in the virtualization grid.** Virtualized rows are
absolutely positioned on a grid whose pitch is `RECORD_TABLE_ROW_HEIGHT
+ 1` (row plus its bottom border), and
`RecordTableRowVirtualizedContainer` reserves the first slot for the
header:

```ts
const pixelsFromTop =
  realIndexByVirtualIndex * (RECORD_TABLE_ROW_HEIGHT + 1) +
  (RECORD_TABLE_ROW_HEIGHT + 1);
```

`RecordTableVirtualizedBodyPlaceholder` reserves `n *
(RECORD_TABLE_ROW_HEIGHT + 1)` of in-flow height to match. But header
cells are sized `height: RECORD_TABLE_ROW_HEIGHT` with their
`border-bottom` inside that box, so the header only occupies 32px, not
the 33px the grid assumes. Everything after the placeholder therefore
sits one pixel above the grid.

**The add-new row started painting over that pixel.** It used to be an
unpositioned sibling, so the absolutely positioned rows painted above it
(positioned descendants paint after in-flow blocks) and the overlap was
invisible. #23211 wrapped it in `DragDropItemEndDropZone`, which is
`position: relative`; #23752 kept that as `StyledEndDropZone`. It is now
a positioned element later in DOM order, so it paints over the rows and
its opaque background covers the last row's border. The border survives
only where cells carry their own `z-index` — the sticky first columns.

Measured on `/objects/workflows` with 2 records, before the fix:

| element | top | bottom |
| --- | --- | --- |
| header row | 88 | 120 (height 32) |
| last row container | 154 | 187 |
| add-new wrapper | 186 | 218 |

## Fix

Give the header container the full row slot (`RECORD_TABLE_ROW_HEIGHT +
1`) so the body lines up with the grid the virtualization already
assumes. Header cells keep their own 32px sizing, so their internal
layout is unchanged.

This also closes the 1px gap that previously sat between the header and
the first row.

After the fix, on the same view:

| element | top | bottom |
| --- | --- | --- |
| header row | 88 | 121 (height 33) |
| first row | 121 | 154 |
| last row | 154 | 187 |
| add-new wrapper | 187 | 219 |

Overlap 0, header-to-first-row gap 0.

Only the ungrouped virtualized table was affected.
`RecordTableRecordGroupRows` has the same `position: relative` wrapper,
but its rows are in normal flow, so the header change just shifts the
whole body down a pixel with no overlap possible.

## Testing

Ran the app locally against seeded data:

- Workflows (2 rows): border restored across the full width, geometry
above verified in the DOM.
- Companies (599 rows): header 33px, first row flush at 0, uniform 33px
pitch across all 240 mounted row containers; scrolled and confirmed rows
slide under the sticky header cleanly.
- Verified the diagnosis independently by toggling the end drop zone to
`position: static` in the running page, which restores the border the
same way.

`npx nx typecheck twenty-front` and `npx nx lint:diff-with-main
twenty-front` are green.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23846?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-06 10:48:38 +02:00
Félix Malfait ae235d3b24 Scope sessions and application authorizations to the workspace (#23843)
Settings / Profile / Devices listed every live session for the account,
so a person who belongs to two workspaces saw all of them from either
one, and "Log out all other devices" signed them out everywhere.

Almost nothing in Twenty is account-wide, and a session belongs to the
workspace its exchange selected, so neither the list nor the revocations
are another workspace's business.

## What was wrong

`currentUserSessions` called `findActiveSessionsForUser(user.id)` with
no workspace filter. Both revoke paths were keyed on `userId` alone, so
`revokeUserSession` could target another workspace's session by id and
`revokeAllOtherUserSessions` cleared every workspace at once.

Sweeping the other resolvers that take `@AuthUser()` turned up the same
shape in the OAuth application authorizations added in #23678:
`findActiveAuthorizationsForUser(userId)` and `revokeAuthorizationById({
authorizationId, userId })`. A grant made in one workspace was listed,
and revocable, from another.

Everything else already pairs `@AuthUser()` with `@AuthWorkspace()`.
`client-config.resolver.ts` is the reference pattern.

## The fix

Both resolvers now take the workspace from the auth context and pass it
down, and the service methods are renamed to say so.

`revokeAllSessionsForUser` keeps `workspaceId` optional on purpose:
`auth.service.ts` uses it on password change, where clearing every
workspace is the intended behaviour.

Sessions with no workspace (the workspace-agnostic ones minted on the
default subdomain, which exists to list workspaces and carry the
auto-login window) now belong to no workspace's list and survive "log
out all other devices".

## Verification

- New integration spec built on Tim's membership of both apple and yc:
the list stays disjoint, a cross-workspace revoke by id is refused and
is a no-op, and revoking all other devices in one workspace leaves the
other signed in
- Mutation-checked by dropping the `workspaceId` from the query, which
fails the isolation test while the two revoke tests still pass,
confirming each assertion targets its own mechanism
- 160 unit tests, 70 integration tests across the session and OAuth
suites

## Also

The devices button drops its danger accent for the plain small variant,
matching Deactivate in `ObjectSettings.tsx`.

## Separate finding, not fixed here

`request.ip` resolves to an internal address behind the Cloudflare /
nginx chain, which is why every row in the screenshot that prompted this
showed the same RFC1918 address. That is an ingress configuration issue
rather than an application one. It does not affect ClickHouse audit
logs, which store no IP, but it does affect the two OAuth rate limiters
that key on `req.ip`.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23843?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-06 10:20:45 +02:00
Marie 26104d47a6 Report a sending domain as pending while SES waits for the DKIM records (#23818)
## Problem
<img width="648" height="310" alt="image-1785940453654"
src="https://github.com/user-attachments/assets/60362b8c-fed5-4ce3-af45-9aa064356f11"
/>

A sending domain that is simply waiting on its DKIM records is displayed
as **Failed**, with every DKIM row marked **Error**, even when the DNS
is correct and AWS has already published the key.

Hit while setting up `twenty.dev` for a demo. All five CNAMEs resolve
correctly from the authoritative nameserver and from a public resolver,
none are proxied, the unsubscribe row is green, and the first DKIM token
already resolves through to its published key at AWS:

```
$ dig +short TXT abbr…._domainkey.twenty.dev
abbr….dkim.amazonses.com.
"p=MIIBIjANBgkq…"
```

Yet all three DKIM rows read Error, which tells the user to go fix DNS
that isn't broken.

## Cause

`determineVerificationStatus` treats `VerifiedForSendingStatus ===
false` as terminal:

```ts
if (
  identityResponse.VerifiedForSendingStatus === false ||
  dkimStatus === 'FAILED'
) {
  return EmailingDomainStatus.FAILED;
}

return EmailingDomainStatus.PENDING;
```

SES reports `VerifiedForSendingStatus: false` for the entire period it
is waiting to detect the DKIM CNAMEs, which is the normal state of every
domain between setup and verification. So a pending domain returns
FAILED, and the PENDING branch is unreachable for any identity where the
field is present at all. `TEMPORARY_FAILURE`, which SES documents as
retryable, was also reported as Failed.

The status is then stamped onto each DKIM row by `withRecordStatus`,
which is why all three rows change together and none of them reflects
its own record.
2026-08-06 08:01:34 +00:00
github-actions[bot] 299ebb1890 i18n - translations (#23845)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-06 09:44:02 +02:00
Félix Malfait 7e00298044 Rename credits "Increase" button to "Manage" (#23840)
The green primary button in the Credits section was labelled "Increase"
with an up-arrow icon, but it opens the credit package picker, whose
slider spans every available package including smaller ones. So the
label promised upgrade-only while the modal supports both directions.

Renamed it to "Manage" and swapped the up arrow for `IconAdjustments`
(sliders), matching the slider-based picker it opens.

The secondary shortcut buttons ("Increase to $100", "Increase to $200")
are unchanged since those really do apply an upgrade directly.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23840?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-06 09:35:41 +02:00
github-actions[bot] 692c0c8402 i18n - translations (#23844)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-06 09:34:19 +02:00
Félix Malfait 647a6aec58 Add nested relation Field widgets on record page layouts (#23815)
## Context

Record pages can show a list of directly related records (Field widget
in Table display mode), but not records two relation hops away. The
canonical ask: on a Client page, list the Transactions of the
Client&#39;s Wallets.

Stacked on #23814 (merged) and #23832 (merged); their commits are
included in this branch. #23836 stacks on this PR to add many-to-one
first hops.

## How it works

The 2-hop case does not need any new query capability. It reuses the
relation traversal filter shipped for advanced filters: the widget
embeds a view on the terminal object (Transaction) with one seeded
filter `inverse relation IS current record`, traversed one hop
(`fieldMetadataId` = Transaction.wallet, `relationTargetFieldMetadataId`
= Wallet.client, value `isCurrentRecordSelected`). At query time this
compiles to `{ wallet: { clientId: { in: [currentRecordId] } } }`, which
is within the backend&#39;s `MAX_RELATION_FILTER_DEPTH = 1` since the
second hop lands on the join column. Records from all intermediate
records (all wallets of the client) are listed, so one-to-many fan-out
on the first hop works out of the box.

## Changes

Configuration
- `FieldConfiguration` gains an optional `nestedRelationFieldMetadataId`
(shared type, DTO, GraphQL fragment, regenerated metadata types).
Backward compatible: existing widgets are untouched.

UI
- The Field picker drills into one-to-many relation fields, mirroring
the advanced filter submenu pattern: back header, an entry to select the
relation itself (previous behavior), then the target object&#39;s
one-to-many relations. Selecting a nested field creates a widget titled
`First hop → Second hop` in Table display mode. First-level rows that
open a submenu never show the checkmark; the selected chain is only
visible inside the submenu, matching the chart group by field selection.
- The layout dropdown, settings panel and renderer resolve the terminal
object of the chain; a widget whose second hop was deleted or
deactivated renders nothing instead of silently showing first-hop
records.
- Nested widgets only offer embedded view layouts (Table / Kanban /
Calendar), since inline display modes would render the first hop&#39;s
relation field.
- The relation table view resolver regenerates the embedded view
whenever the selection results in a table widget and the chain changed
or the view id is missing, so a table widget can never carry a view
belonging to a different chain.

Server
- `FieldConfigurationDTO` accepts the new optional field.
- Both universal configuration mappers (to and from universal
identifiers) carry it for app manifest sync.
- New `validateFieldConfigurationNestedRelationOrThrow` enforces that
both hops are active one-to-many relation fields on the right objects,
wired next to the existing chart field reference validation.

Record creation
- `buildRecordInputFromFilter` skips relation-traversal filters: they
constrain a related record&#39;s column, so prefilling the created
record&#39;s own foreign key from them would link the wrong record (e.g.
`walletId = clientId`).
- Add New in a nested widget table instead prompts for the record to
create through: the row opens a picker listing the current record&#39;s
first-hop records (the client&#39;s wallets), scoped with a find filter
on the relation join column, and creates the record with the picked id
prefilled. Covers the plain table and per-group add rows. Board and
calendar layouts hide their create buttons in nested widgets since they
cannot know the record to create through.
- Matching the created record against the widget&#39;s traversal filter
client side is handled by #23832.

Out of scope, deliberately: depth stays at exactly two levels (matches
the backend filter depth cap), junction and morph relations are not
drillable, and chart widgets on record pages are untouched.

## Tests

- Unit: nested chain resolution util, draft view seeding with the
traversal filter, view id change resolver, picker parameter derivation,
server-side validation. Full `page-layout` and `record-filter` front
suites pass (187 suites / 1233 tests), server `page-layout-widget`
suites pass.
- Manual, on seeded data: created a `People → Opportunities` widget on a
Company page; it lists exactly the opportunities whose point of contact
belongs to that company, persists across save and reload, and scopes per
record. Add New opens a picker showing only that company&#39;s people;
picking one creates an opportunity with `pointOfContactId` set (verified
in DB) and the row appears in the widget immediately.

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

<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23815?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-06 09:26:53 +02:00
github-actions[bot] 9480513689 chore: sync AI model catalog from models.dev (#23839)
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.

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-08-06 08:51:42 +02:00