Commit Graph

14179 Commits

Author SHA1 Message Date
Charles Bochet 13a2e3ebe8 fix(twenty-server): compose email from the caller's own connected account (#23793)
### The bug

`draft_email` and `send_email` take an optional `connectedAccountId`.
When an agent omits it — which it does whenever it has no way to know
the id — `EmailComposerService` resolved the account like this:

```ts
const allAccounts = await this.connectedAccountRepository.find({
  where: { workspaceId, archivedAt: IsNull() },
});

return allAccounts[0].id;
```

The first connected account **in the workspace**, ignoring the
`userWorkspaceId` that `ToolExecutionContext` already carries — with no
`ORDER BY`, so "first" is whatever the planner returns.

We hit this on our own workspace: an agent chat drafted a customer email
on behalf of one user, and the draft landed in a different user's
mailbox. The tool reported `success: true` with a `connectedAccountId`
belonging to someone who was not in the conversation, so nothing
surfaced the mistake. `send_email` shares this composer, so the same
fallback sends mail from another person's address.

### The fix

- **No id supplied** → the caller's own account
(`context.userWorkspaceId`), else an account whose `visibility` is
`workspace`, else throw `CONNECTED_ACCOUNT_NOT_FOUND`. Never a
colleague's private mailbox by accident.
- **Id supplied** → used as given, whoever owns it. Blocking a member
from composing through another member's account is a product decision
this PR does not make; the mix-up above happens when no id is passed at
all.
- **No `userWorkspaceId`** (workflow run) → unchanged.

Ordering is `createdAt ASC, id ASC` so the no-caller path is
deterministic when rows share a `createdAt` — which the seed data does.

### Verified against a real workspace

Run locally against the seeded `test` database — 7 connected accounts in
one workspace, owned by four different members, **all sharing one
`createdAt`**. Same spec, composer swapped:

| Scenario | on `main` | with this PR |
|---|---|---|
| Phil's agent composes, no id | **tim@apple.dev's account** | phil's
own |
| explicit id (jony's), caller is phil | jony's | jony's |
| workflow run (no caller), explicit id | jony's | jony's |
| **workflow run (no caller), no id** | **first account, unordered** |
**first account, `createdAt`/`id` ordered** |
| caller with no account | silently resolved a colleague's | throws |

### What this does not fix

A workflow run carries no caller: `ToolBackedWorkflowAction` executes
the tool with `{ workspaceId }` and no `userWorkspaceId`. So when an
email step's sender resolves to nothing — `postprocessInput` guards for
it — the composer still falls back to the workspace's first account,
because there is no identity to attribute the mail to. The pick is at
least deterministic now. Giving workflow runs an owner is a separate
change.

Normal workflow steps are unaffected:
`EmailWorkflowActionBase.resolveSenderConnectedAccountId` resolves the
configured sender (a connected-account id, or a workspace member id from
a resolved variable) and passes it explicitly.

### Behaviour change to expect

A caller with no connected account of their own, in a workspace with no
shared account, now gets an error where the call previously "succeeded"
from a colleague's mailbox.

### Tests

Resolution is exercised by
`test/integration/email-tool/suites/email-composer-connected-account.integration-spec.ts`
against a real workspace — eight cases: supplied id honoured, supplied
id with no caller, invalid id, unknown id, caller's own account,
workspace-shared fallback (flips `visibility` in Postgres and restores
it), no usable account, and first-account-when-no-caller.

The service's unit spec is deleted: mocking the DI graph asserted the
mock rather than the resolution, and every case it covered now runs
against the database. The pure selection logic keeps unit specs —
`select-connected-account-id-for-caller.util.spec.ts` and
`is-connected-account-usable-by-caller.util.spec.ts`.

Not covered here: the workflow chain itself (`postprocessInput` →
`resolveSenderConnectedAccountId` → `DraftEmailWorkflowAction`), which
this PR does not change.

`npx nx typecheck twenty-server`, the email-tool and connected-account
suites, and the integration spec all pass; oxlint type-aware clean.
2026-08-05 09:31:46 +00:00
github-actions[bot] 6d83018b6f i18n - docs translations (#23796)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23796?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-05 11:25:39 +02:00
Thomas Trompette 61c72942ac feat(workflow): dispatch automated triggers from core behind a flag (#23775)
## Context

Part of the workflow → core migration. Before we can stop writing
workspace `trigger`/`steps`, automated-trigger dispatch must read from
core. Dispatch currently reads the workspace `workflowAutomatedTrigger`
table (populated from the workspace trigger), so it would go blank once
those writes stop. This flips the dispatch reads behind a flag,
mirroring the version-content read switch.

## What this does

New flag `IS_WORKFLOW_DISPATCH_FROM_CORE_ENABLED` (per-workspace,
default off). At each dispatch read site, flag-on reads the core-derived
trigger map and flag-off keeps the current workspace query.

- **DB-event listener** (`workflow-database-event-trigger.listener.ts`):
extracted `getDatabaseEventListeners(workspaceId, eventName)`. Flag-on
filters the core map (`getOrRecompute → byWorkflowId`, `type ===
DATABASE_EVENT && settings.eventName === name`); flag-off keeps the repo
`find`. The evaluation type is broadened to the structural `{
workflowId, settings }` that both the entity and the map entry satisfy;
the enqueue loop and `shouldTriggerJob` are unchanged.
- **CRON job** (`workflow-cron-trigger-cron.job.ts`): extracted
`getWorkspaceCronTriggers(workspaceId)`. Flag-on filters the core map
for `type === CRON` → `{ workflowId, pattern }`; flag-off keeps the raw
SQL. The redis cron cache, dedup and dispatch loop are unchanged; only
the rebuild source swaps.

## Why it's safe

- The core map is keyed by the workspace `workflowId`, and both sites
enqueue `workflowId` only. Nothing consumes the map's core
`workflowVersionId`, so `workflow-trigger.job.ts` still re-derives the
version from workspace `lastPublishedVersionId` (no id translation).
- Flag defaults off, per-workspace rollout. The drift cron's
`checkAutomatedTriggerSync` already compares the core map against the
workspace table, so it's the soak signal for flipping the flag.
- The CRON source is only re-read on a cron-cache rebuild (cache miss),
so a flag flip takes effect on the next rebuild: bounded by the cache
TTL, or immediately on activation/deactivation, which invalidates the
cache. Both sources emit identical `{ workflowId, pattern }` for a
synced workspace, so the switch is a no-op in output.

## Prerequisite

- The orphan-ACTIVE core-version cleanup (#23739) must land first: the
core map is built from core ACTIVE versions, so a phantom orphan would
become a live phantom trigger the moment this flag flips.

## Verification

- Server unit specs cover both sites with the flag off (existing
behavior) and on (reads the core map).
- Live-verified on a dev instance: DB-event and CRON dispatch both fire
from the core map with the flag on, and from the workspace entity with
it off.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23775?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-05 08:58:19 +00:00
Abdul Rahman 42aa566e32 feat(twenty-slack): link records and format the assistant reply footer (#23539)
Follow-up to the Slack bot branch, improving how assistant replies read
in Slack.

## Problem

The assistant never had the workspace URL. `buildSlackAssistantPrompt`
injected the request, requester and thread context, and the agent prompt
said nothing about links, so a record it created or found came back as
plain text with no way to open it. The reply was also a single
`markdown_text` blob with `_Answered in 3s_` appended to the answer.

## Changes

**Record deep links.** `fetchWorkspaceBaseUrl` resolves the workspace
URL from `currentWorkspace { workspaceUrls }`, preferring a custom
domain over the subdomain. It runs in parallel with the existing Slack
context fetch, so no extra latency. The prompt carries the base URL plus
the `[Record Name](base/object/<objectNameSingular>/<recordId>)` rule.
When the URL cannot be resolved the prompt explicitly forbids writing
any Twenty URL, so a failed lookup degrades to plain record names rather
than invented links.

**Reply structure.** The answer now goes out as Block Kit: a `markdown`
block for the body and a `context` block for the duration, so it reads
as a footer rather than italic text tacked onto the answer.
`getSlackChatMessageBodyFields` grew a blocks variant that keeps the
message text as Slack's notification and screen-reader fallback, and
`slackUpdateMessageHandler` now falls back to plain text on
`invalid_blocks` for blocks as well as markdown.

## Screenshots

### Before
<img width="344" height="161" alt="Screenshot 2026-07-30 at 8 13 55 AM"
src="https://github.com/user-attachments/assets/c4a76654-b4bc-4f3d-a8ad-a00073ec5674"
/>

### After
<img width="408" height="126" alt="Screenshot 2026-07-30 at 8 26 15 AM"
src="https://github.com/user-attachments/assets/ba2ec249-0520-42ca-87d1-c272515bddef"
/>


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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23539?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-05 08:45:46 +00:00
github-actions[bot] 196a1945ff i18n - translations (#23794)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23794?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-05 10:24:28 +02:00
Félix Malfait b05d2406ec fix(twenty-front): stop field widget layout dropdown from crashing th… (#23784)
…e record page

RecordTableWidgetViewDraftInitEffect read the page layout edit mode and
the page layout instance id from context, but the widget settings side
panel renders outside the page layout tree. Opening the Layout picker on
a relation field widget displayed as a table threw
"PageLayoutEditModeContext Context not found" and took down the whole
record page.

Both values are now passed in by the caller.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23784?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-05 10:21:28 +02:00
Abdul Rahman ded3f1efb3 Add messages support to runAgent for multi-turn bot conversations (#23395)
- Extend `runAgent` so callers can pass either a one-shot prompt or a
multi-turn messages array (user / assistant text), matching AI SDK’s XOR
shape — for Slack/Discord/Teams bots that need thread history.
- Enforce exactly one of prompt | messages in AgentRunService; map
messages 1:1 to AI SDK ModelMessages in AgentAsyncExecutorService
- Update shared types, GraphQL/SDK inputs, docs (skills-and-agents), and
regenerate metadata clients; existing prompt-only callers stay unchanged

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23395?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-05 08:20:12 +00:00
Paul Rastoin 0b817e3bc0 Restrict which workspace fields can be updated before activation (#23781)
## What

`validateWorkspaceUpdatePermissions` returned early with no checks at
all when the workspace was in `PENDING_CREATION`, so `updateWorkspace`
accepted any field during that window. It now allows only the fields
needed to set the workspace up (`displayName`, `subdomain`, `logo`) and
rejects everything else until the workspace is activated.

Note that `updateWorkspace`'s resolver guard is `CustomPermissionGuard`,
which always returns true and only documents that the check lives in the
resolver/service, so this service method is the actual enforcement
point.

## Why

A workspace stays in `PENDING_CREATION` from signup until onboarding
completes, and the JWT strategy issues an authenticated context for it
without resolving member permissions. During that window every field was
writable with no permission check, including security relevant ones such
as `allowImpersonation`, `isTwoFactorAuthenticationEnforced` and
`isPublicInviteLinkEnabled`.

In practice the only principal present before activation is the
workspace creator, who is granted the Admin role
(`canUpdateAllSettings`) the moment activation completes, so there is no
privilege escalation over another user today. This is defense in depth:
the early return was broader than it needed to be, and it becomes a real
gap if the "only the creator exists before activation" assumption ever
stops holding, for example a workspace left pending or a future flow
that adds members before activation.

The bypass exists because a pending workspace has no roles yet, so
permissions cannot be resolved for it. Keeping a small explicit
allowlist preserves that while removing the blanket skip.

## Scope

Only the `updateWorkspace` path. `SettingsPermissionGuard` has a similar
bypass for `PENDING_CREATION` / `ONGOING_CREATION`, but it covers 62
resolvers including billing endpoints that onboarding legitimately calls
before activation, so narrowing it needs its own analysis and is
deliberately left out.

## Tests

`workspace-update-before-activation.integration-spec.ts`, run against a
real database with the seeded workspace flipped to `PENDING_CREATION`:

- a security sensitive field (`allowImpersonation`) is rejected and the
stored value is unchanged
- mixing a setup field with a security sensitive one rejects the whole
update, and `displayName` is not persisted
- setup fields (`displayName`) still apply, so the restriction does not
break workspace setup

Both rejection tests were verified to fail when the old blanket early
return is put back, while the positive control keeps passing. The
existing `settings-permissions/workspace*` suites still pass (38 tests
total), confirming no change for activated workspaces.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23781?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-05 08:15:31 +00:00
Raphaël Bosi 7c9ec6a770 Continue the workspace setup chat in the side panel when navigating away (#23744)
https://github.com/user-attachments/assets/579a8d41-901e-41c0-85a4-f23e6bc3da8d



The /workspace-setup full-page chat shows the nav drawer, so users can
navigate away mid-conversation and lose sight of the chat. Leaving the
page by any means (drawer link, browser back) now opens the same
conversation in the Ask AI side panel, with the full-page chat visually
shrinking into the panel via the panel's existing width transition.

The page marks a handoff atom while mounted; the side panel consumes it
in a mount layout effect (pre-paint, so no flash frame), opens the Ask
AI page, and enters at full width before shrinking. The Close button
still exits without reopening the panel, the Collapse button keeps its
behavior and gains the same animation, and prefers-reduced-motion skips
it. Mobile is unchanged since the full-screen panel would cover the
destination page.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23744?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-05 08:09:36 +00:00
Paul Rastoin 8bfa9c4adb Proxy API routes through the vite dev server to keep local dev same-origin (#23779)
Replaces #23774 (closed), rebased on latest main.

## Problem

Since the cookie-session migration (#23642), the front sends every
request with `credentials: 'include'` and the server only reflects
`Access-Control-Allow-Origin` for the exact origins in the credentialed
allowlist (`SERVER_URL`, `FRONTEND_URL`, `AUTH_COOKIE_ALLOWED_ORIGINS`).
Any other origin gets the `*` wildcard, which browsers reject for
credentialed requests.

Local dev is split-origin by default (front on `localhost:3001`, API on
`localhost:3000`), and with `IS_MULTIWORKSPACE_ENABLED` every workspace
subdomain (`apple.localhost:3001`, ...) is yet another origin. Each
locally created workspace would need a manual
`AUTH_COOKIE_ALLOWED_ORIGINS` entry.

## Solution

Make local dev same-origin instead of widening the CORS policy: the vite
dev server now proxies all top-level API route prefixes to the backend,
and the front calls its own origin.

- `vite.config.ts` adds a `server.proxy` covering the backend's
top-level prefixes (`/graphql`, `/metadata`, `/admin-panel`, `/auth`,
`/rest`, `/file`, `/client-config`, ...), defined in
`src/config/apiProxyPrefixes.ts`. Keys are anchored regexes
(`^/auth($|[/?])`) so SPA routes sharing a prefix (`/authorize`,
`/settings`) are not swallowed. The target defaults to
`http://localhost:3000` and follows `REACT_APP_SERVER_BASE_URL`.
`changeOrigin` stays off so the backend sees the browser's Host:
same-origin checks (CSRF, cookie issuance) and workspace resolution by
subdomain work unchanged through the proxy.
- `config/index.ts` collapses to
`window._env_?.REACT_APP_SERVER_BASE_URL || window.location.origin`.
Every supported production path injects `window._env_` (docker
entrypoint fails hard without `REACT_APP_SERVER_BASE_URL`; a
server-served front gets it from `generateFrontConfig()`), and in dev
the current origin is correct on `localhost:3001` and every
`*.localhost:3001` workspace subdomain thanks to the proxy. The removed
`http://<hostname>:3000` fallback only served an un-injected production
bundle browsed on localhost, a setup whose credentialed auth the
cookie-session migration had already broken.

The credentialed allowlist itself is unchanged and stays strict; since
dev traffic is same-origin, the per-subdomain cookie-allowlist problem
disappears without loosening any production CORS/CSRF policy.

## Tests

- `src/config/__tests__/apiProxyPrefixes.test.ts` guards the proxy
boundary in both directions: representative backend path shapes
(including `/metadata?query=...` and `/auth/...`) must match, every SPA
route from the `AppPath` enum and vite's own dev paths must not — so a
future route collision fails unit tests instead of breaking dev.
- Verified against running dev servers: API paths proxy to the backend
from both `localhost:3001` and `apple.localhost:3001`, while SPA routes
`/settings` and `/authorize` still serve the vite app; a same-origin
POST from `apple.localhost:3001` goes through with no CORS involvement.
- `lint:diff-with-main` and `typecheck` pass for twenty-front.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2026-08-05 08:08:51 +00:00
Thomas des Francs 8a856a4bce Standardize dragged element feedback (#23772)
## Summary

- make dragged table rows use a consistent background across sticky
cells
- apply one shared opacity treatment to table rows, Kanban cards, and
other dnd-kit feedback

## Before/After

<img width="1400" height="1980" alt="drag-feedback-before-after"
src="https://github.com/user-attachments/assets/ee1fe81f-a762-4343-8db6-c51cf63dbac8"
/>
2026-08-05 07:35:46 +00:00
blockgroot 0f35b5895a fix(workflow): reject if-else branches with a dangling filterGroupId (#23758)
## Summary

An If/Else workflow step branch whose `filterGroupId` doesn't resolve to
any entry in
`stepFilterGroups` was matching unconditionally — before any of the
step's real conditions
were evaluated — instead of being rejected. This let a single stale or
mistyped
`filterGroupId` silently hijack the routing of an entire If/Else step.

Fixes #23754

## Problem

Reproduced with a standalone unit test against `findMatchingBranch`:

```ts
const branches = [
  { id: 'branch-A', filterGroupId: 'group-id-that-does-not-exist', nextStepIds: ['wrong-step'] },
  { id: 'branch-B', filterGroupId: 'real-group', nextStepIds: ['correct-step'] },
];
const stepFilterGroups = [{ id: 'real-group', logicalOperator: 'AND' }];
const resolvedFilters = [{ /* branch-B's real filter, evaluates to false */ }];

findMatchingBranch({ branches, stepFilterGroups, resolvedFilters }).id;
// => 'branch-A'  (its condition was never evaluated at all)
```

`branch-A` wins even though its `filterGroupId` doesn't exist and
`branch-B`'s actual
(non-matching) filter was correctly evaluated to `false`.

## Root cause

`find-matching-branch.util.ts` builds `branchFilterGroups` via
`collectAllDescendantGroups(branch.filterGroupId, stepFilterGroups)`,
which silently
returns an empty `Set` when the root id isn't found. The resulting empty
`branchFilterGroups`/`branchFilters` are passed to
`evaluateFilterConditions`, which treats
"both empty" as vacuously `true` — a rule that's correct for the real
trailing else-branch
(no `filterGroupId` at all, by design) but indistinguishable, at this
call site, from "the
referenced group doesn't exist." Since `Array.prototype.find` returns
the first match, this
branch wins over any later branch whose condition was actually
evaluated.

There was also no validation path that would catch this before
execution:
`validateBranchingStep` (`validate-workflow-graph.util.ts`) already
checks If/Else branch
count and `nextStepIds` connectivity, but had no check for
`filterGroupId` referential
integrity.

## Fix

1. `find-matching-branch.util.ts` — throw
`WorkflowStepExecutorException`
(`INVALID_STEP_INPUT`) when a branch's `filterGroupId` doesn't resolve
to any group,
instead of silently falling through to
`evaluateFilterConditions({filterGroups: [],
filters: []})`. This mirrors the sibling guard clauses already in this
action for other
   malformed-input cases.
2. `validate-workflow-graph.util.ts` — extended the existing `IF_ELSE`
branch checks in
   `validateBranchingStep` with the same check, surfaced as a new
`IF_ELSE_BRANCH_FILTER_GROUP_NOT_FOUND` issue code, so
`validate_workflow` catches this
   before a workflow ever runs.

**Alternative considered:** fixing only at validation time. Rejected —
validation can be
skipped (e.g. the AI workflow-editing tool's `validate: false` option)
or bypassed entirely
by a direct API write, so the execution-time guard is the actual fix;
the validation check
is defense in depth, not a substitute.

**Alternative considered:** silently skipping the malformed branch
instead of throwing.
Rejected — throwing immediately gives a specific, actionable error
pointing at the exact
misconfiguration, matching this file's existing error granularity
(distinct messages for
"not an if-else step", "no branches", "missing filter groups/filters",
"no matching
branch").

## Tests

- `find-matching-branch.util.spec.ts` (new) — real-condition match,
else-branch fallback
match, throws on a dangling `filterGroupId` (fails on `main`, passes
here), throws when no
  branch matches and there's no else branch.
- `validate-workflow-graph.util.test.ts` (+2) — flags
`IF_ELSE_BRANCH_FILTER_GROUP_NOT_FOUND`
for a dangling reference; does not false-positive on a
correctly-configured branch.
- Full module suites: `npx nx test twenty-server` scoped to
`src/modules/workflow` →
68 suites / 626 tests passed. `npx jest
packages/twenty-shared/src/workflow` → 30 suites /
  248 tests passed.
- `npx nx lint twenty-server twenty-shared` and `npx nx typecheck
twenty-server
  twenty-shared` → clean.

## Compatibility / risk

Internal-only change to workflow execution and validation logic — no
GraphQL schema
change, no public API signature change, no migration. A workflow that
today relies
(accidentally) on the silent "dangling group = always match" behavior
would start throwing
at execution time, but that was never intentional or documented
behavior.

## Out of scope

- Branch **ordering** invariants (e.g. asserting the group-less else
branch is always
last) — not needed for this fix; the defect reproduces purely from a
dangling
  `filterGroupId`, independent of order.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23758?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: Thomas Trompette <thomas.trompette@sfr.fr>
2026-08-04 16:04:37 +00:00
Thomas Trompette 29aa6e85d6 fix(front): only show Discard Draft when workflow has a published version (#23756)
## Problem

Draft workflows expose a **Discard Draft** action, but it fails when the
draft is the *only* version of the workflow. The backend refuses the
delete with `The initial version of a workflow can not be deleted`
(guard in `validateWorkflowVersionForDeleteOne`), yet the action was
still shown.

The display condition and the delete-guard disagreed:

- Display condition: `every(selectedRecords, "versions.length")` ->
truthy when there is **at least one** version.
- Delete guard: forbids deletion unless **another** non-deleted version
exists.

So a workflow whose only version is a draft showed the button, and
clicking it hit a `FORBIDDEN` error.

## Fix

Show the action only when the workflow has a published version to fall
back to, which mirrors the backend guard:

```
every(selectedRecords, "lastPublishedVersionId")
  and everyEquals(selectedRecords, "currentVersion.status", "DRAFT")
  and noneDefined(selectedRecords, "deletedAt")
```

`lastPublishedVersionId` is a plain scalar already on the workflow.
`every` (truthiness) is used rather than `everyDefined` because the
field is an empty string for never-published workflows, and
`everyDefined` would treat `""` as present. The command-menu evaluator
reads records straight from the store, and the index/table view only
fetches visible columns, so the enrichment provider now also backfills
`lastPublishedVersionId` (already fetched by
`useWorkflowsWithCurrentVersions`) to keep the condition reliable
outside the record show page.

## Existing workspaces

The standard-application full sync only runs at workspace creation, so
editing the constant alone would fix new workspaces but leave existing
ones showing the broken button. A `2.27.0` workspace upgrade command
re-syncs the `discardDraftWorkflow` availability expression for existing
workspaces, updating it only when it still equals the legacy
`versions.length` value (so custom expressions are left untouched).
Mirrors the existing 2-23 command-menu-item sync pattern.

## Notes

- The `>`-style "more than one version" comparison is not expressible in
the current `conditionalAvailabilityExpression` grammar (comparison
operators only reach top-level scalars like `numberOfSelectedRecords`,
not per-record paths). Gating on `lastPublishedVersionId` achieves the
same intent without adding a parser helper.

## Test

- Fresh workflow (single draft) -> Discard Draft hidden.
- Publish, then edit to create a new draft -> Discard Draft shown and
works.
- Unit test on the sync-operations builder: updates the legacy
expression, no-ops when already synced / custom / missing.
2026-08-04 15:48:06 +00:00
Paul Rastoin 3833015626 Ignore expired invitations in invitation lookups (#23749)
## What

Invitation lookups did not filter on `expiresAt`, so expired invitations
were still treated as active. This aligns them with the sibling
`findInvitationsByEmail`, which already applied that filter.

- `WorkspaceInvitationService.getOneWorkspaceInvitation` - added
`deletedAt IS NULL` and `expiresAt > now` (also converted to a typed
`findOne` so the column references are checked).
- `AuthService.findInvitationForSignInUp` - added `expiresAt > now` (it
already filtered `deletedAt`).
- `throwIfOnboardingInvitationLimitReached` - expired tokens no longer
count toward the onboarding invitation limit.
- `createWorkspaceInvitation` - deletes the expired token for that email
before issuing a replacement, so re-invites don't accumulate stale rows.

## Why

Without the filter, an expired pending invitation behaved as if it were
still active:

- On sign-up with a personal invite token, an expired invitation still
granted access to the workspace.
- Re-inviting an email whose invitation had lapsed reported
`INVITATION_ALREADY_EXIST` instead of sending a fresh invite.
- Expired onboarding invitations still consumed quota, so the limit
could be hit by invitations nobody could use.

Once expired tokens are ignored on read, a re-invite would leave the old
row behind, so `createWorkspaceInvitation` now removes it. The delete is
scoped to the same workspace, invitation token types, that exact email,
and `expiresAt <= now`, so it can only remove tokens that are already
unusable.

Closes twentyhq/private-issues#503

## Tests

Integration suites added, run against a real database:

- `auth/sign-up/failing-sign-up-with-expired-invitation` - expired
personal invitation is rejected (snapshot asserts the specific
`FORBIDDEN` error).
- `auth/sign-up/successful-sign-up-with-valid-invitation` - positive
control: a valid invitation still grants access, so the rejection above
cannot pass for an unrelated reason.
- `expired-workspace-invitation` - re-invite over an expired invitation
succeeds and leaves exactly one (fresh) token; a valid invitation is
still reported as already existing.

Each assertion was verified to fail when its corresponding filter is
removed. Unit tests (`workspace-invitation.service.spec.ts`,
`auth.service.spec.ts`), typecheck, and lint all pass.

Not included: invitations that expire and are never re-invited still
linger, since no cron reaps invitation tokens today.
2026-08-04 15:35:41 +00:00
Félix Malfait be051c8724 Keep the token pair as a fallback after switching to cookie auth (#23755)
`CookieSessionBootEffect` cleared the token pair the moment it switched
a client onto cookie auth. That leaves the client with a single
credential, and a server that still has
`AUTH_COOKIE_SESSIONS_ENABLED=false` ignores the session cookie entirely
— `extractSessionTokenFromRequest` early-returns when the flag is off. A
cookie-only client is therefore unauthenticated against such a server,
`handleTokenRenewal` finds no refresh token, and
`onUnauthenticatedError` signs the user out.

That is not a hypothetical state. It is every request routed to a
not-yet-rolled pod while the flag is being enabled, and every request
after the flag is rolled back. Requests are load-balanced per request,
so a migrated client hits an old pod almost immediately and gets signed
out; signing back in can migrate it again and repeat for the length of
the rollout.

It also means rollback was not free, contrary to how it was described:
flipping the flag back to `false` signed out everyone who had already
migrated, because the pair they were supposed to fall back to had been
deleted.

## Approach

Keep the token pair as a dormant fallback, and stop *sending* it while
cookie auth is active.

Both halves are needed. Retaining it without suppressing the header
would be worse than the bug: `validateTokenByRequest` checks the Bearer
token first and only falls back to the session cookie when there is
none, so a client that keeps sending Bearer would never exercise the
cookie at all, and `CookieSessionCsrfMiddleware` bypasses on any
Bearer-carrying request. Cookie sessions would silently become a no-op.

So:

- `switchToCookieAuth` no longer nulls the token pair
- the auth link omits `authorization` while cookie auth is active,
leaving the cookie as the credential in use
- on an unauthenticated error while cookie auth is active, the client
deactivates cookie auth once per operation and falls through to the
existing renewal path, which replays with a fresh Bearer

The fallback deliberately goes through renewal rather than replaying
immediately: access tokens live 10 minutes, so the retained one has
usually expired while the client was authenticating by cookie, and an
immediate replay would just fail again.

`isCookieAuthActive` is read and written through `localStorage` from the
link because the links run per request and must agree with the atom
synchronously — a React state update lands a render too late to affect
the request being built.

## Follow-up

This trades the immediate removal of the token pair from `localStorage`
for rollout safety, so the XSS-exfiltration surface that cookie sessions
close stays open a while longer. Once cookie sessions are stable across
every environment, the retained pair should be dropped — reverting to a
clear on `switchToCookieAuth` is a one-line change.

## Test

Three cases added to `apollo.factory.test.ts`: no Bearer header while
cookie auth is active; an unauthenticated response falls back and
replays with the token pair rather than calling
`onUnauthenticatedError`; and the fallback is attempted only once before
going through renewal. The existing `CookieSessionBootEffect` assertion
that the pair is cleared is inverted to assert it is retained.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23755?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-04 17:12:51 +02:00
Thomas Trompette 0379b537dd feat(workflow): repair orphan core workflow versions via upgrade command (#23739)
## Context

Part of the workflow + workflowVersion in core migration. Before
enabling `IS_WORKFLOW_VERSION_IN_CORE_ENABLED` on real workspaces,
legacy drift between the workspace source-of-truth and the core mirror
must be repaired. The dangerous category is orphan **ACTIVE** core
`workflowVersion` rows: once dispatch reads core (later step), they
become phantom triggers.

## The problem

Some workspaces carry orphan `core."workflowVersion"` rows: core
versions that no workspace version references via
`coreWorkflowVersionId`. On one production workspace this was 57 rows, 5
of them ACTIVE `DATABASE_EVENT`.

Root cause is pre-2.25 residue:
- The v2.22 `backfill-workflow-version-core-links` command minted a core
row per active workspace version, copying `status` verbatim.
- The workflow delete/destroy cascade did not clean core until #23356
(first in v2.25.0): there was no `deleteCoreVersionsByWorkflowIds` and
the deactivate-on-delete status flip was not mirrored.
- Workflows deleted then destroyed in that window left their core rows
behind, still ACTIVE, with every workspace referrer gone.

Current code (>= v2.25) cleans core transactionally on delete/destroy,
so this cannot recur. This command clears the historical residue.

## What this does

A `@RegisteredWorkspaceCommand('2.28.0')` that, per provisioned
workspace:
- Deletes `core."workflowVersion"` rows with no workspace referrer, then
`invalidateAndRecompute`s the automated trigger map.
- `NOT EXISTS` does not filter `deletedAt`, so a soft-deleted
(restorable) workspace version still protects its core row.
- Scoped to `applicationId = workspaceCustomApplicationId OR NULL`, so
future app-owned core versions are never touched.
- Supports `--dry-run` and is idempotent (re-run is a no-op).

## Testing

Run through the real upgrade harness on a dev instance:
- Injected 2 synthetic orphans (1 ACTIVE `DATABASE_EVENT`, 1 ARCHIVED).
`--dry-run` reported `Would delete 2 (1 ACTIVE)`; real run reported
`Deleted 2 (1 ACTIVE)`.
- The 5 legit linked core versions were untouched (referrer guard
verified). Orphans remaining: 0.
- Re-run logged `No orphan core workflowVersion rows` (idempotent).
- typecheck, oxlint, oxfmt all clean.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23739?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-04 14:55:09 +00:00
Charles Bochet 1ebcbdda42 feat(server): export event-loop delay and workspace-cache recompute metrics (#23751)
## What

Exports three sets of metrics to Prometheus to diagnose the recurring
"Slow DB Query" Sentry issues on `POST /graphql` (e.g. the
`fieldMetadata` select):

- `twenty_nodejs_eventloop_delay_seconds` (mean/p50/p99/max) +
`twenty_nodejs_eventloop_utilization`
- `twenty_workspace_cache_recompute_duration_seconds{cache_key}` — wall
time per provider `computeForCache`
- `twenty_workspace_cache_redis_write_duration_seconds` — serialize +
Redis write time for recomputed entries

## Why

Investigation of these issues showed:
- The flagged query executes in ~1ms (prod EXPLAIN), so it is not a
query/index problem.
- Event counts do **not** correlate with connection-pool acquire latency
(Pearson ~0 against both p99 and the direct count of >1s acquires), so
it is not pool contention.
- Event counts **do** correlate with pod CPU (Pearson +0.40).

The leading explanation is that the slow `db` span is inflated by
event-loop saturation during the workspace metadata cache recompute:
`Promise.all` parallelizes the I/O, but the synchronous work it cannot
parallelize (TypeORM entity hydration of JSONB-heavy result sets, then
`JSON.stringify` of the flat-map payloads into Redis) blocks the single
event-loop thread, so an awaiting query resolves ~1.3s late.

Node event-loop delay was only being collected by Sentry's
`nodeRuntimeMetricsIntegration`, never exported to Prometheus, so it
could not be graphed or correlated in Grafana. These metrics confirm (or
refute) the mechanism and give a before/after baseline for the fix.
Grafana panels land in a companion twenty-infra PR.

## Notes

- Metric-only change; no behavioral change to the cache.
- Uses the same OTel `MetricsService` / meter as the existing DB pool
metrics.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23751?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-04 16:50:50 +02:00
nitin 6e1c710a7d Fan out Fireflies backfill into batched jobs (#23622)
Backfills Fireflies calls missed by webhooks as a fan-out of enqueued
batch jobs.

- `POST /fireflies/backfill { days }` (capped at 3650) validates the
request, enqueues a background discovery worker, and returns immediately
with `{ outcome: 'started' }`.
- The discovery worker and the daily 03:00 healer (fixed seven-day
window) list Fireflies transcript ids for the window, split them into
batches of 20, and enqueue one import job per batch. Discovery reports
transcript, batch, and successfully enqueued batch counts, including
partial enqueue failures.
- Listing is bounded to 2,000 pages. Batch delays are staggered by 60
seconds and capped at the queue's seven-day scheduling horizon.
- Each batch job syncs only its explicit id list, importing only missing
transcript/summary fields; already-synced calls short-circuit.
- Batch jobs use `retryLimit: 2`; a Fireflies 429/5xx fails the job so
the queue retries it. Calls within a batch stay paced one second apart.

Supersedes #23623.

---------

Co-authored-by: martmull <martmull@hotmail.fr>
2026-08-04 12:18:49 +00:00
Raphaël Bosi 167d684a29 Run the people-data-labs spec suite in CI (#23743)
The PDL app's unit vitest config only matched `src/**/*.test.ts`, but
the app's test files were named `*.spec.ts`, so CI ran a single file and
368 tests never executed.

Renamed the 88 spec files to `*.test.ts`, the convention every other
public app and the `create-twenty-app` scaffold already use, which
leaves `vitest.unit.config.ts` byte-identical to the other apps.
`capitalize-name` had both a spec and a test file covering the same
function, so the more thorough one was kept.

Turning the suite on surfaced one real failure: `collectUuids` in the
select-option test scooped up the `path` strings added to
`PDL_LOGIC_FUNCTION_CONSTANTS` and asserted they were v4 UUIDs. It now
stops at any object with a `universalIdentifier` and collects only that
value.

`yarn test:unit` is green at 88 files / 368 tests, with `yarn typecheck`
and `yarn lint` clean.
2026-08-04 12:01:40 +00:00
Félix Malfait 72e2301697 fix(settings): unbreak the AI tools table and validate page-level graphql documents (#23731)
## Problem

Opening Settings > AI (tools tab) fails with a misleading error:

```
{"errors":[{"message":"App version mismatch.","extensions":{"code":"APP_VERSION_MISMATCH"}}]}
```

The real error is a GraphQL validation failure. Both queries on that
page select a `logo` field that no longer exists:

- `FindManyApplicationsForToolTable` selects `Application.logo`
- `FindManyMarketplaceAppsForToolTable` selects `MarketplaceApp.logo`

#23411 renamed `MarketplaceApp.logo` to `logoUrl` and stopped exposing
`logo` on `Application`, but missed these two queries (added in #21121)
and the types/component behind them. So the schema exposes `logoUrl`
while the AI settings page still asks for `logo`.

The reason the error says "App version mismatch" is that
`useGraphQLErrorHandlerHook.onValidate` does not return validation
errors as-is: when a document fails validation and the request's
`x-app-version` is semver-lower than the server's `APP_VERSION`, it
throws `APP_VERSION_MISMATCH` instead. On an instance where the frontend
build trails the backend, every genuine query bug on that instance
surfaces as this message, and refreshing never helps because the query
is wrong in the code.

## Why nothing caught it

Two gaps lined up:

1. **The documents were invisible to codegen.** `codegen-metadata.cjs`
lists documents as an explicit allow-list of
`./src/modules/*/graphql/**` entries; nothing under `./src/pages/**` was
ever in it. Codegen validates every matched document against the live
schema and CI fails on drift, so the rename would have been caught had
these two queries been in the matched set.
2. **The response types were hand-written.**
`SettingsAgentToolApplication` / `SettingsAgentToolMarketplaceApp` were
free-standing object types declaring `logo?: string | null`, passed as
the `useQuery` generic. Nothing tied them to the schema, so they kept
compiling after the field was gone.

## Changes

Fix:

- Select `logoUrl` instead of `logo` in
`findManyApplicationsForToolTable` and
`findManyMarketplaceAppsForToolTable`.

Prevention:

- Add `./src/pages/**/graphql/**/*.{ts,tsx}` to `codegen-metadata.cjs`,
so page-level documents are validated by the CI codegen check and get
generated operation types.
- Add three more previously-unvalidated metadata modules to the same
list: `metadata-store`, `sse-db-event`, `geo-map`.
- Derive `SettingsAgentToolApplication` /
`SettingsAgentToolMarketplaceApp` from the generated operation types
instead of hand-writing them.
- Type `SettingsToolIcon`'s `ApplicationInfo` / `MarketplaceAppInfo` as
`Pick` of those, so a field disappearing from the schema is a compile
error rather than a silently-optional property.
- Regenerate `generated-metadata/graphql.ts` (additive: operation types
+ typed document nodes for the five newly covered documents).

App logos in the tools table also render again, which they hadn't since
#23411.

## Verification

Swept every file containing a `gql` document in twenty-front (484)
against the document globs of all three codegen configs. 65 are
uncovered, most legitimately so (runtime-generated record queries,
mocks, tests, stories). The static documents no config validated were
the two fixed here (broken), `metadata-store` / `sse-db-event` /
`geo-map` (valid, now covered), and `information-banner` /
`settings/legal` (valid, core schema — left alone since `codegen.cjs`
targets a schema that can't be validated offline; worth a follow-up).

Validated the fixed documents against the checked-in metadata SDL, and
reproduced `generated-metadata/graphql.ts` with the pinned codegen
toolchain to confirm the regenerated file matches what CI produces. CI's
own codegen check (`server-validation`) then confirmed it against a live
server, alongside front typecheck, lint, jest and builds.

## Follow-up, not in this PR

The error masking in `use-graphql-error-handler.hook.ts` is worth
revisiting:

- It discards the original validation errors, so a real query bug is
unreportable on any deployment where the frontend trails the backend.
Attaching the underlying errors (or at least logging them) would have
made this a one-minute diagnosis.
- The `x-schema-version` branch above it is dead: no client in the repo
sends that header.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01FuH2fAvLct7NvTUEnAWPSV)_
2026-08-04 13:54:04 +02:00
Félix Malfait 55707868cd Add IS_FEATURE_FLAG_MANAGEMENT_ENABLED to unlock feature flag toggling outside cloud (#23750)
The admin panel has a per-workspace Feature Flags tab that can toggle
any key in `FeatureFlagKey`, but it was hidden unless `NODE_ENV` was
`development` or billing was enabled:

```ts
canManageFeatureFlags:
  this.twentyConfigService.get('NODE_ENV') === NodeEnvironment.DEVELOPMENT || isBillingEnabled,
```

Preview apps run `NODE_ENV=production` with billing off, so the tab
disappears and there is no way to flip a flag on a `trycloudflare.com`
app short of editing `core.featureFlag` by hand.

This is purely a client-side gate. `updateWorkspaceFeatureFlag` is
guarded server-side by `AdminPanelGuard` (`canAccessFullAdminPanel`) and
has no billing or cloud check, so unhiding the tab does not widen what
the server accepts. The gate has to be coarse because `/client-config`
is a public unauthenticated endpoint and cannot carry per-user state,
which is presumably why it ended up keyed on `NODE_ENV`/billing.

## Changes

- New `IS_FEATURE_FLAG_MANAGEMENT_ENABLED` config variable
(`ADVANCED_SETTINGS`), defaulting to `false`.
- `canManageFeatureFlags` now also honours it. Development mode and
billing-enabled instances behave exactly as before.

## Notes

- Deliberately not added to `docker-compose.yml` or `.env.example`, and
not documented in `feature-flags.mdx`. Anyone who needs it can set the
env var directly.
- `twentyhq/ci-public#3` sets it for preview apps by patching the
variable into the server service and the generated `.env`, so it does
not depend on the compose file carrying the entry.
- The seeded dev users already have `canAccessFullAdminPanel: true`, and
on a non-seeded instance the first user to sign up is granted it, so the
tab is reachable once the variable is on.

## Test

`client-config.service.spec.ts` covers the new variable unlocking
management in production with billing off, alongside the existing cases
for development mode, billing enabled, and both off.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23750?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-04 13:48:36 +02:00
github-actions[bot] d8cb7cfb55 i18n - translations (#23748)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23748?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-04 13:21:14 +02:00
Charles Bochet 4b3614b413 fix(front): show relation value chip (Me / record names) in advanced filters (#23718)
## Problem

In advanced filters, a relation filter on a workspace-member field (e.g.
Assignee "is Me") displayed its raw JSON value
`{"isCurrentWorkspaceMemberSelected":true,...}` instead of a readable
chip.

Regular (non-advanced) filters handle this correctly:
`EditableRelationFilterChip` computes the label at runtime via
`useComputeRecordRelationFilterLabelValue`, rendering "Me", the selected
record names, or "N members".

The advanced filter value input instead relied on the deprecated stored
`displayValue` through `getRecordFilterDisplayValue`, which has no
`RELATION` branch and falls back to the raw value. When a saved view
filter carries no `displayValue` (it defaults to the raw stringified
value in `mapViewFiltersToFilters`), the raw JSON leaked into the UI.

## Fix

- Extract the relation value-label computation into a shared hook
`useComputeRecordRelationFilterDisplayValue` (parses the relation value,
resolves "Me" + record names).
- `useComputeRecordRelationFilterLabelValue` now consumes it (regular
chips unchanged).
- The advanced filter clickable select renders a dedicated
`AdvancedFilterRelationValueInputClickableSelect` for `RELATION`
filters, computing the label at runtime just like regular filters.

## Proof

Both filter surfaces render the relation value as **Me**, not the raw
`{"isCurrentWorkspaceMemberSelected":...}` JSON. The advanced-filter
shot loads a **saved view in a fresh session** — the exact bug
condition, where the view filter carries no stored `displayValue`.

**Regular filter chip**

<img width="1280" height="760" alt="image"
src="https://github.com/user-attachments/assets/8a867f51-a538-46f2-ba21-a16bb70d85a5"
/>

**Advanced filter**

<img width="1280" height="760" alt="image"
src="https://github.com/user-attachments/assets/b09b9d70-5692-4bac-8cec-3cb006961042"
/>

## Test

Verified manually on a local instance: created a saved view with an
advanced filter `Account Owner Is Me`, then reloaded it in a fresh
session — the condition where the view filter carries no stored
`displayValue`. The value renders as "Me" instead of the raw JSON.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23718?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-04 11:11:53 +00:00
Raphaël Bosi 453f3479ab Accept a singleton or filter in the GraphQL filter walker (#23738)
`RecordGqlOperationFilter` types `or` as `RecordGqlOperationFilter[] |
RecordGqlOperationFilter`, so both `{ or: [{ name: { ilike: '%acme%' }
}] }` and `{ or: { name: { ilike: '%acme%' } } }` are valid.
`applyLogicalGroup` went straight to `filters.forEach(...)`, so the
non-array form threw `TypeError: filters.forEach is not a function`
instead of returning records. Only `or` is affected: `and` is always an
array and `not` is always a single object.

This is not a new bug. The same assumption existed before the walker was
extracted, when `parseKeyFilter` did the `value.forEach` inline. Sentry
surfaced it on #23369, and it was left out of that PR to keep it scoped.

The fix mirrors `renderLogicalGroup` in the RLS SQL renderer, which
already normalizes a singleton to an array on its first line, so the two
walkers over this filter format now accept the same shapes.
2026-08-04 11:06:59 +00:00
github-actions[bot] 0408816781 i18n - translations (#23746)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23746?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-04 12:32:22 +02:00
neo773 8e5bdcc781 webhook subscriptions error handling (#23707)
A `subscriptionRemoved` lifecycle notification was routed to
`renewSubscription`, which PATCHes a subscription Microsoft has already
deleted and always 404s
([TWENTY-SERVER-J1N](https://twenty-v7.sentry.io/issues/7604034376/),
884 events). Every sampled webhook event on that issue was
`subscriptionRemoved`.

Each lifecycle event now gets its own path: `subscriptionRemoved`
recreates and resyncs the gap, `reauthorizationRequired` renews in
place, `missed` resyncs, unrecognised events are logged and ignored.
Provider errors are parsed into driver exception codes following the
message-import drivers.

Max retry for the renewal cron is deliberately left out and will follow
separately.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23707?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-04 10:23:52 +00:00
Paul Rastoin 997b2c38de Add cookie-session integration test suite (#23715)
Stacked on #23642. Integration suite for the cookie-session surface,
organized as one successful/failing spec pair per stage of the session
lifecycle. 14 spec files, ~36 tests, all over real HTTP against the
booted app.

## Coverage by stage

**1. Session creation on auth exchanges**
(`successful-`/`failing-session-creation`)
Flag gating (default off: tokens, no cookie, no row); httpOnly cookie
snapshot with 180d expiry window; SHA-256 hash-at-rest with the row
bound to the apple seed workspace; scripted sign-ins without an Origin
header still get the cookie; login-CSRF refuses the cookie for
disallowed origins while returning the token pair; sign-in over an
existing session revokes it as `SUPERSEDED`; a failed credentials
exchange mints nothing.

**2. Cookie delivery** (`successful-session-cookie-delivery`,
`secure-deployment-session-cookie`)
The runtime side door (`AUTH_COOKIE_SAME_SITE=none` forces the secure
path) pins the `__Host-`/`Secure`/`SameSite=None` variant in the default
CI run. The exact production combination (`__Host-`, `Secure`,
`SameSite=Lax`) is covered by a dedicated spec that requires the app to
boot with an https `SERVER_URL`: the secure branch is decided by config,
never the transport, so no TLS is needed. It skips itself on plain-http
boots; CI runs it as an extra step on one shard with
`SERVER_URL=https://localhost:3000`, including the `__Host-` round-trip
and the plain-cookie-name downgrade refusal.

**3. Per-request authentication and the CSRF read gate**
(`successful-`/`failing-session-cookie-authentication`)
A cookie-only request resolves the seeded user; a `sess_` token
presented as Bearer is rejected; cookie-authenticated unsafe requests
with a disallowed or missing Origin get 403 `CSRF_ORIGIN_MISMATCH`; an
unknown session token is unauthenticated and its dead cookie is cleared.

**3b. Workspace binding** (`successful-session-workspace-binding`)
Tim signs into both seeded workspaces (apple and yc); each session row
is bound to the workspace its exchange selected (`workspaceId` and
`userWorkspaceId` pinned to the seed ids), and each cookie resolves to
its own workspace context, with no request-side input able to pivot a
session across workspaces.

**3c. Credentialed CORS** (`cors-credentialed-origins`)
Allowlisted origins get the reflected `Access-Control-Allow-Origin` plus
`Access-Control-Allow-Credentials: true` and `Vary: Origin`, preflight
included; other origins keep the public wildcard. See tooling notes:
this surface was previously untestable.

**4. Sessions API** (`successful-`/`failing-user-sessions-api`)
`currentUserSessions` marks exactly the presented session as current;
`revokeUserSession` revokes by id (`USER_REVOKED`) and drops it from the
listing; `revokeAllOtherUserSessions` spares the presented session;
cross-user revocation and unauthenticated listing are refused.

**5. Exits** (`successful-sign-out`, `failing-session-expiration`)
`signOut` revokes with `USER_SIGN_OUT`, clears the cookie, and reuse
fails immediately (cache invalidated, not TTL-bound); a cookie-less
sign-out clears nothing, so a cross-site POST cannot log a visitor out;
absolute-lifetime and idle-timeout expiry both reject and clear the
cookie.

**7. Cleanup cron** (`user-session-cleanup-cron`)
Both halves run in-process against fixtures spanning the 30d retention
boundary. Sessions: expired/revoked-beyond-retention deleted; active,
recently-expired, and idle-expired rows survive (the idle case pins the
known predicate gap). Refresh tokens: old-expired and old-revoked
deleted, fresh kept, and a long-expired token of another type survives,
pinning the `type` filter that keeps the shared `appToken` table safe
from the hard-delete.

Not covered here by design: the impersonation park/restore sub-funnel
(stage 6, follow-up) and the client-side funnel (stage 8, front-end
scope). Password-change revocation and the renewal bridge are also left
to follow-ups.

## How the flag is flipped

`AUTH_COOKIE_SESSIONS_ENABLED` (and `AUTH_COOKIE_SAME_SITE` for the
secure side door) are toggled at runtime through the admin panel config
API, reusing the `twenty-config` test utils: `DatabaseConfigDriver.set`
updates its cache synchronously and `TwentyConfigService` consults the
DB driver before the env driver. No `.env.test` change, no app reboot,
runs in the default CI environment without the `ci:auth-cookie-sessions`
label. `SERVER_URL` is env-only, hence the dedicated CI step for the
production secure-deployment spec.

## Shared tooling changes

- **`applyCredentialedCors` extraction (src change)**: the integration
harness booted with Nest's wildcard `cors: true`, not the
credentialed-allowlist setup living in `main.ts`, so the CORS surface
was untestable by construction. The setup moved into
`applyCredentialedCors`, now called by both the production bootstrap and
`createApp`, making the harness's CORS behavior the deployed one.
Behavior-neutral for production.
- `makeMetadataAPIRequest` accepts an explicit `null` token for
unauthenticated requests. Passing `undefined` silently fell back to the
default admin token (parameter defaults apply to `undefined`), which
made supposedly public requests Bearer-authenticated, bypassing both the
cookie auth path and the CSRF middleware. Existing call sites are
unaffected.
- The `GetLoginTokenFromCredentials` / `GetAuthTokensFromLoginToken`
documents moved into shared query factories; the workspace-origin
builder is extracted and generalized to any seeded subdomain
(`buildWorkspaceOriginForSubdomain`, reused by
`getAccessTokenForCredentials`).
- Suite-local helpers: `signInWithCookieCapture` (full credentials
exchange returning the raw supertest response, with a
`workspaceSubdomain` option), `postMetadataOperationWithHeaders`
(Origin/Cookie header control), cookie extraction for both cookie names,
clearing-cookie detection, snapshot normalization (token and expiry
redacted), and shared `ALLOWED_ORIGIN`/`DISALLOWED_ORIGIN` constants
derived from `FRONTEND_URL`.

Verified locally: full suite green in CI mode on both plain-http and
https-`SERVER_URL` boots; oxlint and tsc clean.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-08-04 10:05:12 +00:00
github-actions[bot] 2db730b65f i18n - translations (#23740)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-04 11:52:40 +02:00
twenty-pr[bot] 3ab6bb7915 chore: bump version to 2.28.0 (#23730)
## 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/23730?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-04 09:47:09 +00:00
Raphaël Bosi a26b507361 Enforce row-level permissions on joined relations (#23369)
Row-level permission predicates were only ever applied to a query's main
alias, so any SQL join leaked rows the caller is not allowed to see. The
visible symptom: dashboard charts grouped by a relation field (e.g.
Opportunities by Company) read the group dimension off an unfiltered
joined table, surfacing hidden companies as chart labels.

`WorkspaceSelectQueryBuilder` now applies the joined object's predicate
to every relation join's `ON` condition. Using `ON` rather than `WHERE`
keeps left-join semantics correct: a visible record linked to a hidden
related row is still counted, it just falls into the null group instead
of being attributed to the hidden row.

This closes the same class of leak in relation filters and
order-by-on-relation, plus the three paths that serialize a builder via
`.getQuery()` and never reach the execution overrides (group-by with
records, per-parent relation limiting, mutation id subqueries). The
per-parent fix also stops hidden rows from consuming `LIMIT` slots
before being filtered out.

```mermaid
flowchart TD
    A["WorkspaceSelectQueryBuilder<br/>SELECT FROM person LEFT JOIN company"]
    A --> B["getMany / getOne / getCount / execute<br/>(execution overrides)"]
    A --> C["getQuery() serialization:<br/>group-by with records,<br/>per-parent relation limit,<br/>mutation id subquery"]
    B --> D["validatePermissions()"]
    D --> E["applyRowLevelPermissionPredicates<br/>ToMainAliasAndJoinedRelations()"]
    C --> E
    E --> F["main alias:<br/>WHERE person predicate"]
    E --> G["every relation join:<br/>ON person.companyId = company.id<br/>AND company predicate"]
    F --> H["hidden companies never surface as group dimensions,<br/>relation-filter matches or sort keys;<br/>a hidden link sorts as NULL and the row is still counted"]
    G --> H
```

The last two commits remove the duplication this fix would otherwise
have introduced: one shared `and`/`or`/`not` filter walker (the GraphQL
filter parser and the RLS util were verbatim forks), one RLS
record-filter resolver used by all three call sites, and one shared set
of RLS integration-test fixtures. Behaviour-preserving, with new
characterization tests pinning the emitted condition tree.

Reviewer notes:
- Results change where a join is involved: relation filters no longer
match hidden related records, and order-by-on-relation sorts
hidden-linked rows as null, which can shift pagination.
- Joins on subqueries/custom tables are skipped, and objects with no
predicates for the role are a no-op, so admins and system contexts are
unaffected.
- Timeline messaging inner joins are filtered too, so thread counts can
change for restricted roles.
- Predicates that need the current workspace member (Me) are still
skipped for API key and application contexts, on joins as on the main
alias.
- The join renderer skips the field-level read-permission check the
main-alias parser performs: predicates on read-restricted fields still
filter joins, and the field values are never selected.
- One user-facing change beyond the leak fix: the empty-array filter
error no longer echoes the submitted value back (`Invalid filter value:
"<value>"` -> `Invalid filter value`), on every filter path rather than
just RLS. Catalogs are not regenerated here, so it falls back to English
until the next i18n sync.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23369?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-04 09:43:45 +00:00
Thomas des Francs 91b6cbd320 Update website release notes through 2.26 (#23736)
## Summary

- Add weekly, user-facing release notes from 2.1 through 2.26.
- Highlight completed, generally available product features and exclude
Labs or rollout-gated work.
- Update the Releases menu preview to the latest 2.26 entry.

## Before/After

Before: production ends at 2.0. After: the changelog includes weekly
releases through 2.26.

<img width="2268" height="720" alt="before-after"
src="https://github.com/user-attachments/assets/7ebf0154-596a-4846-b148-3d9aeff7cf0e"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23736?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-04 11:16:15 +02:00
Raphaël Bosi 5bafaa0994 Hide onboarding credits when billing is disabled (#23717)
Onboarding advertises free credits (the header pill and the green "Earn
+N free credits" tags) even when `IS_BILLING_ENABLED` is false,
promising a reward that can never be granted: `creditWorkspaceBalance`
already no-ops when billing is off.

The server now omits the `onboarding` credit-rewards block from the
client config when billing is disabled, which hides every reward tag on
its own since they all render behind a defined-config guard. The header
pill gets an explicit gate.

Also stops treating onboarding invites as reward-eligible when billing
is off, so they are minted as plain invitation tokens and the 10-invite
`ONBOARDING_INVITE_TEAM_MAX_INVITES` cap no longer applies to
self-hosted instances.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23717?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-04 08:31:24 +00:00
Raphaël Bosi 6f6da14cc8 Stop showing raw chunk load errors (#23571)
On iOS Safari, a failed chunk load showed a snackbar containing the raw
browser string `Importing a module script failed.`

`PromiseRejectionEffect` snackbars the raw `error.message` of any
unhandled rejection, so a floating dynamic import leaked browser
internals to the user. It now skips the snackbar for stale chunk errors,
which are still captured by Sentry.

This only changes what the user sees, not the underlying fetch failure.
2026-08-04 08:24:59 +00:00
Paul Rastoin 850d3d70fc chore(codeowners): guard .claude, .mcp.json and CLAUDE.md (#23734)
## What

Adds `.claude/`, `.mcp.json` and `CLAUDE.md` to CODEOWNERS.

## Why

These files configure the coding agent that maintainers attach to PRs:
`SessionStart` hooks, MCP servers, and agent instructions. They were
previously outside CODEOWNERS coverage (which only spanned `.github/`
and `.yarnrc.yml`), so a change to any of them could land on `main`
without core-team review.

CODEOWNERS gates merge approval only. It does not affect an unmerged
branch that a session merely checks out, so this closes the "malicious
agent config quietly lands on main" path, not fork-branch execution. It
is one layer, not the whole answer.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23734?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-04 08:11:38 +00:00
Shinu Cherian 4e3747f3a9 fix(twenty-front): expand HTML preview viewport in DocumentViewer (#23668) (#23676)
## Description
Fixes #23668.

When previewing `.html` or `.htm` files in the file preview modal,
`@cyntler/react-doc-viewer` mounts an `iframe` inside `#html-renderer`.

Previously, `StyledDocumentViewerContainer` applied `height: 100%;
width: 100%;` to `#react-doc-viewer`, `#proxy-renderer`, and
`#msdoc-renderer`, but omitted `#html-renderer` and its inner `iframe`.
As a result, the preview iframe defaulted to inline iframe bounds
instead of expanding to fill the modal container.

This PR adds `#html-renderer`, `#html-renderer iframe`, and `iframe`
selectors to `StyledDocumentViewerContainer`, ensuring HTML previews
expand fully within the modal viewport.

## Testing
- Verified StyledDocumentViewerContainer CSS rules target
`#html-renderer` and `iframe` elements.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23676?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-04 06:46:04 +00:00
Félix Malfait 66e0f620bc Default workspaceMember openRecordIn when the workspace is not upgraded yet (#23723)
`WorkspaceMemberDTO.openRecordIn` is `@Field(() => OpenRecordIn, {
nullable: false })`, and the transpiler passed the entity value straight
through. The field is created per workspace by the 2-27 workspace
command `upgrade:2-27:add-workspace-member-open-record-in`, which runs
*after* the code is already serving traffic — the deploy job only runs
instance commands. Until a workspace's turn comes, `openRecordIn` is
`undefined`, GraphQL raises `Cannot return null for non-nullable field
WorkspaceMember.openRecordIn`, `GetCurrentUser` fails outright, and
nobody in that workspace can load the app.

This is not theoretical. On main it broke all 68 live workspaces and
stayed broken for three days: the instance command ran on Jul 31 with
#23614, and `core."upgradeMigration"` had no row for any 2-27 workspace
command until the sequence was run manually today. On prod the window is
however long `upgrade` takes to walk every workspace sequentially.

`SIDE_PANEL` is already the declared `defaultValue` of the standard
field, so behaviour is unchanged once a workspace is upgraded. The same
function already guards `userEmail` this way.

The other write path, `user-workspace.service.ts` inserting
`openRecordIn` on workspace member creation, does not need a guard: the
workspace entity metadata is built per workspace from its own field
metadata, so TypeORM's insert builder omits a property that has no
column rather than failing.

`OpenRecordIn` moves from a type-only to a value import since it is now
referenced at runtime.

## Test

Two cases in a new spec: the value is preserved when present, and falls
back to `SIDE_PANEL` when the workspace has not been upgraded. The
second fails on `main`.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23723?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-04 06:51:53 +02:00
github-actions[bot] e623d6fd88 i18n - docs translations (#23729)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-04 05:39:19 +02:00
github-actions[bot] a042b350f7 i18n - translations (#23728)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-04 04:55:37 +02:00
Abdul Rahman 3a646ffcb0 feat(connections): add an onDisconnect lifecycle hook to connection providers (#23538)
Platform half of the follow-up to
https://github.com/twentyhq/twenty/pull/22984#discussion_r3673946334.
The Slack app claims a `team_id` on connect and had no way to release
it, because connection providers only had an on-connect hook. Nothing
here is Slack-specific, so it targets `main`. The app side is #23540, on
top of `feat/slack-bot`, and waits on this plus an SDK release.

## What changes

`defineConnectionProvider` accepts `onDisconnectLogicFunction` alongside
`onConnectLogicFunction`. It is stored on
`connectionProvider.onDisconnectLogicFunctionUniversalIdentifier` (fast
instance command `2.26.0_...1785350000000`) and enqueued right after the
`ConnectedAccount` row is deleted, in the disconnecting workspace, with
the same payload as on-connect:

```ts
type OnDisconnectPayload = {
  connectionProviderId: string;
  connectionProviderName: string;
  connectedAccountId: string;
};
```

The `ConnectedAccount` is gone by the time the hook runs, so
`getConnection` no longer resolves. Anything the cleanup needs has to be
in the key-value store, written at connect time and keyed by
`connectedAccountId`. The docs section spells that out, along with the
fact that uninstalling an app drops its connections through a cascade
that never reaches this hook, where `uninstallLogicFunction` is the
right tool instead.

Both dispatches moved into a new
`ConnectionProviderLifecycleHookService`, so
`ConnectionProviderOAuthFlowService` no longer owns hook plumbing and
`ConnectedAccountMetadataService.delete` can reuse it. On-connect
behaviour is unchanged: best effort, never blocks the caller, failures
go to Sentry.

## Tests

- `connection-provider-lifecycle-hook.service.spec.ts`: the on-connect
cases moved over, plus on-disconnect dispatch, no-hook, and
missing-provider cases
- `connection-provider-oauth-flow.service.spec.ts`: now asserts
delegation to the lifecycle hook service
- SDK validation, manifest duplicate-identifier, and manifest to flat
converter specs extended

Server unit tests and typecheck for shared, sdk and server pass locally.
2026-08-04 02:47:23 +00:00
Abdul Rahman 5ffa121e59 feat(slack): implement channel welcome message functionality (#23699)
https://github.com/user-attachments/assets/a77aa941-da48-4c30-8e14-587516c19ac4


Added a new feature that allows the bot to introduce itself when added
to a Slack channel. This includes a welcome message and a detailed
thread reply outlining its capabilities. The implementation includes new
utility functions for handling the welcome event, managing welcome
state, and posting messages. Updated relevant logic functions to support
this feature, ensuring the bot can provide a seamless introduction to
users in new channels.

- Introduced `slack-channel-welcome` logic function.
- Added constants for welcome message text.
- Implemented event parsing and handling for `member_joined_channel`.
- Updated `slack-events-resolver` to route welcome events appropriately.
2026-08-03 19:08:40 +00:00
github-actions[bot] 1d367bbc57 i18n - docs translations (#23721)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-03 21:07:03 +02:00
Félix Malfait 116c04d8b2 Record and enforce per-user OAuth application authorizations (#23678)
Sits on `main` now that #23642 has merged. 18 files changed.

## Why

Application tokens are stateless JWTs. When a user completes an OAuth
`authorization_code` exchange, the server issues an access/refresh pair
carrying `userId` as a claim and stores nothing. So today:

- there is no record that a person ever authorized an app, hence nothing
to list on a settings screen
- there is no way for that person to take an app&#39;s access away. The
only revocation that exists is uninstalling the app, which is
workspace-wide and admin-only
- `/oauth/revoke` accepted a refresh token, logged it and did nothing,
because there was no state to change

`client_credentials` is unaffected: no user is involved and it returns
an access token with no refresh token.

## What

**`core."applicationAuthorization"`**, one row per (user, application),
unique on that pair so re-authorizing updates in place. Written at the
`authorization_code` exchange, before the token pair is issued, so a
refresh token is never handed out without the grant that makes it
redeemable.

A dedicated table rather than a new `AppTokenType`: this is a grant
keyed on identity, not a token keyed on a secret, and `appToken` is
already overloaded.

FKs to user, workspace, application and userWorkspace all cascade, which
covers hard deletes. Membership removal soft-deletes the `userWorkspace`
row, so that cascade does not fire and the grant outlives the
membership. The refresh path therefore rechecks membership on every
renewal rather than trusting the row&#39;s existence.

**Enforcement.** `refresh_token` checks the row when the token carries a
user, and returns `invalid_grant` if it is revoked. Revoking does not
kill live access tokens, so access ends within one access-token window
(`APPLICATION_ACCESS_TOKEN_EXPIRES_IN`, 30 minutes) rather than
instantly. The alternative is a DB read on every API request, which is
not worth it for a 30 minute tail; the UI should say so.

**RFC 7009 revocation now revokes.** Revoking a refresh token revokes
the authorization behind it. It also now checks the token was issued to
the client asking, which it never did before. That check did not matter
while revocation was a no-op; it does now.

**Introspection** reports a refresh token inactive once its
authorization is revoked. Access tokens keep reporting active until they
expire, because they genuinely still work.

**API:** `currentUserApplicationAuthorizations` and
`revokeApplicationAuthorization`, both behind `UserAuthGuard`. The
mutation scopes by `userId` inside the `UPDATE` rather than
read-then-write, so one user cannot revoke another&#39;s authorization
by guessing an id.

## Backwards compatibility

Refresh tokens already in the wild have no row. Rejecting them would
sign every live integration out on deploy, so the first refresh
backfills the grant that was always implied. A revoked authorization
keeps its row, so this never resurrects access someone turned off, and
the backfill is insert-only so it cannot overwrite a real consent. If
the user has since left the workspace, the refresh fails instead.

Those tokens carry no scope claim and no record of when consent was
given, so `scopes` and `lastAuthorizedAt` are nullable and left null on
a backfilled row. Null means &#34;the original consent is not on
record&#34; rather than a guess assembled from what the application
declares today; a real re-authorization fills both in. Revoking such a
token lays the row down before marking it, so the revocation sticks
instead of being undone by the next refresh.

## Not in this PR

The settings UI, following how #23643 shipped the sessions API and
#23645 the devices screen.

Introspection still reports a refresh token active once the membership
is gone. That matches access tokens, which genuinely keep working in
that case, so closing it belongs with the wider question of validating
membership on every application-token request.

## Testing

- 29 unit tests across the authorization service and the three OAuth
grant paths
- 9 integration tests on `/oauth/token`, `/oauth/revoke` and the GraphQL
API: scopes as granted are recorded, revoking blocks the next refresh,
re-authorizing reinstates, a pre-record token backfills without
inventing a consent, a revoked pre-record token stays revoked, the
authorization is listed to the user who granted it, revoking from that
list stops the refresh token being redeemed, a repeated revocation
reports no-op, and another user can neither see nor revoke it
- the cross-user isolation and revoke-from-list tests are
mutation-checked: dropping the `userId` scoping from
`revokeAuthorizationById` fails only the isolation test, and disabling
the `revokedAt` check in `oauth.service.ts` fails the revoke-from-list
test plus two pre-existing ones
- full `twenty-server` suite green
- instance command applied against a fresh `database:reset`,
table/index/FK shape verified against `information_schema`

Closes part of https://github.com/twentyhq/core-team-issues/issues/2747

---------

Co-authored-by: prastoin <45004772+prastoin@users.noreply.github.com>
2026-08-03 20:59:28 +02:00
nitin b28fc54b44 Classify Recall no-capture sub codes as NOT_RECORDED in call-recorder app (#23693)
Second part of twentyhq/core-team-issues#2706, following #23478 which
shipped the NOT_RECORDED status and workspace upgrade: the call-recorder
app now classifies benign no-capture outcomes (bot never admitted,
meeting not started, nobody joined) as NOT_RECORDED instead of FAILED.

- Parse status sub codes from Recall webhooks and bot snapshots, and map
no-capture sub codes to NOT_RECORDED with the sub code stored as the
failure reason
- Derive NOT_RECORDED from bot snapshots during sync when the bot
finished without a recording and a no-capture leave is in its history
- Treat NOT_RECORDED as terminal alongside FAILED: no artifact-import
completion, no late-event flips between the two; calendar reconciliation
may reset it to SCHEDULED for upcoming meetings
- Prefer the sub code over the status code in FAILED reasons
- Bump the app to 1.6.0 and require twenty >=2.26.0, where the status
exists

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

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-03 20:02:41 +02:00
Félix Malfait 267ecb12db Migrate web auth from localStorage token pairs to httpOnly cookie sessions (#23642) 2026-08-03 19:54:54 +02:00
Félix Malfait f8e3fd110d Bound an application by its own role as well as the user's (#23680)
## Why

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

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

## What had to change

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

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

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

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

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

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

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

Two smaller fixes fall out:

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

## Behaviour on the edges

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

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

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

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

## Testing

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

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

## Not in this PR

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

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

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

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



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

Fixes #22947.

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

## Root cause

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

## Fix

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

## Verification

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

Fixes #20483.

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

## Root cause

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

## Fix

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

## Testing

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

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

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

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

## Scope / follow-up

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

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

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


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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-03 16:01:05 +02:00