Commit Graph

13441 Commits

Author SHA1 Message Date
Abdul Rahman f90137b536 chore(server): remove nestjs-query from user-workspace module (#22591)
## Summary
Continues the incremental removal of `@ptc-org/nestjs-query`. Migrates
the `user-workspace` module to plain NestJS/TypeORM. This module
registered no resolvers, so `nestjs-query` was only acting as module
wiring and providing the service's inherited query methods — no public
API behavior depended on it.

## Changes
- `user-workspace.module.ts`: replaced
`NestjsQueryGraphQLModule.forFeature` with plain
`TypeOrmModule.forFeature`; kept all module imports and the service
provider unchanged.
- `user-workspace.service.ts`: dropped `extends TypeOrmQueryService` and
the `super()` call; added an explicit `findById` (the only inherited
method used externally, by `agent-actor-context.service.ts`).
- `user-workspace.entity.ts`: swapped `@IDField` for the standard
`@Field` on `id` (renders identically as `UUID!`).


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22591?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 12:37:31 +02:00
Paul Rastoin 628ab153a8 App installation workspace version check engines constraint (#22613)
## What

Makes the app-installation version gate **workspace-scoped**. App
installation now validates a manifest's `engines.twenty` requirement
against the version the **target workspace has actually finished
upgrading to**, instead of the instance/server's inferred version.

## Why

The server binary and a given workspace's migration state can diverge.
In a multi-workspace deployment the instance can already report version
`X` while an individual workspace still hasn't completed its
workspace-scoped upgrade commands for `X` (it's mid-upgrade or a
migration failed). Gating on the instance version let an app that
requires `X` install into a workspace whose schema/metadata is
effectively still at `X-1`, which can break the app. The requirement
should be checked against what the *workspace* has completed, not what
the server reports.

## How

- **`UpgradeStatusService.getWorkspaceCompletedVersion(workspaceId)`**
(new): resolves the last fully-completed upgrade version for a workspace
by reading its upgrade cursor and walking the upgrade sequence:
- Returns the cursor's version when the cursor sits on the **last step
of its version segment** and its status is `completed`.
- Otherwise walks backwards to the previous fully-completed version
segment.
- Returns `null` when the cursor is missing, not found in the sequence,
or otherwise uninterpretable.
- **`ApplicationVersionValidationService`**:
- Adds `validateWorkspaceCompatibility({ requiredServerVersion,
workspaceId })`.
- Extracts the shared semver logic into a private
`validateVersionAgainstRange({ version, requiredVersionRange, scope })`
and makes error messages scope-aware (workspace vs. instance).
`validateServerCompatibility` is preserved and now delegates to it.
  - New failure reason `INVALID_WORKSPACE_VERSION`.
- **`ApplicationInstallService`** now calls
`validateWorkspaceCompatibility` with the `workspaceId` instead of
`validateServerCompatibility`.
- **Exception plumbing**: new
`ApplicationExceptionCode.INVALID_WORKSPACE_VERSION`, surfaced as a
`UserInputError` (`BAD_USER_INPUT`) with a user-friendly message ("This
workspace's upgrade state could not be determined…"). The
tarball/registration path maps it onto the existing
`INVALID_SERVER_VERSION` registration code.

## Notes

- **Publishing (app registration) is intentionally not
workspace-gated.** The tarball/registration path
(`ApplicationTarballService`) still uses the instance-level
`validateServerCompatibility` check, not the new workspace-scoped one.
Publishing an app is not tied to any particular workspace's upgrade
state, so there is no workspace version to check at that point — the
workspace-completed-version gate only applies when installing an app
into a specific workspace.

## Testing

- Unit tests for `ApplicationVersionValidationService`
(`validateServerCompatibility` + new `validateWorkspaceCompatibility`)
covering: no requirement, invalid semver range, satisfied/unsatisfied
ranges, and the uninterpretable-cursor case.
- Unit tests for `UpgradeStatusService.getWorkspaceCompletedVersion`
against a three-segment mock upgrade sequence (multi-command version,
instance-only version, workspace-terminated version).
- New integration suite
`failing-app-installation-workspace-version.integration-spec.ts` (+
snapshots) exercising the real install flow: rejects installation when
the workspace hasn't completed the required version, and when the
workspace's upgrade cursor can't be interpreted. Adds a
`create-app-tarball.util.ts` test helper.
2026-07-07 10:05:06 +00:00
nitin cabe5545ae Use SDK calendarEventRecordPageFields identifiers in call-recorder (#22618)
Replaces the hardcoded calendarEventRecordPageFields view/group
identifiers in the call-recorder preference view-field with
`STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.views.calendarEventRecordPageFields`,
resolving the TODO. The published `twenty-sdk@2.18.0` (already pinned by
the app) ships these identifiers with values matching the previously
hardcoded ones.

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

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

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-07 11:54:54 +02:00
martmull 07a921f8ca Add Document Generator SDK app + step-by-step tutorial (#22522)
## What & why

This adds a **guided tutorial** that teaches the Twenty SDK by building
one real, useful app end to end — plus the finished app itself, ready
for the marketplace.

The app, **Document Generator**, turns reusable templates into
personalized documents using CRM data: write a template once with
`{{placeholders}}`, then generate a filled-in document for any Person or
Company from the command menu, an AI agent, or a workflow.

## Two parts

**1. The app — `packages/twenty-apps/public/document-generator`**

Each capability maps to one tutorial chapter:
- **Data:** `documentTemplate` + `document` objects, fields, and a
bidirectional relation
- **Logic:** a single `generate-document` handler exposed as an **AI
tool**, a **workflow action**, and an **HTTP POST route**; plus a public
**HTML view route**
- **UI:** two views + sidebar navigation, a **command-menu item** (on
Person selection) that opens a **React front component**
- **AI:** an agent + skill; a default application role; marketplace
metadata + logo
- **Tests:** unit tests for the template renderer + an install
integration test

**2. The tutorial —
`packages/twenty-docs/.../apps/tutorials/document-generator/`**

A six-chapter series under **Developers › Apps › Tutorial** (Overview →
Data model → Generating documents → HTTP routes → Building the UI → AI
agent → Publishing). Minimal prose, paste-ready code, inline links to
the matching reference pages, and real screenshots. Registers a new
"Tutorial" nav group and regenerates `docs.json` + the navigation
template.

## Verification

Validated against a running Twenty instance (`twenty-app-dev` on
`:2020`):
- `twenty dev --once` installs cleanly (28 metadata objects created)
- Generated a real document from a Person — placeholders resolved (name,
job title, `company.name`, email), zero missing tokens
- Command menu → front component → generate flow works in the UI
- Public HTML view route renders the document
- App gates green: `yarn lint` (0/0), `yarn typecheck`, `yarn test:unit`
(7/7)

All screenshots in the tutorial are captured from this run.

## Notes
- Left out per-app CI workflows (`.github/workflows`) to keep scope
tight — happy to add them if wanted.

https://claude.ai/code/session_012FoC76zPbPmpgtN7MXMPwy

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

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-07 11:13:34 +02:00
martmull 2a495c3477 feat(app): allow claiming ownership of unclaimed app registrations (#22609)
## After

<img width="653" height="703" alt="image"
src="https://github.com/user-attachments/assets/ebe800da-b00b-4239-99a9-e157f7bfd7a0"
/>
<img width="634" height="711" alt="image"
src="https://github.com/user-attachments/assets/00a048bf-36b1-489f-a080-1ed2d069e625"
/>


## Context

App registrations track their owner via `ownerWorkspaceId`. Curated /
catalog / CLI apps are seeded **unclaimed** (`ownerWorkspaceId: null`).
Until now there was no way to take ownership of an unclaimed app from
the UI — the only ownership action was **Transfer ownership**, which
requires the caller to already be the owner, so it can't act on a
null-owner app.

This PR adds a way to **claim** an unclaimed app registration, and makes
the owner always visible on the detail page.

## Behaviour

Admin panel → app registration detail → General tab:

- The **Owner** row is now always shown — an **Unclaimed** tag when
there's no owner workspace (previously the row was hidden).
- Danger zone buttons are ownership-aware:
- **Unclaimed** app → **Delete app** + **Claim ownership** (claims it
for the current workspace).
  - **Owned** app → **Delete app** + **Transfer ownership** (unchanged).

Transfer is hidden for unclaimed apps because transferring requires the
caller to already own the registration.

## Changes

**Backend**
- New `claimOwnership` service method: looks the registration up
globally, rejects it if it already has an owner, otherwise assigns
`ownerWorkspaceId` to the caller's workspace.
- New `claimApplicationRegistrationOwnership` mutation, guarded by
`WorkspaceAuthGuard` + `SettingsPermissionGuard(APPLICATIONS)` (same
guards as transfer).
- New `ClaimApplicationRegistrationOwnershipInput` DTO
(`applicationRegistrationId`).

**Frontend**
- **Claim ownership** button (shown only when the registration has no
owner workspace); opens a confirmation modal and calls the new mutation.
- **Transfer ownership** button now renders only for owned
registrations.
- The **Owner** row in the general info card is always displayed, with
an `Unclaimed` tag when there is no owner.

**Generated**
- Regenerated the checked-in GraphQL artifacts (`twenty-front` metadata,
`twenty-client-sdk` schema/types) against the live server so codegen
output matches.

## Verification
- `nx typecheck twenty-front` and `nx typecheck twenty-server` pass.
- `oxlint` + `oxfmt` pass on all changed source files.
- Codegen is idempotent — re-running the three `graphql:generate`
configs + `generate-metadata-client` produces no diff.
- Verified end-to-end on the running app against the seeded unclaimed
`Twenty CLI` registration (Owner shows `Unclaimed`; Danger zone shows
Delete + Claim ownership).

https://claude.ai/code/session_01U7rbxhBSUQRWBbdP5TmAgZ
2026-07-07 11:07:21 +02:00
Paul Rastoin 5dc9d7ab36 fix(server): make all view children reparentable across a workspace migration sync (#22600)
## Summary

Uniformizes the workspace migration engine so **every** view child
entity — `viewField`, `viewFieldGroup`, `viewGroup`, `viewFilter`,
`viewSort`, `viewFilterGroup` — can be reparented from one view to
another within a single manifest sync, including when the previous
parent view is deleted in the same sync.

### Context

When an app manifest deletes a view and reparents its children onto
another view in the same sync (e.g. replacing a custom `FIELDS_WIDGET`
view with a standard one), the sync failed with a builder validation
error `View field to update parent view not found`. Root causes:

1. `viewField`, `viewFieldGroup` and `viewGroup` had `viewId.toCompare:
false`, so the diff never detected the parent-view change and never
emitted a reparent update (the already-reparentable siblings
`viewFilter`/`viewSort`/`viewFilterGroup` had `toCompare: true`).
2. `validateFlatViewFieldGroupUpdate` resolved the *old* parent view (it
ignored the update patch), inconsistent with the other view-child
validators.
3. Once the builder no longer errors, the runner would fail silently:
`view.delete` ran **before** the child reparent updates, and `viewId` is
`onDelete: CASCADE`, so the old view's deletion cascade-deleted the
children before they could be reparented (silent data loss, since
`repository.update` on a missing row is a no-op).

### Changes

-
**`all-entity-properties-configuration-by-metadata-name.constant.ts`**:
set `viewId.toCompare: true` for `viewField`, `viewFieldGroup`,
`viewGroup`. Because `viewId` maps to `universalProperty:
'viewUniversalIdentifier'`, the diff compares **only**
`viewUniversalIdentifier` (never the raw FK). Snapshot updated
accordingly.
- **`flat-view-field-group-validator.service.ts`**: merge
`flatEntityUpdate` and resolve the **new** parent view, matching the
`viewField`/`viewGroup`/`viewSort` validators.
- **`compute-ordered-migration-actions.util.ts`**: move `view.delete` to
run **after** all view-child create/update actions so a child can be
reparented off a view that is being deleted in the same sync. Child
`delete → create → update` order is preserved (needed for `viewField`'s
partial-unique `(fieldMetadataId, viewId)`).
- **New integration test**
`successful-manifest-reparent-view-children.integration-spec.ts`
covering reparenting of every view child (a) between two persisting
views and (b) when the source view is deleted in the same sync.

## Test plan

- [x] `nx typecheck twenty-server`
- [x] oxlint + oxfmt on changed files
- [x] Unit snapshot regenerated:
`all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec`
- [x] New integration test passes (both scenarios)
- [x] Verified the delete-source scenario **fails** on the old action
ordering (children cascade-deleted, `Received length: 0`) and **passes**
after the reorder — confirming it's a genuine regression guard

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22600?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 10:52:34 +02:00
Raphaël Bosi 1d3f6176b2 Use record pickers for People Data Labs enrichment workflow inputs (#22596)
Follow-up to #21494, which added record-typed logic function workflow
inputs but deferred the People Data Labs migration until the SDK
release.

twenty-sdk 2.16.0 (published) now includes the `record`/`records` input
schema support, so this types the enrichment inputs accordingly:
`records` on enrich-people/enrich-companies and `recordId` on
enrich-person/enrich-company render as record pickers bound to
Person/Company.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22596?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 10:41:40 +02:00
Raphaël Bosi c55bfab11e Fix side panel open/close animation glitches in the page header and panel content (#22598)
When the side panel opens or closes, the pinned header button (e.g. New
Company) was flat-clipped for the whole 300ms animation: the ⋮ toggle
mounts instantly and shifts the button's slot, framer's `layout` prop
compensates with a transform that the surrounding `overflow: hidden`
containers don't follow, and the ResizeObserver-driven rerenders re-seed
that transform every frame. Removing `layout` lets the button follow
plain reflow, which cannot clip.

The panel content also squeezed during the animation (labels
re-truncating at every intermediate width) because the inner panel was
`width: 100%` of the width-animating wrapper. Pinning it to
`var(--side-panel-width)` turns the animation into a rigid drawer slide;
drag-resize is unaffected since it writes the same CSS variable.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22598?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 10:40:26 +02:00
Raphaël Bosi d3b79320b1 Remove book a call step from onboarding (#22597)
The book a call screen was shown as a dedicated onboarding step after
sending team invites. It is no longer part of the flow: the
`BOOK_ONBOARDING` status, its pending user var, the
`skipBookOnboardingStep` mutation and the `BookCallDecision` screen are
removed, and onboarding completes right after the plan step.

The `/book-call` Cal.com page remains, reachable only from the "Book a
Call" link on the upgrade screen, with a back link to `/plan-required`.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22597?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 10:34:20 +02:00
martmull ee28ae363f feat(files): use direct-to-storage upload for email and AI-chat attachments (#22610)
## Context

Follow-up to the direct-to-storage upload work (#22449 / #22531 / #22533
/ #22576). That migrated files-field, attachments and workflow uploads
off the buffered path. This PR does the same for the **last two
user-facing upload surfaces**: email attachments and AI-chat files.

## What this does

- Adds `EmailAttachment` and `AgentChat` to the server's
`DIRECT_UPLOAD_FILE_FOLDERS` allowlist. Both folders already resolve
through the workspace-custom-application path in
`resolveUploadLocation`, so no other server change is needed.
- Routes the two frontend hooks through the existing
`useDirectFileUpload` handshake (`createFileUpload` → `PUT` →
`completeFileUpload`):
- `useUploadEmailAttachment` → `FileFolder.EmailAttachment` (keeps its
existing `MAX_ATTACHMENT_SIZE` client check — email has a real send-size
limit).
  - `useAiChatFileUpload` → `FileFolder.AgentChat`.

Each hook keeps its public signature and return shape, so call sites are
unchanged. No schema change and no codegen needed — the
`CreateFileUpload`/`CompleteFileUpload` documents and the `FileFolder`
enum values already exist in `generated-metadata` from #22576.

## Why these are safe to migrate

Both server services (`file-ai-chat`, `file-email-attachment`) just
`writeFile` (store) and return a signed URL — no synchronous processing
of the bytes at upload time — so the store-and-reference direct-upload
flow fits exactly, same as files-field/workflow.

## Out of scope

`CorePicture` (avatars, member/workspace pictures, logos) stays on the
buffered path on purpose: small images that go through server-side image
handling and are served inline, where the 10 MB body limit is already
appropriate.

## Tests

Extends the `FileUploadService` unit spec with an `it.each` asserting
`createFileUpload` supports the `EmailAttachment` and `AgentChat`
folders.

## Verification

`typecheck` and `lint:diff-with-main` green on both `twenty-front` and
`twenty-server`. (The server jest suite couldn't run in my local sandbox
due to an unrelated config-import quirk present on a clean `main`
checkout too — CI runs it normally.)

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22610?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 10:26:04 +02:00
Paul Rastoin 9086b031e8 chore: bump sdk packages to 2.19.0-alpha.1 prerelease (#22599)
## Summary

- Bumps `twenty-sdk`, `twenty-client-sdk` and `create-twenty-app` from
`2.19.0` to `2.19.0-alpha.1` so the CD pipeline can publish a prerelease
of the SDK.

## Context

#22565 made system field universal identifiers deterministic and
fail-closed: any app package built with SDK ≤ 2.18 carries legacy system
field identifiers in its `manifest.json` and is now rejected at
install/sync time on servers running `main`. Rebuilding the apps
requires a published SDK carrying the new derivation.

Publishing `2.19.0-alpha.1` lets us rebuild all apps in
`packages/twenty-apps` (follow-up PR) against the new derivation while
keeping `latest` on `2.18.0` for authors targeting prod, which has not
run the 2.19 backfill yet.

⚠️ The publish job must tag this prerelease under a non-`latest`
dist-tag (e.g. `next`): the server resolves app installs and the upgrade
version check against the `latest` dist-tag.

Server version constants (`TWENTY_CURRENT_VERSION`, etc.) are
intentionally untouched.

## Test plan

- [ ] Verify the three package versions are `2.19.0-alpha.1`
- [ ] Verify the publish workflow in the CD repo tags the release as
`next` (not `latest`)

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22599?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 09:49:16 +02:00
Marie c8f7315c67 fix(server): converge app-sync isUnique diff for single-column unique constraints (#22592)
## Context

Fixes #22550. On app sync (`twenty dev`), a field backed by a
single-column unique constraint produces a **permanent, non-converging
`[isUnique] changed` field-metadata diff**: the change is reported, the
apply succeeds, and the identical change is reported again on the very
next sync.

## Root cause

Since the uniqueness-source-of-truth moved from a
`FieldMetadata.isUnique` column to `IndexMetadata` (@FelixMalfait's
#20846 / #20883), `field.isUnique` is a **derived** property. The two
sides of the app-sync diff derive it differently:

- **"from" side** (workspace cache,
`WorkspaceFlatFieldMetadataMapCacheService`) derives `isUnique` from
indexes via `computeUniqueFieldMetadataIdsFromIndexes`, which counted
**any** single-column `UNIQUE` index.
- **"to" side**
(`from-field-manifest-to-universal-flat-field-metadata.util.ts`) sets
`isUnique` from the field-level manifest flag (`fieldManifest.isUnique
?? false`).

For a field whose uniqueness is declared with `defineIndex({ isUnique:
true, fields: [oneField] })` (no field-level flag):
- "from" derives `true` (the custom unique index exists),
- "to" is `false` (no field-level flag),

so the diff emits a `fieldMetadata … [isUnique] changed` update forever.
The field-update runner drops `isUnique` before the SQL `UPDATE` (it has
no column), and the custom index persists, so the derived value never
changes — the loop cannot converge.

## Fix

Restrict the derivation in `computeUniqueFieldMetadataIdsFromIndexes` to
the field's **engine-owned backing constraint** — a `UNIQUE` index with
`isSystemSideEffect: true` — rather than any single-column unique index.

This makes `field.isUnique` mean the same thing on both sides:

| declaration | backing index (`isSystemSideEffect`) | "from" derived |
"to" flag | converges |
|---|---|---|---|---|
| field-level `isUnique: true` | side-effect handler generates it →
`true` | `true` | `true` |  |
| `defineIndex({ isUnique: true, fields:[x] })` | custom index → `false`
| `false` | `false` |  (index converges on its own) |

Standard objects and the create/update side-effect backing indexes are
all `isSystemSideEffect: true`
(`create-standard-index-flat-metadata.util.ts`,
`generate-deterministic-index-for-flat-field-metadata-or-throw.util.ts`),
so their fields keep `isUnique = true`. Only a user-declared custom
`defineIndex` unique index (`isSystemSideEffect: false`) is now excluded
— which is also what stops the create/update side-effect from generating
a **second, duplicate** backing index for a field the custom index
already covers (which would otherwise trip `DUPLICATE_UNIQUE_INDEX` on
first apply).

## Why this location (and not the manifest "to" side)

An earlier attempt derived `isUnique` on the manifest "to" side from the
built indexes. That breaks after #22295: `field.isUnique === true` is
the **trigger** for the `fieldUniqueBackingIndexOnCreate/Update`
side-effect handlers, so forcing it to derive from the compute-service
maps (which don't yet contain the not-yet-generated backing index) would
suppress the backing index for field-level unique fields. Narrowing the
shared "from" derivation keeps the side-effect trigger intact and makes
both sides symmetric in one place.

## For review — @FelixMalfait

This touches the uniqueness model you own in #20883 ("make IndexMetadata
the source of truth for uniqueness"), and interacts with the side-effect
engine from #22295. The semantic change is: **`field.isUnique` now
reflects only the field's backing constraint, not an arbitrary
user-declared single-column unique index.** A `defineIndex`-declared
single-column unique field now surfaces `isUnique: false` on the field
(the constraint is still enforced by the index). If instead you'd want
`defineIndex` single-column uniqueness to surface as `field.isUnique:
true`, the fix would need to live in the side-effect engine (dedupe the
backing index against the declared one) rather than the derivation —
happy to take it that direction. Flagging for your call before this
leaves draft.

## Related

- Issue #22550
- @FelixMalfait #20883, #20846 (IndexMetadata as source of truth for
uniqueness)
- #22295 (centralized side-effect engine — unique field backing index)
- #21383 (adjacent field-`isUnique` handling)

https://claude.ai/code/session_01T1Cqvt5tHS6tZ1FeQQWyRo
2026-07-07 09:34:47 +02:00
neo773 2e1117d442 feat(messaging): message campaign delivery stats + views (#22452)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22452?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 04:47:52 +02:00
Marie 8a4bcd1445 (Billing for self hosts) Tie enterprise key to server (#22464)
# Enterprise key: bind to a server, free dev instances, self-serve
transfer, shorter license

## Summary

Enterprise keys were being reused across multiple instances (e.g. one
prod + one dev, or several environments), which broke seat accounting
and made licensing ambiguous. This PR ties each enterprise key to a
**single server**, while giving customers a legitimate, self-serve way
to run a **free development instance** and to **move their key** when
they replace a server.

## Product behavior

### 1. Enterprise key is bound to one server
- The first server to validate an enterprise key **claims** it
(claim-on-first-use). From then on, that key is bound to that one server
(until unbound - see 3.).
- Any other instance that presents the **same key from a different
server is hard-rejected**: it does not receive a license, so enterprise
features stay off there.
- Each instance has a stable server identifier. If one isn't set, the
instance generates and persists one automatically on first validation
(in keyValuePair table), so existing customers generally don't need to
do anything (unless they have disabled config variables in db then they
should add it to .env).

### 2. Free development instance
- Every enterprise subscription gets **one free, non-billable
development instance** in addition to its production instance.
- An instance registers as development by declaring its instance type as
`development` (done by default when validating the enterprise key, then
can be toggled from UI or by updating value in keyValuePair table).
- The free dev slot is only granted while there is an **active
production instance** on the same subscription (so it's a perk for
paying customers, not a way to run for free).
- Only **one** dev instance can be active at a time per subscription,
and it is **not counted as a billable seat**.

### 3. Self-serve unbind / rebind (transfer)
- Admins can **release** the binding from the enterprise settings, which
frees the key so it can be **claimed by a new server**.
- This is the intended path when **sunsetting an instance and standing
up a new one** (migration, re-hosting, disaster recovery): release on
the old/dead box, then the new box claims it on its next validation.
- To prevent abuse, releases are **rate-limited (10 per rolling 30
days)**; hitting the limit shows a clear message.

### 4. Automatic release of dead servers
- If a bound server stops checking in for **14 days**, its binding is
considered stale and is **auto-released**, so a replacement can claim
the key without any manual step. This covers the case where the old
server is already gone and can't release itself.

### 5. Shorter license validity (30 → 7 days)
- The license (validity token) now expires after **7 days** instead of
30. The daily background refresh keeps healthy instances licensed
transparently.
- This limits the value of copying a license from one instance to
another, since a copied license now stops working within a week.

### 6. License issuance is rate-limited
- Issuing a new license is capped at **twice per 24h, independently for
production and for development**. This tolerates the normal daily
refresh (including small drift between runs) while blocking bursts of
license minting for cloned instances.
- Hitting this limit never revokes an existing, still-valid license —
the current one keeps working until it expires; the manual "refresh"
button just reports that the daily limit was reached.

## What changes for existing self-hosted customers

**If you run a single production instance with one enterprise key:**
nothing to do. On the next validation your instance reports its server
identifier, claims the binding, and keeps working.

**If you reuse one key across several instances (e.g. prod + dev, or
multiple environments):** only the **first** instance to validate keeps
its license. The others will **lose enterprise features**. To migrate:
- Keep your production instance as-is (it claims the binding).
- For a secondary/testing box, mark it as a **development instance**
(set the instance type to `development`) to use the free dev slot — no
extra cost.
- If you genuinely need multiple production instances, you'll need
**separate subscriptions/keys** for each.

**If you're replacing a server (decommissioning + rebuilding):**
- **Release** the binding from enterprise settings on the old instance,
then start the new one — it will claim the key automatically.
- If the old server is already gone, just wait for the **14-day
auto-release**, or contact support.

**Legacy instances that can't persist a server identifier
automatically:** set the server identifier explicitly in your
environment configuration (the instance logs a message telling you to do
so).

**Offline instances:** because licenses now last 7 days, an instance
that can't reach our licensing endpoint for more than a week will lose
enterprise features until it can check in again.

> A migration email will be sent to affected customers separately.

## Technical implementation (brief)

- Binding state lives in the **subscription's billing metadata** (bound
server id + last-seen timestamps for prod and dev, release timestamps,
and license-issuance timestamps). No new database is introduced on the
licensing side; the billing provider's subscription metadata is the
source of truth.
<img width="976" height="413" alt="metadata_3"
src="https://github.com/user-attachments/assets/ccc64822-e177-4223-a65a-4a4602aedf0e"
/>

- On each validation, a pure **binding resolver** takes the reported
server id + instance type + current metadata and returns `allowed` (with
the metadata to persist and whether the seat is billable) or `rejected`.
It handles claim-on-first-use, staleness/auto-release, the
dev-requires-active-prod rule, and the single-dev-slot rule.
- **Rate limits** (release + license issuance) use a shared
sliding-window helper stored as pruned timestamp lists in the same
metadata, so the metadata self-cleans and never grows unbounded. License
issuance uses **separate windows per instance type**.
- The self-hosted instance **generates and persists a server
identifier** if none is configured, and sends it (plus instance type) as
instance metadata on validation.
- A rejected binding returns a specific error code; the instance
**revokes its stored license** on that code. A license-issuance
rate-limit instead **throws a typed exception that surfaces to the
manual refresh** while leaving the existing license untouched; the daily
refresh job swallows it.
- License lifetime is a configurable duration (defaulted from 30 to **7
days**), clamped to the subscription's cancellation date when sooner.
2026-07-06 18:07:03 +02:00
Félix Malfait ed2b2f8911 feat: publish MCP & API discovery documents (well-known standards) (#22589)
## What & why

Makes Twenty's **MCP server** and **REST/GraphQL APIs**
auto-discoverable by catalogs (e.g. integrations.sh) and AI agents,
using vendor-neutral open standards rather than a proprietary manifest.

The tricky part is that Twenty is **multi-tenant and the REST OpenAPI is
generated per workspace** (it reflects each workspace's custom objects,
and with no token even the base schema is empty). So there is no single
public URL that describes the full API contract. This PR solves that
with two complementary layers.

## 1. Static standards on `twenty.com` (`twenty-website`)

The brand-level catalog entry, using `{your-workspace-url}` placeholders
since `twenty.com` is not a workspace host:

- `public/.well-known/mcp/server-card.json` — MCP Server Card (SEP-2127)
- `src/app/.well-known/api-catalog/route.ts` — RFC 9727 linkset (route
handler so the `application/linkset+json` content type survives the
global `nosniff` header)
- `public/llms.txt` — LLM-readable overview

## 2. Dynamic per-host serving from `twenty-server`

A new `well-known` core module serves the same documents built from the
**request host**, so every workspace subdomain, custom domain, and
self-hosted instance advertises its own **real, connectable** endpoints
(`https://{that-host}/mcp`, its live `/rest/open-api/core`, etc.) — no
placeholder:

- `GET /.well-known/mcp/server-card.json`
- `GET /.well-known/api-catalog`

Both are public + CORS + cached. The api-catalog's `service-desc` points
at each host's **live** per-workspace OpenAPI — the honest answer to
"it's generated per workspace" (real endpoint, real custom objects,
still token-gated). The `version` comes from `APP_VERSION`.

The two layers are complementary: the static one serves
catalog/marketing discovery at the brand domain; the dynamic one serves
connecting clients the real endpoints — which is where the MCP spec
expects the server card to live (same origin as `/mcp`).

## Refactor

Extracted the request→base-URL logic that `OAuthDiscoveryController` had
as a private method into a shared
`src/utils/get-request-base-url.util.ts`, now used by both it and the
new controller.

## Notes

- Docs URLs are sourced from the shared `DOCUMENTATION_BASE_URL`
(server) and the `SITE_URLS` registry (website) rather than hardcoded.
- MCP endpoint, transport (`streamable-http`), and protocol version
(`2025-06-18`) are read from the existing MCP constants.
- OAuth resource metadata (`/.well-known/oauth-protected-resource`)
already existed and is unchanged.

## Testing

- `twenty-server` unit tests for the builders and controller (host
derivation, version fallback, linkset shape) — passing.
- `nx typecheck twenty-server` — passing.
- `oxlint` + `oxfmt` clean on both packages; website `check-conventions`
OK.

https://claude.ai/code/session_01F6g7kefcfpjXSZjH6cwqhi

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22589?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 17:53:05 +02:00
Weiko 8580cd6f27 feat(ai): open Ask AI side panel with a preprompt in two modes (#22582)
Add the ability to open the Ask AI side panel pre-filled with a prompt
from any frontend component, with a mode to control whether the message
is sent automatically or left for the user to review.

- agentChatPrepromptState: holds the pending preprompt and its mode
(PREFILL = fill only, SEND = fill and auto-submit)
- useOpenAskAiPageWithPreprompt: seeds the new-thread draft, opens a
fresh Ask AI thread and stores the preprompt intent
- AgentChatPrepromptEffect: applies the intent once the chat editor and
send listener are mounted, either restoring the editor content or
dispatching the send event and clearing the editor

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22582?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 15:38:28 +00:00
Raphaël Bosi 62c7e8f6b4 Polish onboarding v2 verify animation and step screens (#22585)
https://github.com/user-attachments/assets/1d9b8dc2-ef01-4202-a97e-b41cab048f87





A few polish tweaks to onboarding v2:

- **Verify/workspace-creation animation:** emphasize the key phrase of
each message in medium weight, the rest regular (e.g. "Creating your
**workspace**…").
- **Wider content column:** 340px → 440px. Collapses to full width on
mobile via the existing `max-width: 100%` on every consumer.
- **Sticky disabled buttons:** step submit buttons now stay disabled
from submit through navigation instead of briefly re-enabling once the
mutation resolves.
- **Fewer pulse loaders:** stop the pulsing logo from flashing when
navigating between onboarding steps (removed the step-page Suspense
fallback loader). The verify animation, cold-boot gates, and sign-in
fallbacks are unchanged.

Note: the reworded activation messages get new Lingui catalog IDs, so
non-English locales fall back to English until catalogs are
re-extracted.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22585?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 14:44:44 +00:00
Paul Rastoin 6c40c7b91a Deterministic system field universal identifier (#22565)
# Introduction

Close twentyhq/core-team-issues#2641

Auto-provisioned field metadata used to get its `universalIdentifier`
from three unrelated sources: random `v4()` on the server when creating
custom objects, hardcoded values in `STANDARD_OBJECTS`, and an ad-hoc
`v5` derivation in the SDK manifest build. This PR unifies all of them
behind the shared `getFieldUniversalIdentifier` derivation:

```
universalIdentifier = f(applicationUniversalIdentifier, objectUniversalIdentifier, fieldName)
```

## Ownership model

The rollout is built on an explicit split of who owns a field's
universal identifier:

- **The 8 system fields** (`id`, `createdAt`, `updatedAt`, `deletedAt`,
`createdBy`, `updatedBy`, `position`, `searchVector`) are
**server-owned**. Their universal identifiers are always the
deterministic derivation, on **every** application (standard,
workspace-custom, installed). Clients cannot provide custom values: a
temporary check in `validateObjectMetadataSystemFieldsIntegrity` rejects
any non-derived system field identifier at migration build time. This
check stands in until system fields are generated exclusively server
side by the metadata side-effect engine and stripped from client inputs
— at which point it becomes structurally impossible to send one.
- **`name` is a default field, not a system field**: it is
auto-provisioned when absent (server side for custom objects, SDK side
for application objects) but authors can define their own. It is only
derived where it is guaranteed to be auto-provisioned. In particular,
standard objects keep their **historical hardcoded** `name` identifiers:
the standard app authors its `name` fields like any installed app would,
and moving those identifiers would break every installed application
referencing them (e.g. views on `opportunity.name`).
- **User-created and author-provided fields** keep random / explicit
identifiers, untouched.

## Server

- `validateObjectMetadataSystemFieldsIntegrity` now validates, on top of
the existing type/`isSystem` checks, that each system field's
`universalIdentifier` equals the deterministic derivation. Runs for
every object creation going through the migration orchestrator: app
sync, custom object creation, standard provisioning
- `build-default-flat-field-metadatas-for-custom-object.util.ts` derives
the system field identifiers (and the auto-provisioned `name`) with
`getFieldUniversalIdentifier` instead of `v4()`
-
`build-default-relation-flat-field-metadatas-for-custom-object.util.ts`
derives both the forward and the reverse default relation field
identifiers deterministically
- `generateMorphOrRelationFlatFieldMetadataPair` accepts optional
`sourceFieldUniversalIdentifier` / `targetFieldUniversalIdentifier` so
callers can inject deterministic values; user-created relations still
default to `v4()`

## twenty-shared

- `STANDARD_OBJECTS` system field identifiers (the 8) are now computed
at module load via `buildStandardObjectSystemFields`; `name` and every
other identifier keep their hardcoded values
- New snapshot test pinning **every** universal identifier of
`STANDARD_OBJECTS`: any identifier change now requires an explicit
snapshot update and should ship with a coordinated backfill

## SDK (breaking, pre-GA)

- `generateDefaultFieldUniversalIdentifier` delegates to
`getFieldUniversalIdentifier` and now requires
`applicationUniversalIdentifier`
- Reverse default relation field identifiers are derived from the
field's real coordinates (standard object UID + actual field name, e.g.
`targetRocket` on `attachment`) instead of the legacy custom-object UID
+ synthetic `${fieldName}Inverse` hash input. Field *names* are
unchanged
- The manifest build threads the application universal identifier
through default field injection (two-pass over object configs)
- `twenty dev:add` now resolves the application universal identifier
upfront and refuses to scaffold anything until `defineApplication`
declares one — no more `fill-later` placeholder for the app UID in
generated files

## Upgrade

A 2.19 **workspace command** backfills existing
`fieldMetadata.universalIdentifier` rows to the deterministic
derivation. Coverage follows the ownership model:

- **The 8 system fields**: taken over for **every application**,
whatever value they currently hold. This is both safe and required now
that sync rejects non-derived values — leaving a row unconverged would
make its application unsyncable
- **`name`**: workspace-custom app → always taken over
(server-generated, no author to clobber); installed applications → only
rows still carrying the legacy SDK derivation are recomputed,
author-provided identifiers are never touched; standard app → never
touched (hardcoded in `STANDARD_OBJECTS`)
- **Default relation fields**: workspace-custom app → forward fields on
custom objects and reverse fields on the standard relation objects;
installed applications → legacy-derivation probe only

All identifiers of a workspace are updated inside a single transaction,
then the command flushes the field-metadata-related workspace caches and
bumps the metadata version.

Stored `applicationRegistration.manifest` snapshots are intentionally
**not** rewritten: installs and upgrades always sync from the
`manifest.json` inside the resolved package (npm/tarball), the stored
column is only used for display/marketplace purposes.

## Breaking behavior for old packages (fail closed)

Packages built with an older SDK carry legacy system field identifiers
in their tarball `manifest.json`. Installing or upgrading such a package
now fails with an explicit `INVALID_SYSTEM_FIELD` validation error
("universal identifier is not deterministic") instead of silently
mismatching against the backfilled rows and triggering a destructive
delete+create. The remediation is to rebuild the package with the new
SDK; the backfill has already converged the installed rows, so the
rebuilt manifest syncs cleanly.

## Test plan

- [x] `twenty-sdk` unit tests (526 tests) and typecheck
- [x] `twenty-shared` unit tests (1635 tests) including the
`STANDARD_OBJECTS` snapshot; `name` identifiers verified byte-for-byte
identical to `main`
- [x] Lint and typecheck clean on all touched packages
- [x] Integration: create a custom object and verify system + default
relation field identifiers match the deterministic derivation
(`create-one-object-metadata-deterministic-field-universal-identifiers`,
13 assertions passing)
- [x] Integration: `failing-sync-application-object-system-fields`
extended with a non-derived system field identifier case; all
identifiers in the spec pinned deterministically so snapshots embedding
expected/actual values are stable across runs (verified with a double
run)
- [x] Integration: all application sync suites pass with the derived
system field identifiers now required by the
`buildDefaultObjectManifest` test helper (9 suites, 20 tests)
- [x] Full test-database reset: standard app provisioning and seeded
workspaces pass the new validation
- [x] SDK manifest build verified on the postcard example app: all
auto-generated default field identifiers match the derivation
- [ ] Run
`upgrade:2-19:backfill-deterministic-field-universal-identifiers`
(dry-run then real) on a seeded workspace and verify identifier
convergence with a rebuilt app manifest
2026-07-06 13:34:33 +00:00
martmull 0706c7c1bc feat(front): upload files directly to storage for files-field, attachments and workflow (#22576)
## Context

Final step of the direct-to-storage upload work (follows #22449
endpoints, #22531 reaper, #22533 content-verify). The server can now
hand the client an upload URL so bytes go straight to storage instead of
being buffered through the Node process (the original OOM problem). This
PR switches the frontend to that flow for the three in-scope surfaces.

## What this does

Adds **`useDirectFileUpload`** — the shared hook that runs the
handshake:

1. `createFileUpload({ filename, size, fileFolder, fieldMetadataId? })`
→ `{ fileId, uploadUrl, contentType, expiresAt }`
2. `PUT` the raw file to `uploadUrl` with `Content-Type: contentType`
3. `completeFileUpload({ fileId })` → `FileWithSignedUrl` (`{ id, path,
size, createdAt, url }`)

Routes the three existing upload hooks through it, **keeping each hook's
public signature and return shape unchanged** so no call sites change:

| Hook | Folder |
|---|---|
| `useUploadFilesFieldFile` (FILES fields) | `FilesField` |
| `useUploadAttachmentFile` (attachments — the Attachment object's
`file` FILES field) | `FilesField` |
| `useUploadWorkflowFile` (workflow send-email attachments) | `Workflow`
|

Adds the `CreateFileUpload` / `CompleteFileUpload` gql documents and
regenerates `generated-metadata` types (+19 lines, scoped to the two new
operations).

## Out of scope

- AI-chat (`AgentChat`) and email-attachment (`EmailAttachment`) uploads
keep the legacy buffered mutations — those folders aren't in the
server's direct-upload allowlist (`[FilesField, Workflow]`).
- Workflow serverless-function code is saved via metadata mutations, not
the file path.

## Notes

- The legacy `uploadFilesFieldFile` / `uploadWorkflowFile` mutations
still exist server-side and remain used by the out-of-scope surfaces, so
this is non-breaking.
- Local storage routes the `PUT` to the token-authenticated streaming
endpoint (`SERVER_URL/file-upload/:id?token=…`); S3 uses a presigned
`PUT`. CORS is already enabled globally on the server and the token
rides in the query string (no cookies), so the browser upload works
cross-origin.

## Verification

`typecheck` and `lint:diff-with-main` green on `twenty-front`; codegen
ran against a live metadata schema so the generated file matches the
drift check. No existing tests/stories cover these hooks.

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22576?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 15:28:57 +02:00
nitin 9657c59272 Inject functions URL into logic function env (#22583)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22583?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 15:18:07 +02:00
Raphaël Bosi 29920738dc Fix stale token race forcing re-login on verify pages (#22573)
Landing on /verify often forced users to refresh and log in again. The
culprit is a logout side effect triggered by a stale token: when a
previous session's token pair is still in localStorage, boot queries use
it, fail, and the failed token renewal reacts by logging the user out
(onUnauthenticatedError clears the token pair). That logout fires while
the loginToken exchange is running, so it can wipe the fresh session
that was just stored.

Fix: clear the stale token pair right before exchanging the loginToken
(in useVerifyLogin, so both /verify and /verify-email are covered) —
with no stale token to renew, the logout side effect never fires against
the new session. Also removes the redundant clientConfig gate on the
verify effect, stops that same logout side effect from redirecting users
off /verify-email mid-verification, and always re-enables app redirects
after loading the user.

Note: opening a loginToken link now replaces an existing valid session
instead of keeping it.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22573?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 14:58:32 +02:00
martmull 2327ae7122 Revert "feat(server): add instance-level file storage layer" (#22579)
Reverts twentyhq/twenty#22560

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22579?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 11:47:45 +00:00
Paul Rastoin 22b49a502c chore(server): remove unused flat-field-metadata per-object mocks (#22581)
## Context

The `flat-field-metadata/__mocks__/` directory contained 11 large
per-object `as const` mock catalogs (`OPPORTUNITY_FLAT_FIELDS_MOCK`,
`PERSON_FLAT_FIELDS_MOCK`, `PET_FLAT_FIELDS_MOCK`, ...) plus a
`getRelationTargetFlatFieldMetadataMock` helper. An audit of the whole
server package (searching both the constant names and any import of the
directory) showed almost none of them are consumed anymore — tests have
moved to building exactly the fields they need with the
`getFlatFieldMetadataMock` factory.

Usage found:
- `getFlatFieldMetadataMock` (factory): ~25 spec files + 2 core-modules
mocks — **kept**
- `COMPANY_FLAT_FIELDS_MOCK`: 1 spec
(`object-record-event-publisher.spec.ts`), which only used the `name`
field
- The other 10 `*_FLAT_FIELDS_MOCK` catalogs and
`getRelationTargetFlatFieldMetadataMock`: **zero consumers**

## Changes

- Delete the 11 unused `*-flat-fields.mock.ts` catalogs and
`get-morph-or-relation-target-flat-field-metadata-mock.ts` (~4,900
lines). Only `get-flat-field-metadata.mock.ts` remains.
- In `object-record-event-publisher.spec.ts`, build the company `name`
field inline with `getFlatFieldMetadataMock` (wired to
`COMPANY_FLAT_OBJECT_MOCK.id`/`workspaceId`) and replace the three
`COMPANY_FLAT_FIELDS_MOCK.name.type` references with
`FieldMetadataType.TEXT`.

The sibling `flat-object-metadata/__mocks__/` catalogs are untouched —
several of those are still consumed by the morph/relation specs.

## Verification

- `object-record-event-publisher.spec.ts`: 27/27 passing
- `npx nx lint:diff-with-main twenty-server`: green
- `npx nx typecheck twenty-server`: green

https://claude.ai/code/session_01XcGEtwdXQo9uJibGRPexuG

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22581?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 13:47:17 +02:00
Thomas Trompette fb0a54c73a fix(server): pace lambda control-plane calls to avoid 'Rate exceeded' on release (#22569)
## Problem

Logic functions intermittently fail with:

```
Lambda invocation failed for function '<id>' during build: Rate exceeded
```

`Rate exceeded` is AWS Lambda's control-plane throttling
(`TooManyRequestsException`), thrown during the **build** phase — before
invoke — inside `buildExecutor`.

### Why it spikes on release
A build is skipped (`canSkip = true`, zero control-plane calls) unless
the executor is missing/inactive **or**
`flatApplication.isSdkLayerStale` is true. `isSdkLayerStale` is flipped
to `true` for the **whole application at once** whenever the SDK client
regenerates (app install / development / schema change).

So on release, every logic function in the app goes stale simultaneously
→ each enters `ensureExecutor` in its own per-function lock → a burst of
`Create`/`Update`/`PublishLayer`/`GetFunction` calls across many
functions at once → the low, account-region-wide control-plane quota is
exceeded → `Rate exceeded`. Between releases everything is warm and no
control-plane calls happen — hence "spikes on release, silent
otherwise".

The Lambda client was created with no retry override, so it used the SDK
default (`standard` mode, `maxAttempts = 3`): a few retries with
backoff, but no client-side pacing.

## Change

Configure the shared Lambda client with:
- `retryMode: 'adaptive'` — adds a client-side token-bucket rate limiter
that slows outgoing requests when it sees throttling, instead of
fire-then-backoff.
- `maxAttempts: 8` — rides out the burst.

Applied after the options spread so it always takes effect, and covers
**every** control-plane call including the
`waitUntilFunctionActive/UpdatedV2` pollers (same client).

## Scope / follow-up

This is the cheap, high-leverage mitigation and dampens the burst per
process. It does **not** add a cross-function/cross-pod concurrency cap,
so a large enough release across multiple replicas could still exceed
the account quota. A follow-up could add a limiter (in-process
semaphore, or a distributed token bucket via the existing Redis
cache-lock) around `ensureExecutor`.

## Testing

- `tsc --noEmit` on twenty-server: clean.
- Not runtime-tested — AWS control-plane throttling can't be reproduced
locally. Worth confirming against a real release-time CloudWatch window
after deploy.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22569?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 13:44:33 +02:00
martmull 0baf213fa4 feat(server): add instance-level file storage layer (#22560)
Part of the app settings architecture cleanup
(twentyhq/core-team-issues#2456) — PR 1 of the instance-level documents
plan. Today all file storage is workspace-scoped
(`FileEntity.workspaceId NOT NULL`, `{workspaceId}/{app}/…` storage
keys, workspace-anchored tokens); instance-level data like
application-registration manifests and tarballs for ownerless catalog
registrations has no first-class home, forcing raw-driver bypasses
(`DefaultAiCatalogService`, prototype #22556).

## Changes (core storage layer only — no HTTP serving, no GraphQL
exposure)

**New `instanceFile` table** (`InstanceFileEntity`) — deliberately
separate from the workspace-scoped `file` table so nothing about the
existing system changes:
- `id`, `path` (unique, `{fileFolder}/{relativePath}` mirroring
FileEntity's convention), `size`, `mimeType`, timestamps
- nullable `applicationRegistrationId` FK (`onDelete: CASCADE`) —
registration-owned documents follow their registration
- no `workspaceId`, no `applicationId`; plain repository (added to the
`prefer-workspace-scoped-repository` lint rule's global-table
exemptions, as the rule's own message directs)

**New `InstanceFileStorageService`** (exported from the global
`FileStorageModule`):
- storage keys under a literal `instance/{fileFolder}/…` prefix —
collision-free with workspace prefixes (UUIDs); scope-validation util
mirroring `validateStoragePathIsWithinWorkspaceOrThrow`
- `writeInstanceFile` (upsert row on `path` conflict + driver write;
throws on failure — no swallowing),
`readInstanceFile`/`readInstanceFileById` (missing file surfaces
`FILE_NOT_FOUND` like `FileStorageService.readFile`),
`checkInstanceFileExists`, `deleteInstanceFile`/`deleteByInstanceFileId`
(bytes best-effort, row authoritative),
`deleteByApplicationRegistrationId` (lifecycle hook for
registration-owned files)
- same driver path as `FileStorageService` (`FileStorageDriverFactory` →
`ValidatedStorageDriver`)

**Migration**: fast instance command `add-instance-file-table` (2.19,
generator-produced; post-command `database:migrate:generate` reports no
pending changes).

## Next PRs in the plan

- PR 2: HTTP serving + token type for instance files (new route + guard;
workspace file endpoints untouched)
- PR 3: application-registration manifests stored as versioned instance
files (supersedes draft #22556)
- PR 4 (optional): registration tarballs migrate to instance scope,
removing the cross-workspace `FileEntity` read in
`application-package-fetcher` and the `ownerWorkspaceId` requirement on
`uploadTarball`

## Verification

- New specs: scope-validation util (traversal cases) + service (upsert
conflict, missing-file error, best-effort byte deletion, registration
cascade) — 16/16; `npx jest "application"` still 31 suites / 160 green
- Typecheck, `lint:diff-with-main`, full `oxfmt --check src/` (6421
files) and full type-aware oxlint clean
- Fast command executed against the local DB — table, unique index, and
CASCADE FK verified via psql; generator then reports no schema drift
- Server boots with the new provider; `generate-metadata-client
--skip-nx-cache` zero diff (no GraphQL change)

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22560?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 12:23:42 +02:00
Raphaël Bosi 11edd56505 Don't list headless front components in the widget picker (#22578)
Front components can be marked headless (`isHeadless: true`), meaning
they render no UI and only run logic. Both the record-page and dashboard
widget pickers were listing every front component, including headless
ones, which have nothing to render as a widget.

This filters out headless front components where each picker reads them
from `FIND_MANY_FRONT_COMPONENTS`. The downstream select-item mapping,
keyboard-navigation list, and the "Front Components" group guard all
derive from that array, so filtering once excludes them everywhere and
hides the section when every front component is headless.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22578?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 12:20:05 +02:00
Abdul Rahman faaeeee6f2 refactor(server): remove nestjs-query from key-value-pair module (#22575)
## Summary

First step toward removing `@ptc-org/nestjs-query` from the codebase
(follow-up to the `indexFieldMetadatas` DI bug
[discussion](https://github.com/twentyhq/twenty/pull/22439#issuecomment-4864265452)).

The `key-value-pair` module wrapped its entity in
`NestjsQueryGraphQLModule.forFeature`, but registered **no resolvers** —
the `KeyValuePair` type is exposed in no GraphQL schema, and
`KeyValuePairService` only uses a plain TypeORM repository. The
nestjs-query layer was doing nothing except registering that repository
as a side effect.

## Changes

- Replace the empty `NestjsQueryGraphQLModule.forFeature({...})` wrapper
with a plain
  `TypeOrmModule.forFeature([KeyValuePairEntity])`
- Swap the entity's `@IDField` (nestjs-query) for the standard `@Field`
from `@nestjs/graphql`

`nestjs-query` is no longer referenced anywhere under `key-value-pair/`.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22575?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 12:19:24 +02:00
Raphaël Bosi 4cd05b2b3e Reverse pinned command menu item order on record header (#22577)
## Before
<img width="502" height="102" alt="CleanShot 2026-07-06 at 12 10 42@2x"
src="https://github.com/user-attachments/assets/da186911-5a1f-446f-a590-1af4a191554f"
/>

## After
<img width="500" height="102" alt="CleanShot 2026-07-06 at 12 10 17@2x"
src="https://github.com/user-attachments/assets/6e7bb914-55af-4968-a15a-fb17378fffc7"
/>

Pinned command menu items in the record page header rendered
left-to-right by position, putting the first item on the left. They
should read the other way: first item on the right, last on the left.

Fixed with `flex-direction: row-reverse` on the items container so the
reversal is purely visual. The DOM/source order stays in position order,
so the responsive overflow logic still keeps the highest-priority items
visible and keyboard/screen-reader order is unaffected.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22577?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 12:17:30 +02:00
martmull e5e3fadbbb feat(files): reap stale pending direct-upload files via hourly cron (#22531)
## Context

Follow-up to #22449 (direct-to-storage upload endpoints). That PR
introduced the `PENDING` → `UPLOADED` file lifecycle: `createFileUpload`
inserts a file record in `PENDING`, the client uploads the bytes
directly to storage, then `completeFileUpload` flips it to `UPLOADED`.

A client that initiates an upload but never confirms — a crash, a closed
tab, an expired presigned URL — leaves a `PENDING` file record and a
possibly-partial storage object behind forever. This PR reaps them.

## What this does

Adds an hourly cron that hard-deletes `PENDING` files older than 24h
together with their storage objects, in bounded batches.

- **`PendingFileCleanupService`** — finds `PENDING` files with
`createdAt` older than `PENDING_FILE_MAX_AGE_MS` (24h), capped at
`PENDING_FILE_CLEANUP_BATCH_SIZE` (200) per run, and deletes each via
`FileStorageService.deleteByFileId` (which tolerates a missing object).
A failure on one file is logged and skipped so the rest of the batch
still gets cleaned.
- **`PendingFileCleanupCronJob`** — `@Processor(cronQueue)` job that
runs the service and reports exceptions.
- **`PendingFileCleanupCronCommand`** — registers the job on the hourly
pattern (`0 * * * *`).
- Wired into `FileUploadModule` (providers + export) and registered in
`cron:register:all`.

### Why 24h

The reaper threshold sits well past the presigned URL expiry, so a
`PENDING` file only becomes reapable long after any legitimate in-flight
upload could still complete — the cleanup can never race a real upload.
A file that was never confirmed is referenced by nothing; the client
recovery path is simply re-uploading under a fresh `fileId`, so we never
promote to `UPLOADED`.

## Tests

`pending-file-cleanup.service.spec.ts` covers: the query shape (status +
age threshold + batch cap), deleting each stale file and returning the
count, continuing past a per-file deletion failure, and the empty-batch
no-op.

## Scope

Server-only, non-breaking, no user-facing change. Part of the
incremental direct-upload rollout being split into small PRs.

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22531?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 08:39:32 +00:00
martmull d6b6962604 feat(files): content-verify direct uploads and pin pending files to octet-stream (#22533)
## Context

Follow-up to #22449 (direct-to-storage upload endpoints). In that flow
`createFileUpload` inserts a `PENDING` file record before any bytes
exist, and until now it guessed the mime type from the **filename
extension** — an untrusted, client-controlled value. This PR makes a
pending file opaque and only trusts a mime type that was verified
against the actual stored bytes.

## What this does

**1. A pending file is always `application/octet-stream`.**
`createFileUpload` records the pending file — and signs the presigned
PUT — as `application/octet-stream`. The extension is still kept on the
stored object name so the content can be checked against it later.

**2. Content verification at completion.**
`completeFileUpload`, after the existing size check, reads a **bounded
prefix** of the stored object (`readReadablePrefix`, capped at 64 KiB —
a large object is never buffered in full) and runs the existing
`extractFileInfoOrThrow` util to detect the real mime type from the
content. It:
- writes the detected type alongside `status = UPLOADED`, and
- rejects a file whose bytes don't match its declared extension (the
record stays `PENDING`, so it can never be served or attached, and is
reaped by the pending-file cleanup cron).

Serving already overrides `Content-Type` from the DB record, so storing
the object as octet-stream is fine.

**3. A database constraint as backstop.**
`CHK_FILE_PENDING_MIME_OCTET_STREAM` — `"status" != 'PENDING' OR
"mimeType" = 'application/octet-stream'` — added to `FileEntity` and
applied by a fast instance command (`2-19`). It is added `NOT VALID` on
purpose: an instance freshly upgraded past #22449 may still hold
`PENDING` rows whose mime came from the old extension-guess path, and
`NOT VALID` enforces the invariant on every new/updated row without
failing on that legacy backlog (those rows get overwritten to
octet-stream when completed — `status` flips to `UPLOADED`, so the check
passes — or are reaped while pending).

## Tests

- `read-readable-prefix.spec.ts` — prefix reader: short source, early
stop on a large source (asserts it tears the stream down without
draining it), error propagation, empty stream.
- `file-upload.service.spec.ts` — create records octet-stream; complete
sniffs and sets the detected type, overrides a spoofed extension with
the real content type, and rejects content that can't be matched to the
declared extension.
- `direct-file-upload.integration-spec.ts` — end-to-end case rejecting a
`.png` upload whose bytes are plain text.

## Verification

`typecheck` green, `lint:diff-with-main` clean, unit suites pass (17
tests). No GraphQL schema change, so no codegen drift.

## Scope

Server-only, part of the incremental direct-upload rollout being split
into small PRs. Independent of the reaper-cron PR (#22531).

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

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

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

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

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-07-06 09:25:31 +02:00
nitin fe8ec0463a Call recorder: parse conference links from calendar event text (#22555) 2026-07-05 17:44:13 +02:00
Matt Van Horn 3cd4498bb2 fix: convert Microsoft calendar event HTML body to plain text description (#22540)
## Summary
Calendar events synced from Microsoft accounts now show a readable
plain-text description instead of raw HTML. Microsoft Graph returns
event bodies as HTML by default; the importer stored
`event.body.content` verbatim, so descriptions rendered as markup soup
in the record page and the event drawer.

## Why this matters
Issue #22537 reports Microsoft-synced calendar events displaying full
`<html><body>...` content in the description field. Google Calendar
events don't have this problem because Google returns plain text. The
fix converts HTML bodies with the `html-to-text` package that's already
a `twenty-server` dependency (used the same way in
`packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/utils/format-text-body.utils.ts`
for email bodies), then normalizes line endings, non-breaking spaces,
and blank-line runs. Non-HTML bodies pass through unchanged, and the
existing `sanitizeCalendarEvent` step still runs afterwards.

## Testing
Added specs to `format-microsoft-calendar-event.util.spec.ts` covering
HTML-to-text conversion with `<br>` and block elements, HTML entity
decoding and `&nbsp;` handling, empty/null bodies, and text-body
passthrough.

Fixes #22537


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22540?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: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: neo773 <neo773@protonmail.com>
2026-07-05 19:23:05 +05:30
Félix Malfait 3a21089e0a feat(server): abort ai stream jobs that outlive the shutdown drain budget (#22517)
## Why

#22514 makes SIGTERM drain workers, but `worker.close()` waits for
active jobs with **no upper bound** (BullMQ semantics). An AI stream job
can run for 10 minutes; a deploy would either hang the rollout or hit
the pod's termination grace deadline and get SIGKILLed anyway — back to
the frozen-stream + stalled-rerun failure this series eliminates.

## What

On shutdown, `aiStreamQueue` gets a bounded drain: active stream jobs
have `AI_STREAM_SHUTDOWN_DRAIN_MS` (60s) to finish naturally; stragglers
are then aborted and terminate exactly like a stream failure —
`lastStreamError` persisted with the new typed
`AiExceptionCode.STREAM_INTERRUPTED`, the pinned `stream-error` →
`queue-updated` terminal sequence published, claim released. The client
shows the interrupted state with Retry (#22434) within seconds instead
of a stream frozen mid-sentence. This is deliberately **not** the
user-cancel path, which resolves cleanly and persists no error.

Mechanism — evaluated BullMQ 5.78's native cancellation vs a parallel
in-process registry, and picked native:

- The driver's processor now declares the 3-arg signature, which makes
BullMQ create a per-job `AbortController` (`processor.length >= 3` is
the trigger), and the signal is handed to job handlers as an optional
`MessageQueueJobContext`.
- `worker.cancelAllJobs()` is purely cooperative: it aborts the signal
and nothing else, so the job's own persist/publish/cleanup still runs to
completion and `worker.close()` still waits for it — no force-fail race,
no second signaling channel to maintain, and the timer lives inside the
same `closeWorker()` call so there is no dependence on Nest
module-destroy ordering.
- The stream job maps the shutdown signal onto its **existing**
AbortController (the one already wired through the AI SDK for user
cancel), with an `AiException(STREAM_INTERRUPTED)` reason to tell the
two apart. One abort path end to end, no new infrastructure.

Error-type choice: the job throws a plain `AiException`, not BullMQ's
`UnrecoverableError`. Stream jobs are enqueued with `attempts: 1` (no
`retryLimit`), so there is no BullMQ retry to suppress — retryability
for this queue lives at the app layer (`lastStreamError` + client
Retry), and an `UnrecoverableError` would only obscure the typed
exception.

`STREAM_INTERRUPTED` also replaces the string constant introduced on the
base branch (#22482's reap now uses the same enum member) — one code,
two producers (reap for dead workers, abort for live shutdowns),
identical client behavior.

Notes:
- 60s is a static constant mirroring `AI_STREAM_LOCK_DURATION_MS` rather
than an env var — it has to move in lockstep with the worker's
`terminationGracePeriodSeconds` (120s, twenty-infra PR) anyway, and we
ship multiple releases a day. Happy to lift it into a config variable if
you want runtime tunability.
- The `onModuleDestroy` scaffolding (drain logs,
workers-close-before-queues) deliberately matches #22514; whichever
lands second rebases clean.

Stacked on #22482 (needs the heartbeat/reap base). Merge order: #22482 →
this. Depends on #22514 for SIGTERM to reach the driver at all.

## Validation

- `stream-agent-chat.job.spec.ts`: shutdown-abort persists
`STREAM_INTERRUPTED`, publishes `stream-error` before `queue-updated`,
releases the claim, skips the queued-message flush; user-cancel
semantics unchanged with a wired-but-idle shutdown signal (60/60 ai-chat
tests green).
- Local end-to-end: real AI stream mid-flight, SIGTERM the worker →
drain window → abort → interrupted state persisted, process exits on its
own. (Transcript in the PR conversation.)


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22517?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-05 14:15:24 +02:00
neo773 904957ea1e message campaign redesign (#22508)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22508?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-05 13:44:34 +02:00
github-actions[bot] e90ab56c7a chore: sync AI model catalog from models.dev (#22558)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

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

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-07-05 09:03:02 +02:00
Thomas des Francs a4ed561e11 Add focus-safe side panel shortcuts (#22499)
## Summary

- Add side-panel-owned Escape and Backspace behavior for the side-panel
search input.
- Keep side-panel Escape scoped to side-panel focus and avoid
left-content fallback behavior.
- Add a Side Panel group to the keyboard shortcut menu.
- Reuse the side-panel focus id for AI chat thread-list shortcuts.


## Demo


https://github.com/user-attachments/assets/80a632d6-7ff7-496b-905f-a3f95f9cfc14

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-07-05 01:19:39 +02:00
neo773 f14e62ec0c Run integration tests against the real BullMQ driver (#22551)
Migrate whole suite to real BullMQ

Shard times unchanged, still 5-6 min.
2026-07-05 00:30:21 +02:00
martmull 4e43a0fb4e refactor(server): regroup application resolvers by resource and unify install permission flag (#22532)
Part of the app settings architecture cleanup
(twentyhq/core-team-issues#2456) — implements the API-surface regroup
Charles asked for in #20825 ("in application resolvers we have
uninstall, upgrade, findMany, etc. and for some reason install is part
of the marketplace, and they are not protected by same guards").

## Changes

**Resolver regroup by resource** (GraphQL operation names and signatures
unchanged):
- `installApplication` + `installMarketplaceApp` (deprecation preserved)
move from the marketplace resolver into
`application-install.resolver.ts`, next to
`findManyApplications`/`findOneApplication`/`uninstallApplication`.
- `uninstallApplication` moves from the manifest resolver into
`application-install.resolver.ts`.
- `runWorkspaceMigration` is deleted outright (unused — no consumer
anywhere in the repo, front/SDK/e2e/docs); the now-empty manifest
resolver is deleted. Its `AllMetadataName` GraphQL enum registration
moves to `collection-hash.dto.ts` (its remaining consumer).
- `generateApplicationToken` moves from the development resolver into
`application-oauth.resolver.ts` next to `renewApplicationToken`, keeping
its effective guards (`WorkspaceAuthGuard` +
`SettingsPermissionGuard(APPLICATIONS)`) and its token-bucket throttle
verbatim.
- `upgradeApplication` stays in the upgrade resolver (moving it into the
install resolver would create a module cycle — the upgrade module
imports the install module).
- Marketplace resolver now only holds catalog concerns:
`findManyMarketplaceApps`, `findMarketplaceAppDetail`,
`syncMarketplaceCatalog`.

**Permission unification** (the only behavior change):
`installApplication`, `installMarketplaceApp` and `upgradeApplication`
move from `MARKETPLACE_APPS` to `APPLICATIONS`, matching uninstall and
the find queries. Front-end install/upgrade button gating updated
accordingly (`SettingsApplicationDetails` /
`SettingsAvailableApplicationDetails`).

**Module wiring**: `MarketplaceModule` no longer imports
`ApplicationInstallModule` (only the moved resolver needed it);
`ApplicationInstallModule` now imports `MarketplaceModule` — no cycle.
Exception filters follow the moved operations
(`ApplicationRegistrationExceptionFilter` on the install resolver;
`ApplicationExceptionFilter` on the oauth resolver, which also fixes
`renewApplicationToken`'s previously unmapped FORBIDDEN).

**Codegen**: `twenty-client-sdk` metadata client regenerated for the new
schema ordering (pure reordering — no field changes); all front
`graphql:generate` configurations produced zero diffs.

## Explicitly kept (per review discussion)

`installMarketplaceApp` (deprecated) and `generateApplicationToken` are
kept for SDK back-compat despite having no current consumers.
`runWorkspaceMigration` was also consumer-less but, unlike those two,
had no back-compat rationale (not a deprecated alias, not a token
primitive), so it is removed rather than relocated.

## Deferred follow-ups (guard inconsistencies found in the audit,
intentionally NOT changed here)

- `findApplicationRegistrationByUniversalIdentifier` uses
`NoPermissionGuard` and returns the full registration entity, bypassing
the `API_KEYS_AND_WEBHOOKS` gate that `findOneApplicationRegistration`
enforces on the same data (SDK CLI `ensure-app-registration` depends on
it today).
- `upgradeApplication` alone requires `UserAuthGuard` — an API key can
install but not upgrade.
- `uploadAppTarball` (`MARKETPLACE_APPS`) and
`transferApplicationRegistrationOwnership` (`APPLICATIONS`) are
flag-inconsistent with the rest of registration CRUD
(`API_KEYS_AND_WEBHOOKS`).
- `syncMarketplaceCatalog` triggers an instance-wide job but is gated
only by a per-workspace settings flag.

## Verification

- `npx nx typecheck twenty-server` / `twenty-front` ✓;
`lint:diff-with-main` clean for both
- `npx jest "application"` in twenty-server: 30 suites / 154 tests
passed
- Server boots with the new module graph (DI verified at runtime);
codegen run against the live server
- Repo-wide grep: no remaining imports of the deleted manifest resolver

<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22532?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>

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-07-05 00:24:12 +02:00
Thomas des Francs 59c16ef46f Polish settings billing and MCP UI (#22554)
## Summary

- Polish Billing credits progress rounding and secondary action styling.
- Update MCP setup logos, card spacing, grouping, and badge color.

## Before/After

MCP & APIs

<img width="2258" height="2010" alt="MCP & APIs settings visual"
src="https://github.com/user-attachments/assets/915c8b50-5b98-4ba9-8e5c-a33f36440f6e"
/>

Billing

<img width="2240" height="1644" alt="Billing settings visual"
src="https://github.com/user-attachments/assets/9e3eb332-5792-4a4a-80b0-1b984e574008"
/>
2026-07-04 23:27:40 +02:00
martmull ea851f7d9c feat: expose marketplace app detail fields explicitly and deprecate manifest blob (#22526)
Part of the application settings architecture work:
https://github.com/twentyhq/core-team-issues/issues/2456 — follow-up to
#22513.

`MarketplaceAppDetail` returned the entire `manifest` jsonb (100KB+)
over GraphQL and the front dug display fields and roles out of it. This
PR:

- Adds explicit fields to `MarketplaceAppDetail`: `description, author,
category, logo, websiteUrl, aboutDescription, termsUrl, emailSupport,
issueReportUrl, screenshots, defaultRoleUniversalIdentifier`, sourced
from the registration columns introduced in #22513, and `roles:
[MarketplaceAppRole!]` (full permission shape — the permissions tab and
install modal render object/field permissions), sourced from the
manifest at detail time.
- Marks the `manifest` field `@deprecated` (kept functional — removal
would be a breaking change).
- Front: the shared `marketplaceAppDetailFragment` no longer selects
`manifest`; display and role reads are flattened across
`SettingsAvailableApplicationDetails`, `SettingsApplicationDetails`, and
the share-link buttons. The three consumers that genuinely need deep
manifest structure (content-tab counts/`manifestContent`, permissions
objects, `useApplicationManifest` page-layout/view reads) use a scoped
`FindMarketplaceAppManifest` query until the manifest demotion PR
removes that need.
- Codegen regenerated where the documents live: front metadata config +
twenty-client-sdk metadata client (data/admin configs verified
untouched).

Verified: server+front typecheck, lint (0 warnings), server marketplace
suite 10/10, front marketplace/applications suites 41/41, live schema
introspection confirms the new fields and the deprecation.

<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22526?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>
2026-07-04 23:26:53 +02:00
martmull 3db09e4423 feat(server): refresh application registration on install (#22527)
Part of the app settings architecture cleanup
(twentyhq/core-team-issues#2456) — unifies registration ingestion across
sources.

## Problem

The dev sync, catalog sync and tarball upload flows all refresh the
`applicationRegistration` row (manifest + display columns) at ingestion
time, but the install/upgrade flow never did. Installing or upgrading an
app relied on catalog sync having run beforehand, so a registration
could serve stale display data (name, logo, description, screenshots…)
after an install that shipped a newer manifest.

## Changes

- `doInstallApplication` now refreshes the global registration from the
resolved manifest after all install steps succeed (post-install hook
included, so a hook failure that triggers uninstall can't leave the
registration refreshed for a failed install).
- Downgrade guard: the refresh is skipped when the installed version is
provably older than `latestAvailableVersion` (per-workspace installs of
an older version never downgrade the global registration). Extracted as
a pure util `shouldRefreshApplicationRegistrationOnInstall` with unit
tests:
  - `latestAvailableVersion` null or invalid semver → refresh
- installed ≥ latest → refresh, and `latestAvailableVersion` is bumped
to the installed version
- installed < latest, or installed not valid semver while latest is →
skip
- Asset URLs mirror the existing per-source ingestion behavior: NPM
registrations get manifest `logoUrl`/`screenshots` resolved to registry
CDN URLs (same as catalog sync); tarball and other sources persist the
manifest as-is (same as tarball upload).
- `updateFromManifest` gains an optional `latestAvailableVersion` param
(same conditional-spread style as `sourceType`).
- `ApplicationRegistrationModule` added to `ApplicationInstallModule`
imports (no cycle: nothing in the registration module's import graph
imports the install module).

The dev sync flow (`syncRegistrationMetadata`) already goes through
`updateFromManifest` and writes the display columns — verified, no
change needed.

## Verification

- New unit spec: 6 cases on the guard util
- `npx jest "application-registration|application-install|marketplace"`
→ 3 suites, 21 tests passed
- `npx nx typecheck twenty-server` → success
- `npx nx lint:diff-with-main twenty-server` → clean

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22527?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: Charles Bochet <charles@twenty.com>
2026-07-04 22:45:12 +02:00
Charles Bochet 99f99adf8f fix(server): require confidential client auth in authorization_code grant (#22548)
## Summary

Closes a **confidential-client authentication bypass** in the OAuth
`authorization_code` grant.

`OAuthService.exchangeAuthorizationCode` only validated `client_secret`
**when one was supplied** (`if (clientSecret)`), and the fallback check
at the end (`if (!clientSecret && !storedCodeChallenge)`) treats a valid
PKCE `code_verifier` as sufficient to complete the exchange. As a
result, a **confidential client** — one registered with a
`client_secret` (`oAuthClientSecretHash` set) — could have its
authorization codes redeemed using PKCE alone, with **no client
authentication**.

PKCE is defense-in-depth for public clients; it is not a substitute for
authenticating a confidential client (RFC 6749 §4.1.3, OAuth 2.1
§4.1.3). The `refresh_token` grant already enforces this exact rule —
this PR mirrors that gate in the `authorization_code` grant so any
client issued a secret must always present it.

## The fix

```ts
// Confidential clients (those issued a secret) must always authenticate,
// even when PKCE is used.
if (applicationRegistration.oAuthClientSecretHash && !clientSecret) {
  return this.errorResponse(
    'invalid_client',
    'Client authentication required for confidential clients',
  );
}
```

The check runs immediately after client resolution and before the
authorization code is even looked up. Public (PKCE-only) clients — those
without a stored secret hash — are unaffected.

## Testing

Added `oauth.service.spec.ts` covering:
- **Regression:** a confidential client presenting only PKCE and no
`client_secret` is rejected with `invalid_client` before any code
lookup.
- A wrong `client_secret` for a confidential client is still rejected.
- A public (PKCE) client is **not** blocked by the new gate and proceeds
to the code lookup.

Verified the regression test fails without the fix and passes with it.
Existing `application-oauth` suites remain green (8/8). Lint (`oxlint
--type-aware`, `oxfmt`) clean; the touched files typecheck.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22548?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-04 20:36:03 +02:00
Charles Bochet 74b3a4216e chore(apps): remove the twenty-for-twenty app (consolidated into twenty-eng) (#22549)
Removes the internal `twenty-for-twenty` app from this public repo. It
has been consolidated into the private `twenty-eng` monorepo, which now
also owns the Resend module (moved there in a companion PR).

- Removes `packages/twenty-apps/internal/twenty-for-twenty/**` (177
files).
- No build wiring referenced it (no nx project, not in `nx.json`/root
workspaces); the only mention elsewhere is a naming-convention comment
in `twenty-linear`.

## ⚠️ Sequencing
- This removes **source only** — it does **not** uninstall the app
currently deployed on the workspace. Merge only **after** the twenty-eng
app has taken over the Resend objects there, so the live integration
isn't left orphaned.
- Supersedes the migration-plan doc PR (#22546), which added a doc into
this now-removed directory; that doc now lives in the twenty-eng app.
#22546 can be closed.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22549?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-04 18:05:30 +02:00
Thomas des Francs 1a60d4eaa3 Add MCP setup screen (#22468)
## Summary

- Add a first-tab MCP setup experience under MCP & APIs with quick
install cards, manual configuration, client logos, and HTTPS gating for
Claude install links.
- Rename API/Webhooks settings surfaces to MCP & APIs and update related
icons, permissions, breadcrumbs, and command menu entries.
- Add the Tabler sparkle-2 icon wrapper and MCP setup visual assets.

## Screenshots

| Before | After |
| --- | --- |
| ![Before: APIs & Webhooks MCP
tab](https://gist.githubusercontent.com/Bonapara/f8a97d31fbc3cab2771d18cbacd53d4c/raw/5838d123bc3aae3df0d37507b3e69135bec86444/before-mcp-settings.png)
| <img alt="image"
src="https://github.com/user-attachments/assets/a6ae2ae6-322b-4370-b9b6-0a3d73ff7fa7"
/> |

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22468?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: Charles Bochet <charles@twenty.com>
2026-07-04 16:38:59 +02:00
Charles Bochet cfe0fc7ce6 feat(ci): detect bot signatures in PR description, comments and reviews (#22547)
## What

Extends the **Blocked Contributors Check** beyond commits so it also
scans a PR's:

- **Description** (PR body)
- **Conversation comments**
- **Inline review comments**
- **Review summaries**

## Why

The check already fails a PR when a commit is attributed to a known bot
(via author/committer/email and `Co-Authored-By` trailers). But
bot-generated content also leaks into PR prose — descriptions and
comments carry attribution footers like `🤖 Generated with Claude Code`
that the commit-only scan never saw.

## How

- **Commits** keep matching on bot *identity* (`IDENTITY_PATTERNS`:
`@anthropic.com`, `cursoragent@cursor.com`, `copilot-swe-agent[bot]`).
- **Prose surfaces** are matched only on `SIGNATURE_PATTERNS` — the
verbatim auto-generated attribution footers (`Generated with Claude
Code`, `Co-Authored-By: Claude`, Cursor equivalents). This is
deliberately tight: contributors legitimately discuss Claude/Cursor in
comments, so a bare product-name mention must **not** trip the check.
Verified that "I used Claude Code to draft this but rewrote it", "works
great in Cursor", and human `Co-Authored-By` lines all stay clean while
real footers flag.
- The workflow now also triggers on `issue_comment`,
`pull_request_review` and `pull_request_review_comment` (plus PR
`edited`), so bot prose added *between* commit pushes is still caught.
`issue_comment` is guarded to PRs only, and `PR_NUMBER` resolves from
either event.
- Each prose violation reports the surface kind and a clickable URL.

## Notes

`SIGNATURE_PATTERNS` are conservative by design and won't catch a footer
someone reworded by hand. Widening them is a follow-up if we decide to
trade some false positives for broader coverage.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22547?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-04 15:11:32 +02:00
avonian 26491ecdc6 fix(front): don't blank record title when the title cell is untouched (#22293)
## Problem

On a record show page, the record title (label identifier) can be
silently blanked. Repro:

1. Create a record, set its name, set a relation field (e.g. a
one-to-one/many relation).
2. Reload the page.
3. Click the **edit** affordance on the relation field.

→ The record's name clears to “Untitled”, and it persists (an
`updateOne` fires with `input: { name: "" }`).

## Root cause

`RecordTitleCellTextFieldInput` registers `onClickOutside` / `onEnter` /
`onTab` / `onShiftTab` and always forwards `draftValue ?? ''` to be
persisted. When the title cell is in edit mode but the user never typed
in it, `draftValue` is `undefined`, so the forwarded value is `""`.

Opening another field's input counts as a click-outside on the title
cell, which then persists `{ name: "" }` over the existing label
identifier.

## Fix

Skip persisting when the title draft is untouched (`draftValue ===
undefined`) by passing `skipPersist` on the blur-style events. An
unedited title can no longer overwrite the existing label identifier;
genuine edits set the draft and persist exactly as before.

## Test plan

- Repro above: name no longer clears when editing a relation after
reload.
- Creating a new record and naming it still works.
- Renaming an existing record via its title still works.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22293?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: Charles Bochet <charles@twenty.com>
2026-07-04 13:09:21 +00:00
Charles Bochet 6b2405c2e9 feat(twenty-for-twenty): bump to SDK 2.18 with local UI components (#22542)
## What & why

Bumps the **Twenty for Twenty** internal app to the current SDK line and
serves it on the matching `twenty-app-dev` image.

- `twenty-sdk` / `twenty-client-sdk`: `^2.14.0` → **`^2.18.0`** (npm
latest)
- Runs against `twentycrm/twenty-app-dev:v2.18.5` (same 2.18 line)
- App version `0.1.0` → **`0.2.0`**

### The `twenty-sdk/ui` break

twenty-sdk 2.18 **removed the `twenty-sdk/ui` subpath** (#22326, "Remove
twenty-ui reexport from the SDK"). The intended replacement —
`twenty-ui@1.0.0-alpha.1` subpaths — requires **React 19 + a
monaco-editor peer** that this React-18 app can't adopt, so the app no
longer builds against 2.18 as-is.

Instead of migrating to twenty-ui, this replaces the four
`twenty-sdk/ui` consumers with **self-contained local components** under
`src/ui/`, imported via a new `@ui` alias:

- `Callout`, `H2Title`, `Status`
- Tabler-style inline-SVG icons (`IconAlertCircle`, `IconInfoCircle`,
`IconMail`, `IconRefresh`, `IconHelp`)
- a `ThemeColor` type

They mirror the twenty-ui components 1:1 using the `--t-*` theme CSS
variables the front-component host injects (the same inline-style
pattern the app already used for theme tokens) — no new runtime deps, no
React 19 requirement.

## Test plan

- `twenty dev:build` ✓, `yarn typecheck` ✓, `yarn lint` ✓
- Synced into a local `twenty-app-dev:v2.18.5` container (`twenty dev
--once`) — objects/fields/views/app install applied cleanly
- Verified rendering live: Sync Status page (`H2Title` headings +
`Status` "Not synced" pills) and all three `Callout` variants
(error/info/neutral) with correct colors + icons

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22542?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-04 12:34:41 +02:00
Miguel 873f8ad24c feat(helm): parametrize liveness, readiness and startup probes for server (#22268)
Closes #22267
 
## What
 
Exposes `startupProbe`, `livenessProbe` and `readinessProbe` as
configurable values under `server.*` in `values.yaml`, with sensible
defaults that work out of the box on current Twenty releases. Also
switches the probe path from `/` to `/healthz`.
 
## Why
 
The probes are hardcoded in the chart template today, and the defaults
are no longer realistic for the current product.
 
On a clean install of `v2.16.1` the server takes about **111 seconds**
to reach `Nest application successfully started`. The current hardcoded
`livenessProbe` only gives the pod **110 seconds** before killing it
(`initialDelaySeconds: 60` + `failureThreshold: 5` x `periodSeconds:
10`). The pod is killed roughly 1 second before it would have been
healthy and the deployment enters `CrashLoopBackOff` indefinitely.
 
Twenty's boot time grows release by release as new Nest modules are
added (v2.16 already registers 16 minor versions worth of upgrade
commands at startup), so the chart's hardcoded defaults will keep
drifting away from a working configuration.
 
The probe path `/` returns the SPA HTML (or a 404 depending on routing),
not a health response. The correct endpoint is `/healthz`, which returns
`{"status":"ok","info":{},"error":{},"details":{}}` from a dedicated
Nest controller.
 
## How
 
Uses the same `{{- with }}` pattern already present in the chart
(`extraEnv`, `extraVolumeMounts`, and the four scheduling fields added
in #22233):
 
```yaml
{{- with .Values.server.startupProbe }}
startupProbe:
  {{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.server.livenessProbe }}
livenessProbe:
  {{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.server.readinessProbe }}
readinessProbe:
  {{- toYaml . | nindent 12 }}
{{- end }}
```
 
This pattern lets the user disable any probe by setting it to `null`,
override individual fields by providing the full block, or fall back to
the defaults shipped in `values.yaml`.
 
## Defaults
 
```yaml
server:
  startupProbe:
    httpGet:
      path: /healthz
      port: http-tcp
    periodSeconds: 10
    failureThreshold: 30   # 5 minutes total boot grace
  livenessProbe:
    httpGet:
      path: /healthz
      port: http-tcp
    periodSeconds: 30
    failureThreshold: 3
  readinessProbe:
    httpGet:
      path: /healthz
      port: http-tcp
    periodSeconds: 10
    failureThreshold: 3
```
 
## Scope
 
This PR only touches the server Deployment. The worker Deployment is
intentionally out of scope: it does not expose HTTP, had no probes
before this change, and any probe added there would need a different
shape (`exec` or `tcpSocket`). It can be addressed in a follow-up if
maintainers want it.
 
## Backward compatibility
 
For any cluster that booted Twenty correctly with the previous defaults
(boot time under 5 minutes), `helm template` output is functionally
equivalent: the new `startupProbe` covers the boot window, then
`livenessProbe` and `readinessProbe` take over with similar semantics.
 
For clusters where the previous defaults were already failing (such as
this one — see "Validation" below), the new defaults make the install
work out of the box.
 
Setting any probe value to `null` disables that probe entirely.
 
## Schema note
 
`values.schema.json` updated with `startupProbe`, `livenessProbe` and
`readinessProbe` under `server`, all typed as `["object", "null"]` to
honour the disable-by-null contract.
 
## Validation
 
- `helm lint` passes.
- `helm template` with default values renders the three probe blocks on
the server Deployment.
- `helm template` with one probe set to `null` correctly omits that
probe.
- `helm template` with overridden values renders the user-supplied probe
configuration.
- Live install validated on a multi-node Kubernetes cluster running
Twenty v2.16.1 on an Oracle Cloud ARM64 worker node. With the new
`startupProbe` the server reaches `Ready 1/1` in around 2 minutes from
fresh pod creation. With the previous hardcoded probes the same pod
entered `CrashLoopBackOff` indefinitely (276 restarts in 21 hours
observed before applying the fix).
````
 
## Files touched
 
- `packages/twenty-docker/helm/twenty/templates/deployment-server.yaml`
- `packages/twenty-docker/helm/twenty/values.yaml`
- `packages/twenty-docker/helm/twenty/values.schema.json`
## Related
 
- Discovered while validating PR #22233 (nodeSelector / tolerations / DNS overrides).
- Same chart, same pattern, same self-host audience.
- Closes #22267.

<!-- This is an auto-generated description by cubic. -->
<a href="https://cubic.dev/pr/twentyhq/twenty/pull/22268?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: Charles Bochet <charles@twenty.com>
2026-07-04 11:48:49 +02:00