The SSE event stream could silently die from network partitions, NAT
table flushes, browser tab throttling, or server restarts. When this
happened:
1. The `error` callback only called `captureException` — no reconnection
was triggered
2. The `complete` callback was `() => {}` — a cleanly terminated stream
left the client permanently broken
3. No mechanism existed to detect a silently dead connection where no
FIN/RST was received
## Summary
- **Fix `error`/`complete` callbacks**: The `graphql-sse` subscription's
`error` callback only reported to Sentry, and `complete` was a no-op.
Both now set `shouldDestroyEventStreamState = true` to trigger the
destroy-recreate lifecycle, ensuring detected transport failures and
clean stream terminations lead to automatic reconnection.
- **Add server-side keepalive**: The existing heartbeat timer now runs
every 30s (instead of 6min) and publishes empty events through the Redis
pub/sub channel in addition to refreshing the Redis TTL (throttled to
~6min). Unlike GraphQL Yoga's opaque SSE comment pings, these are real
subscription events that flow through the client's `next`/`message`
handlers.
- **Add client-side keepalive monitor (`SSEKeepAliveEffect`)**: Tracks
the timestamp of the last received event. If no event arrives within 90
seconds (3x the keepalive interval), it clears query listeners and
triggers a stream destroy-recreate cycle.
## Test plan
- [x] Start the app, verify SSE events flow normally (workflow runs
update in real-time)
- [x] Leave the app idle for >90 seconds, then trigger a workflow run —
verify the stream auto-reconnects and events are delivered
- [x] Kill the server, restart it, verify the frontend recovers its
event stream
- [x] Verify keepalive events (empty
`objectRecordEventsWithQueryIds`/`metadataEvents`) appear in browser
network tab every ~30s
- [x] Verify no regressions in SSE-dependent features (record updates,
metadata changes, workflow run visualization)
xAI deprecated Live Search, so the `searchParameters` provider option
now returns 410. This routes all xAI models through the Responses API
and binds web/X search as native agent tools, matching how
Anthropic/OpenAI expose search.
- xAI provider now uses `provider.responses()` — its
`webSearch()`/`xSearch()` tools only run against the Responses endpoint,
not chat completions
- web/X search migrated from the `provider-option` variant to `sdk-tool`
(`web_search`/`x_search`); deleted the dead `searchParameters` path, the
`provider-option` variant, and `providerOptions` on `NativeModelBinding`
- dropped a dead `rolePermissionConfig` param on `getAgentRoleId`, left
over from #20331
---------
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <[email protected]>
**i18n** — collapsed the ~22 scattered i18n files into a single module
and turned on Spanish alongside French.
**Sections** — dropped the old compound pattern (`Section.Root`,
`Section.Heading`, …). Reusable layout shells moved to `src/templates/`,
atomic bits stay in `design-system/`, and each page now owns its copy in
local `_components` blocks instead of pulling it out of shared sections.
Data files hold arrays only, no prose.
**Copy → `<Trans>`** — A lot of headings were split across several
`<HeadingPart>`s just for font styling, which meant each piece was a
separate translation string. A translator got "Build your Enterprise
CRM" and "at AI Speed" as two unrelated strings and had no way to
reorder them for their language. Those are now single `<Trans>` units
with placeholders. Same idea for the old `\n` + `white-space: pre-line`
line-break trick: replaced with a small `ResponsiveLineBreak` element so
the break is doesn't quietly rot, and did a dead-code pass.
The de-fragmentation changes the message IDs, so around 60 strings will
fall back to English in fr/es until Crowdin re-syncs.
## Problem
`generateFrontConfig()` writes `window._env_.REACT_APP_SERVER_BASE_URL =
process.env.SERVER_URL` unconditionally. The frontend then pins to that
absolute URL. For self-hosted deployments reachable from multiple
hostnames (Tailscale IP, LAN IP, internal DNS, SSH tunnel to localhost,
public DNS), only the one matching `SERVER_URL` works — others hit CORS
errors or unreachable hosts because the frontend tries to call the API
at the configured URL, not the one the user came in via.
The frontend already supports the right fallback:
`packages/twenty-front/src/config/index.ts:20-21` reads
`window._env_?.REACT_APP_SERVER_BASE_URL` and falls back to
`getDefaultUrl()` (which uses `window.location`) when the env var is
absent. But the server-side `generateFrontConfig` always populates
`_env_`, so the fallback never runs.
## Fix
One file: `packages/twenty-server/src/utils/generate-front-config.ts`.
Add a `FRONT_AUTO_BASE_URL=true` opt-in (also triggered when
`SERVER_URL` is unset entirely). When the toggle is on, inject
`window._env_ = {}` so the frontend's existing `getDefaultUrl()`
fallback resolves the origin from `window.location` at runtime.
## Backwards compatibility
When `SERVER_URL` is set AND `FRONT_AUTO_BASE_URL` is unset (or anything
other than `'true'`): unchanged — `REACT_APP_SERVER_BASE_URL:
process.env.SERVER_URL` is injected exactly as before.
The toggle is strictly additive. Existing single-hostname deployments
are not affected.
## Use case
Self-hosted Twenty reachable via:
- `http://100.115.12.29` over Tailscale
- `http://localhost:4440` over SSH tunnel
- `http://twenty.internal` over LAN DNS
- `http://crm.example.com` public
With `FRONT_AUTO_BASE_URL=true`, all four paths work without rebuilds or
per-hostname server processes.
## Test plan
- [ ] `SERVER_URL=http://x.com` (toggle unset) → `<script>window._env_ =
{"REACT_APP_SERVER_BASE_URL":"http://x.com"};</script>` (unchanged from
main)
- [ ] `SERVER_URL` unset → `<script>window._env_ = {};</script>` (new
fallback path)
- [ ] `SERVER_URL=http://x.com FRONT_AUTO_BASE_URL=true` →
`<script>window._env_ = {};</script>` (toggle wins)
- [ ] `FRONT_AUTO_BASE_URL=false SERVER_URL=http://x.com` → unchanged
(only `'true'` triggers the toggle)
---------
Co-authored-by: martmull <[email protected]>
Add a note to the command menu items docs explaining that
RECORD_SELECTION already guarantees a non-empty selection, so
numberOfSelectedRecords > 0 is redundant in
conditionalAvailabilityExpression.
## Summary
- Fix intermittent failure where the Quick Lead workflow form step did
not auto-open
- Root cause: race conditions between SSE events, Apollo cache writes,
and the `runWorkflowVersion` mutation timing
- Add generic monotonicity guard in the SSE handler that drops stale
updates for all records (not just WorkflowRun)
## Changes
- **`useTriggerOptimisticEffectFromSseUpdateEvents.ts`**: Compare
incoming `updatedAt` with cached record before writing — skip if stale.
Moved `upsertRecordsInStore` after the guard so neither Apollo cache nor
Jotai store receive stale data.
- **`useRunWorkflowVersion.tsx`**: Await mutation before opening side
panel; register SSE listener eagerly before mutation
- **`useWorkflowRun.ts`**: Simplified back to plain `useFindOneRecord` +
schema parse (no extra state needed)
- **`generateWorkflowRunDiagram.ts`**: `shouldOpenStep` matches both
PENDING and RUNNING for form steps (backend RUNNING means "waiting for
user input")
- **`WorkflowRunVisualizerEffect.tsx`**: Pass `runStatus` directly
without status mapping
- **`WorkflowRunStepNodeDetail.tsx`**: Form is interactive when step is
PENDING or RUNNING
- **Deleted `latestWorkflowRunFamilyState.ts`**: No longer needed — the
generic SSE guard replaces it
## Test plan
- [x] Hard refresh, run Quick Lead workflow 10+ times — form should
always auto-open
- [x] Complete the form and verify all subsequent steps execute without
getting stuck
- [x] Verify the workflow diagram is always visible (never disappears)
- [x] Verify other record types still update correctly via SSE (e.g.
edit a person in another tab)
Documents how a headless front component calls a server-side logic
function over HTTP via the /s/ route, so AI agents have a clear
reference for implementing this pattern.
Fixes https://github.com/twentyhq/twenty/issues/21044
## Summary
- Closes [#490](https://github.com/twentyhq/private-issues/issues/490) —
JumpCloud customers (and anyone else whose IdP only advertises
`HTTP-POST` for `SingleSignOnService`) could not upload their SAML
metadata; the parser silently rejected them with a generic `Invalid
file` toast.
- The SAML IdP metadata parser now falls back to `HTTP-POST` when
`HTTP-Redirect` is not advertised. Both are valid SAML 2.0 bindings.
- The parser now returns a descriptive `reason` string (Zod issues +
custom errors) instead of an opaque `error: unknown`, and the upload
snack bar surfaces it so the customer can self-diagnose (e.g. `entityID:
entityID is not a valid URL` if they forgot to fill in their IdP Entity
ID).
- Added unit tests for HTTP-POST-only metadata, HTTP-Redirect
preference, and each descriptive-error path.
## Test plan
- [x] `npx jest parseSAMLMetadataFromXMLFile
--config=packages/twenty-front/jest.config.mjs` — 8/8 pass
- [x] `npx oxlint -c packages/twenty-front/.oxlintrc.json` on changed
files — clean
- [x] `npx oxfmt --check` on changed files — clean
- [ ] Manual: upload the customer's JumpCloud metadata (HTTP-POST only,
placeholder `entityID`) and confirm the error now says `Invalid file:
entityID: entityID is not a valid URL` instead of `Invalid file`
- [ ] Manual: upload metadata with a real `entityID` and HTTP-POST-only
binding, confirm the form populates correctly
## Changes
**Provisioning idempotent** (`aws-ses-register-domain.service.ts`)
- Each SES create call (`CreateConfigurationSet`, event destination,
contact list, tenant association) now swallow `AlreadyExistsException`
via `.send().catch()`.
- Retry after partial failure re-run every step, no blow up on "already
exists". Before: one existing resource kill whole provision.
**Workspace delete clean up cloud** (`workspace.service.ts`,
`emailing-domain-workspace-cleanup.job.ts`,
`emailing-domain.service.ts`)
- On workspace delete, fetch domain list first, pass domains to cleanup
job.
- Cleanup now loop `driver.cleanupDomain(domain)` per domain +
`deprovisionWorkspace`. Tear down SES identity/tenant/config-set, not
just delete DB rows.
- Before: DB rows gone, SES resources orphaned forever. Now: cloud match
DB.
**Inbound replay dedupe** (`ses-inbound-mail-handler.service.ts`)
- Use `snsMessageId` as job id. SNS deliver same message twice → second
is no-op. No duplicate inbound email import.
Creating a workflow on a table with with a filter on status (eg: status
is "active") failed because it added the status to createOneWorkflow (in
order to have the record belonging to the view) - while
createOneWorkflow throwed a 400 exception when attempting to create a
workflow with a status (does not correpsond to a valid behaviour).
Silently stripping status rom create workflow endpoints.
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).
This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.
**Please review before merging** — verify no critical models were
incorrectly deprecated.
Co-authored-by: FelixMalfait <[email protected]>
## Fixes#20084
### Problem
A saved address is visible in the **table view** but shows **"Empty"**
in the **record detail page** when `addressStreet1` is `null`.
This reproduces with the default seed data out of the box — e.g.
**Google** (city "Mountain View", no street), **Microsoft** (Redmond),
**Meta** (Menlo Park) — which is why several users reported hitting it
immediately.
### Root cause
The frontend zod schema required `addressStreet1` to be a **non-null**
string:
```ts
// isFieldAddressValue.ts
export const addressSchema = z.object({
addressStreet1: z.string(), // ← required non-null
addressStreet2: z.string().nullable(),
...
});
```
…but the backend composite type marks it `isRequired: false`
(`address.composite-type.ts`), and the DB column is nullable. So the API
legitimately returns `addressStreet1: null` when only other subfields
are filled.
The two views diverge on how they render:
- **Record detail** gates the value behind `useIsFieldEmpty()` →
`isFieldValueEmpty()`, which for addresses calls
`isFieldAddressValue()`. With `addressStreet1: null` the `safeParse`
**fails**, so `isFieldValueEmpty` returns `true` and the `"Empty"`
placeholder is shown (`RecordInlineCellDisplayMode`).
- **Table view** (`RecordTableCellDisplayMode`) renders
`AddressFieldDisplay` directly with **no** empty check, so the address
stays visible.
This was a latent mismatch since the address guard was introduced.
### Fix
Make `addressStreet1` nullable to match the backend and the other
subfields:
- `addressSchema` → `addressStreet1: z.string().nullable()`
- `FieldAddressValue.addressStreet1` → `string | null`
- `FieldAddressDraftValue.addressStreet1` → `string | null` (keeps the
input/draft type consistent; the text input already renders `?? ''`)
The change is strictly more permissive — persisting and the settings
default-value form still accept string values; they now also accept
`null`.
### Tests
- `isFieldAddressValue.test.ts` — guard returns `true` for
`addressStreet1: null` with other subfields filled.
- `isFieldValueEmpty.test.ts` — new address coverage: empty address is
empty; **`street1: null` + city filled is NOT empty**; normal address is
not empty. (Added an `addressFieldDefinition` mock.)
Both new assertions were confirmed to **fail before the fix** and pass
after.
### Verification
- `npx jest isFieldValueEmpty isFieldAddressValue
normalize-address-field-value-for-persist` → 17 passed
- `npx nx typecheck twenty-front` → pass
- `npx nx lint:diff-with-main twenty-front` → 0 warnings, 0 errors
## Summary
Fixes [#21014](https://github.com/twentyhq/twenty/issues/21014).
When two pie chart widgets shared the same group-by field (and therefore
the same slice ids) but used different aggregation operators (e.g.
`count` vs `sum`), the arc-link labels would mirror between the two
charts — both ending up showing either the count or the sum values,
depending on render order. Center metrics stayed correct.
**Root cause.** Nivo's `ArcLinkLabelsLayer` and `ArcsLayer` (from
`@nivo/arcs`) wire `react-spring`'s `useTransition` with `keys: e =>
e.id`. When two `<ResponsivePie>` instances render with overlapping ids,
the transitioned data bleeds across charts. The center metric is
unaffected because it's computed by a separate hook
(`usePieChartCenterMetricData`).
**Fix.** Namespace the Nivo-computed slice id per widget by passing an
`id` accessor to `<ResponsivePie>`:
```tsx
id={(datum) => `${id}:${String(datum.id)}`}
```
Lookups inside the widget switch to `datum.data.id` (the original,
un-namespaced id stored on the raw datum), so value/percentage
formatting, the custom tooltip, and the legend hover-dim behavior all
keep working.
Touched files:
- `GraphWidgetPieChart.tsx` — add `id` accessor
- `CustomArcsLayer.tsx` — compare legend highlight against
`datum.data.id`
- `getPieChartFormattedValue.ts`, `getPieChartTooltipData.ts` — match on
`datum.data.id`
- Tests for both utils get a regression case covering the namespaced
computed id
## Test plan
- [ ] `npx jest getPieChartFormattedValue` ✅
- [ ] `npx jest getPieChartTooltipData` ✅
- [ ] `npx tsc --noEmit` ✅
- [ ] Manual: dashboard with two pies on the same group-by field, one
`count` and one `sum`, "Display data label" on for both — confirm each
chart shows its own metric on the slices, and the central total is
unchanged.
- [ ] Manual: hover a legend item — the matching slice in that chart
stays solid while the others dim, and the sibling chart is not affected.
- [ ] Manual: clicking a slice still drills into the correctly filtered
view.
## Summary
Splits AI agent tool resolution into two independent rails:
- **Native tools** — capabilities baked into the model SDK
(Anthropic/OpenAI `web_search`, xAI `web`/`x` provider options). Bound
by `NativeToolBinderService`, controlled by per-agent
`modelConfiguration` toggles. Opaque to Twenty — executed on the model
provider's servers.
- **Action tools** — registry-scoped tools from `ToolRegistryService`
(code interpreter, send email, record CRUD, etc.). Permission-gated via
the agent's role. Executed on Twenty's server.
Both rails merge into a single `ToolSet` at call time. When both
surfaces expose a search tool the model picks at runtime — coexistence
is intentional (relevant once Exa returns as an action, see below).
## Notable changes worth calling out
**Contract change: `AgentAsyncExecutorService.executeAgent` no longer
accepts `rolePermissionConfig`.** Workflow agents now scope exclusively
by the agent's own permission-tab role (`unionOf: [agentRoleId]`). The
previous role-merging path (caller role intersected with agent role) is
removed. No agent role → no registry tools (fail-closed by design).
**`NativeToolBinderService` relocated** from
`core-modules/tool-provider/native/` →
`metadata-modules/ai/ai-models/services/`. The binder needs SDK-package
knowledge, which lives in `ai-models`. Old location created a backwards
module dependency.
**`NATIVE_MODEL_TOOLS_BY_SDK_PACKAGE` is exhaustive over
`AiSdkPackage`** (`Record<>`, not `Partial<Record<>>`). Adding a new SDK
without thinking about native tools now fails the build. SDKs without
native tools (Bedrock, Google, Mistral, Azure, OpenAI-compatible) get
explicit `{}` entries.
**Discriminated union `kind: 'sdk-tool' | 'provider-option'`** lets one
registry describe both function tools (Anthropic/OpenAI) and runtime
sources (xAI). Follows the local `tool-provider` convention from #19321.
## Deferred to follow-ups
- **Exa web search is dropped from this PR** (along with its
`WEB_SEARCH_TOOL` permission flag and the Exa-specific gating). Exa
comes back as an **action/app tool** once apps can define permission
flags through the SDK — ongoing work in #20481.
- **xAI native search currently errors.** xAI deprecated its Live Search
API (the `web`/`x` provider-option sources this rail maps to), so xAI
returns `410` when native search is actually exercised. The code path
itself is clear — it's only hit if you test xAI native tools. Fixed
separately alongside the broader xAI model fixes.
## Conscious non-decisions
- **No "twenty-native" category.** `native` is reserved for
model/provider SDK features; everything Twenty-owned is just a
tool/action.
- **Coexistence over precedence.** No rule forcing an action search tool
to override native search (or vice-versa) — when both exist, it's the
user's choice in workflow agents and the model's choice in chat.
---------
Co-authored-by: Félix Malfait <[email protected]>
Co-authored-by: Félix Malfait <[email protected]>
## Summary
Three related fixes to the auto-creation of People records from calendar
events and email messages, all centred on the data-quality problem of
contacts being created with missing or malformed names.
### 1. Enrich names on existing contacts (commit 1)
Previously: when an email or calendar import matched an existing Person
by email, the existing record was left untouched — even if the new
source carried a better name.
This is the root cause of contacts like `"Félix"` (no last name)
sticking around forever: the `To:`/`Cc:` headers of outbound emails
rarely include a display name, and Google Calendar only returns
`displayName` for attendees already in the organizer's address book. So
the first sighting often creates a Person as `{firstName: "felix",
lastName: ""}`, and a later inbound `From: "Félix Malfait"
<[email protected]>` — which would have produced the right name — gets
silently dropped because the Person already exists.
The new `computePeopleToEnrichNames` bucket and
`CreatePersonService.enrichPeopleNames` method fill in missing
`firstName`/`lastName` fields from the new parsed name, with
conservative rules:
- Only enrich when the existing Person's `createdBy.source` is
`CALENDAR` or `EMAIL` — `MANUAL`, `IMPORT`, `API`, `WORKFLOW`, etc. are
never touched.
- Only fill empty fields. Non-empty `firstName`/`lastName` are never
overwritten.
- Soft-deleted contacts continue to be handled by the existing restore
path.
### 2. Handle multi-comma "Last, First, Suffix" display names (commit 2)
The comma-inverted swap in the parser previously required *exactly* one
comma. Names like `"Smith, Jane, Jr."`, `"O'Brien, Mary, MD"` or `"Doe,
John, Patrick"` fell through to the space-split fallback, which stored
the comma in `firstName` (e.g. `"Smith,"`) and produced garbled records
(the avatar shows a single "B" and the name reads `"Barbey, Julien"`
because the entire string lives in `firstName`).
The regex now splits on the first comma and treats the remainder as the
first name, collapsing any further commas to spaces. Single-comma
behaviour is unchanged.
### 3. Perf: skip the parser when an existing record is already
populated (commit 3)
`computePeopleToEnrichNames` runs on every cron-driven email/calendar
import batch. The first version called the display-name parser for every
matched existing person, even when both `firstName` and `lastName` were
already set — i.e. the steady-state case after the initial enrichment
pass.
Reordered so the cheap "both fields populated" check short-circuits
before any parsing happens. Same behaviour, fewer parser calls on the
hot path.
## Test plan
- [x] 8 new unit tests for the enrichment bucket: empty `lastName`
enrichment, both `EMAIL` and `CALENDAR` sources, non-overwrite of
non-empty fields, skip on `MANUAL`/`IMPORT`, skip when the new source
also has no last name, skip for soft-deleted, fill `firstName` while
preserving `lastName`, handle null `name` field
- [x] 5 new parser tests for multi-comma forms: `"Last, First, Suffix"`,
credential suffixes (`MD`), three-token forms, whitespace around inner
commas, `:GROUP` tag interaction
- [x] 1 new parser test on the single-comma path covering multi-word
first names (`"Smith, Mary Jane"`)
- [x] All 15 existing parser tests still pass
- [x] All 116 tests in `contact-creation-manager` pass
- [x] `npx nx typecheck twenty-server`
- [x] `npx oxlint --type-aware` + `npx oxfmt --check` on changed files
- [ ] Manual: trigger a fresh contact creation from an outbound email
with no display name, then a subsequent inbound email from the same
address with a full display name, and confirm the Person's last name
gets populated
## Summary
Follow-up to #20953. Migrates 23 of the 30 entities that were left in
`WORKSPACE_SCOPED_EXEMPTIONS` last time, so the lint rule's
workspaceId-enforcement default now covers most of the core/metadata
schema.
### Migrated (23 entities, 88 files, 22 commits)
| Family | Entities |
|---|---|
| Trivial caches | `NavigationMenuItem`, `Skill`, `DataSource`,
`Webhook`, `CommandMenuItem`, `IndexMetadata` |
| Views | `View`, `ViewField`, `ViewFieldGroup`, `ViewFilter`,
`ViewFilterGroup`, `ViewGroup`, `ViewSort` |
| Layouts | `PageLayout`, `PageLayoutTab`, `PageLayoutWidget` |
| Roles & permissions | `Role`, `RoleTarget`, `PermissionFlag`,
`ObjectPermission`, `FieldPermission`, `RowLevelPermissionPredicate`,
`RowLevelPermissionPredicateGroup` |
For each entity: swap `@InjectRepository(X)` →
`@InjectWorkspaceScopedRepository(X)` (and the field type →
`WorkspaceScopedRepository<X>`); rewrite every call site to pass
`workspaceId` as the first arg (stripped from `where`/criteria — the
wrapper throws if you include it now); register
`provideWorkspaceScopedRepository(X)` in every owning NestJS module;
update affected spec providers to
`getWorkspaceScopedRepositoryToken(X)`.
### Rule update
- `ApplicationRegistrationVariableEntity` was misclassified — moved to
`STRUCTURAL_EXEMPTIONS` (no `workspaceId` column; it's keyed on
`applicationRegistrationId` at the instance level).
- 22 of the 23 migrated entities removed from
`WORKSPACE_SCOPED_EXEMPTIONS` entirely (zero remaining raw
`@InjectRepository` sites).
- `RoleTargetEntity` also removed; one call site in
`user-workspace.service.ts` keeps a raw injection with an
`eslint-disable` + reason because `softRemove(...)` is not on the
wrapper API yet (the migration would require threading `workspaceId`
through `deleteUserWorkspace`'s three callers).
### Still exempted (7 entities, follow-up PRs)
| Entity | Why deferred |
|---|---|
| `ApplicationEntity` | ~50 sites with several cross-workspace lookups
by id (auth, OAuth, file-storage, cleanup) |
| `CalendarChannelEntity` / `MessageChannelEntity` | Use
`.increment(...)` (not on wrapper) and
`repository.manager.transaction(...)` — wrapper needs to grow
`.increment` + the transaction sites need `withManager` or dual-inject |
| `FieldMetadataEntity` / `ObjectMetadataEntity` | The metadata services
`extends TypeOrmQueryService<X>` and `super(rawRepo)` — requires
dual-inject or reworking the inheritance |
| `KeyValuePairEntity` | Allows `workspaceId: IsNull()` for
instance-level config; wrapper rejects null |
| `UpgradeMigrationEntity` | Same — instance-level + cross-workspace
ledger |
## Test plan
- [x] `npx nx typecheck twenty-server` — clean
- [x] `npx nx lint twenty-server` — clean (0/0)
- [x] All 10 affected unit specs pass (115 tests) — api-key, agent-role,
permissions, workspace-roles-permissions-cache, view-filter-group,
workflow-version-step-operations, two-factor-authentication (service +
resolver), user-workspace, file
- [ ] Server integration tests in CI
## Summary
Closes#20662.
Two AI permission flags exist:
- **`AI`** (label "Ask AI") — user-facing: chat with AI agents, use AI
features
- **`AI_SETTINGS`** (label "AI") — admin: create and configure AI agents
After auditing every use of these flags I found:
### Frontend — chat UI gated by the admin permission (user-facing bug
from the issue)
A user granted only `Ask AI` could not see chat tabs, the "new chat"
button (desktop & mobile), or the chat content pane; thread
initialization was also skipped, leaving the chat in a half-initialized
state and producing intermittent `THREAD_NOT_FOUND` errors. Switched
these to `AI`:
- `MainNavigationDrawerTabsRow.tsx`
- `MainNavigationDrawer.tsx`
- `MobileNavigationBar.tsx`
- `AgentChatThreadInitializationEffect.tsx`
### Backend — admin-only resolvers gated by the user permission
(privilege escalation)
Two resolvers had a class-level guard of `AI`, letting any user with the
user-facing flag reach admin endpoints (skill CRUD, eval runs). Switched
the class-level guards to `AI_SETTINGS`:
- `SkillResolver` — create/update/delete/activate/deactivate skills
- `AgentTurnResolver` — read turns, run/grade evaluations
### Left as-is (already correct)
- `AgentResolver` — class-level `AI` for reads (workflow editors and
admin pages both need them), mutation-level `AI_SETTINGS` overrides for
writes
- `AgentChatResolver` & `AgentChatSubscriptionResolver` — already `AI`
- `AiGenerateTextController` — already `AI`
- Workspace AI config fields in `workspace.service.ts` — already
`AI_SETTINGS`
## Test plan
- [ ] As a user with `Ask AI` only (no `AI_SETTINGS`): chat tabs, "new
chat" button, and chat history pane are visible on desktop + mobile;
sending a message works; no `THREAD_NOT_FOUND` errors
- [ ] As a user with `AI_SETTINGS` but no `Ask AI`: chat UI is hidden
- [ ] As a user with `Ask AI` only: calling `skills` / `createSkill` /
`agentTurns` / `runEvaluationInput` via GraphQL returns permission
denied
- [ ] As an admin (`AI_SETTINGS`): skill settings and agent eval pages
still work
## Summary
closes https://github.com/twentyhq/core-team-issues/issues/2464
Introduces compile-time branded types to distinguish encrypted
ciphertext from plaintext strings, preventing mix-ups like the one fixed
in #20819 — but at the type level rather in addition to the one existing
at runtime.
### Branded string primitives
- Created `EncryptedString` and `PlaintextString` as hard nominal brands
using `z.string().brand(...)`, making them non-assignable to each other
or to raw `string`
- Created `isEncryptedString` type predicate to narrow `string` to
`EncryptedString` based on the `enc:v2:` envelope prefix
- Retyped `SecretEncryptionService`: `encryptVersioned` accepts
`PlaintextString`, `decryptVersioned` returns `PlaintextString`
### Entity typing
- Typed encrypted columns across entities:
`SigningKeyEntity.privateKey`,
`TwoFactorAuthenticationMethodEntity.secret`,
`ApplicationRegistrationVariableEntity.encryptedValue`,
`ApplicationVariableEntity.value`
- Parameterized JSONB types for connected account connection parameters
(`ImapSmtpCaldavParams<Pwd>`) with reusable aliases
`EncryptedImapSmtpCaldavParams` / `DecryptedImapSmtpCaldavParams`
- Typed DTOs (`CreateApplicationRegistrationVariableInput`,
`UpdateApplicationRegistrationVariablePayload`,
`UpdateApplicationVariableEntityInput`) with `PlaintextString`
### ApplicationVariable always-encrypt uniformization
- Retyped `ApplicationVariableEntity.value` to `EncryptedString | ''` —
all values are now encrypted regardless of `isSecret`
- Updated `ApplicationVariableEntityService` to always encrypt on write
and always decrypt on read
- Simplified `UpdateApplicationVariableActionHandlerService` by removing
conditional encrypt/decrypt-on-isSecret-toggle logic
- Added slow instance command (`2.9.0`) to backfill-encrypt existing
`isSecret=false` plaintext rows and tighten the `CHECK` constraint
### ConfigStorageService refactor
- Split `convertAndSecureValue` (which used `any`) into two well-typed
methods: `convertAndDecrypt` and `convertAndEncrypt`
- Introduced `isSensitiveStringValue` type predicate to narrow values
before encryption/decryption
### What's next
- Typeorm entity derivation to strictly type sitemap configuration as
code + handler logic for encryption rotation
- https://github.com/twentyhq/core-team-issues/issues/2465
## Context
The Install Playwright step ran npx playwright install with no
arguments, which downloads all browsers (Chromium + Firefox + WebKit +
ffmpeg, ~500MB+) on every run with no caching.
Fix:
- Install Chromium only — npx playwright install chromium instead of all
browsers.
- Cache the browser binaries — actions/cache on ~/.cache/ms-playwright,
keyed on the resolved Playwright version (v4-playwright-browsers-${{
runner.os }}-<version>). On a cache hit the install step is skipped
entirely; the cache invalidates automatically when the Playwright
version bumps.
Fixes https://github.com/twentyhq/twenty/issues/20225
The "Me" filter (current workspace member) worked in view filters but
not in dashboard widget filters — the server never resolved the
placeholder, and the widget side-panel UI had no "Me" option and only
allowed single selection.
Backend: `ChartDataQueryService` now forwards the current workspace
member id (from authContext) into filterValueDependencies, so the shared
filter logic resolves "Me" the same way it does for view filters. Added
unit tests for the converter.
Frontend: new multi-select picker for workspace member filters in the
widget side panel, mirroring the view filter's actor select: search
input, "Me" pinned item, and a multi-select workspace member list.
## Before
<img width="3024" height="1488" alt="CleanShot 2026-05-27 at 17 16
36@2x"
src="https://github.com/user-attachments/assets/b2cff46c-53e5-4e8a-a463-b106daf96c8c"
/>
## After
<img width="3024" height="1488" alt="CleanShot 2026-05-27 at 17 14
05@2x"
src="https://github.com/user-attachments/assets/8b3b5f11-44b9-4ae5-a2f3-9c7a689f4bb2"
/>
Fix two omissions from #19973 that prevented `twenty-sdk/billing` from
being a fully exported subpath:
- `package.json`: add `billing` to `typesVersions` (every other subpath
was listed; billing was the only one missing, breaking type resolution
for consumers using classic TS moduleResolution).
- `project.json`: add the billing vite build and `dist/billing` output
to the `build:sdk` target
Bumps [@apollo/client](https://github.com/apollographql/apollo-client)
from 4.1.6 to 4.2.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/apollographql/apollo-client/releases">@apollo/client's
releases</a>.</em></p>
<blockquote>
<h2><code>@apollo/client</code><a
href="https://github.com/4"><code>@4</code></a>.2.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/apollographql/apollo-client/pull/13132">#13132</a>
<a
href="https://github.com/apollographql/apollo-client/commit/f3ce805425d10a9666218a8e109288a2d46dcab1"><code>f3ce805</code></a>
Thanks <a
href="https://github.com/phryneas"><code>@phryneas</code></a>! -
Introduce "classic" and "modern" method and hook
signatures.</p>
<p>Apollo Client 4.2 introduces two signature styles for methods and
hooks. All signatures previously present are now "classic"
signatures, and a new set of "modern" signatures are added
alongside them.</p>
<p><strong>Classic signatures</strong> are the default and are identical
to the signatures before Apollo Client 4.2, preserving backward
compatibility. Classic signatures still work with manually specified
TypeScript generics (e.g.,
<code>useSuspenseQuery<MyData>(...)</code>). However, manually
specifying generics has been discouraged for a long time—instead, we
recommend using <code>TypedDocumentNode</code> to automatically infer
types, which provides more accurate results without any manual
annotations.</p>
<p><strong>Modern signatures</strong> automatically incorporate your
declared <code>defaultOptions</code> into return types, providing more
accurate types. Modern signatures infer types from the document node and
do not support manually passing generic type arguments; TypeScript will
produce a type error if you attempt to do so.</p>
<p>Methods and hooks automatically switch to modern signatures the
moment any non-optional property is declared in
<code>DeclareDefaultOptions</code>. The switch happens across all
methods and hooks globally:</p>
<pre lang="ts"><code>// apollo.d.ts
import "@apollo/client";
declare module "@apollo/client" {
namespace ApolloClient {
namespace DeclareDefaultOptions {
interface WatchQuery {
errorPolicy: "all"; // non-optional → modern signatures
activated automatically
}
}
}
}
</code></pre>
<p>Users can also manually switch to modern signatures without declaring
any <code>defaultOptions</code>, for example when wanting accurate type
inference without relying on global <code>defaultOptions</code>:</p>
<pre lang="ts"><code>// apollo.d.ts
import "@apollo/client";
declare module "@apollo/client" {
export interface TypeOverrides {
signatureStyle: "modern";
}
}
</code></pre>
<p>Users can do a global <code>DeclareDefaultOptions</code> type
augmentation and then manually switch back to "classic" for
migration purposes:</p>
<pre lang="ts"><code>// apollo.d.ts
import "@apollo/client";
declare module "@apollo/client" {
export interface TypeOverrides {
signatureStyle: "classic";
}
}
</code></pre>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/apollographql/apollo-client/blob/main/CHANGELOG.md">@apollo/client's
changelog</a>.</em></p>
<blockquote>
<h2>4.2.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/apollographql/apollo-client/pull/13132">#13132</a>
<a
href="https://github.com/apollographql/apollo-client/commit/f3ce805425d10a9666218a8e109288a2d46dcab1"><code>f3ce805</code></a>
Thanks <a
href="https://github.com/phryneas"><code>@phryneas</code></a>! -
Introduce "classic" and "modern" method and hook
signatures.</p>
<p>Apollo Client 4.2 introduces two signature styles for methods and
hooks. All signatures previously present are now "classic"
signatures, and a new set of "modern" signatures are added
alongside them.</p>
<p><strong>Classic signatures</strong> are the default and are identical
to the signatures before Apollo Client 4.2, preserving backward
compatibility. Classic signatures still work with manually specified
TypeScript generics (e.g.,
<code>useSuspenseQuery<MyData>(...)</code>). However, manually
specifying generics has been discouraged for a long time—instead, we
recommend using <code>TypedDocumentNode</code> to automatically infer
types, which provides more accurate results without any manual
annotations.</p>
<p><strong>Modern signatures</strong> automatically incorporate your
declared <code>defaultOptions</code> into return types, providing more
accurate types. Modern signatures infer types from the document node and
do not support manually passing generic type arguments; TypeScript will
produce a type error if you attempt to do so.</p>
<p>Methods and hooks automatically switch to modern signatures the
moment any non-optional property is declared in
<code>DeclareDefaultOptions</code>. The switch happens across all
methods and hooks globally:</p>
<pre lang="ts"><code>// apollo.d.ts
import "@apollo/client";
declare module "@apollo/client" {
namespace ApolloClient {
namespace DeclareDefaultOptions {
interface WatchQuery {
errorPolicy: "all"; // non-optional → modern signatures
activated automatically
}
}
}
}
</code></pre>
<p>Users can also manually switch to modern signatures without declaring
any <code>defaultOptions</code>, for example when wanting accurate type
inference without relying on global <code>defaultOptions</code>:</p>
<pre lang="ts"><code>// apollo.d.ts
import "@apollo/client";
declare module "@apollo/client" {
export interface TypeOverrides {
signatureStyle: "modern";
}
}
</code></pre>
<p>Users can do a global <code>DeclareDefaultOptions</code> type
augmentation and then manually switch back to "classic" for
migration purposes:</p>
<pre lang="ts"><code>// apollo.d.ts
import "@apollo/client";
declare module "@apollo/client" {
export interface TypeOverrides {
signatureStyle: "classic";
}
}
</code></pre>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/apollographql/apollo-client/commit/e010bdd239b5c10415d4b70ca791467cde12fc88"><code>e010bdd</code></a>
Version Packages (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13241">#13241</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/9c4c01a640b43bfb47bd52b25d5881c4ad7bec71"><code>9c4c01a</code></a>
Release 4.2 (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13129">#13129</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/222838e99bc6054120cc1f881bb225b1ef049de9"><code>222838e</code></a>
Exit prerelease mode</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/7d3a533c811a8423536ceeebccc06413ded5b6a3"><code>7d3a533</code></a>
Merge branch 'main' into release-4.2</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/f20d591bbf74cb4f0d87ec9a14b93a59fe46b039"><code>f20d591</code></a>
chore(deps): update actions/create-github-app-token digest to d72941d
(<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13239">#13239</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/d4a28b6142e47164c8a24bd8c05a8aa3f1ce4eee"><code>d4a28b6</code></a>
chore(deps): pin dependencies (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13237">#13237</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/c1f39cf5402b052ab92886a1857840a745aee02b"><code>c1f39cf</code></a>
ci: pin Actions@SHA and disable cache on workflows with elevated OIDC
permiss...</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/511048b7bd6253a38a6b7ebe58e9674a39c74273"><code>511048b</code></a>
Event-based refetching docs (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13228">#13228</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/d1f68f1a5fdb7c6915a72b2426cad373a0526c06"><code>d1f68f1</code></a>
Version Packages (rc) (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13234">#13234</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/f1b541fed4111028b6842727178288156582e669"><code>f1b541f</code></a>
Prepare for rc release (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13232">#13232</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/apollographql/apollo-client/compare/@apollo/[email protected]...@apollo/[email protected]">compare
view</a></li>
</ul>
</details>
<br />
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
</details>
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>