Commit Graph

2596 Commits

Author SHA1 Message Date
Abdul Rahman 4ab36a630b remove nestjs-query from app-token and drop unused createOneAppToken mutation (#22765)
## What

Migrates `app-token` off `@ptc-org/nestjs-query` and removes the
auto-generated `createOneAppToken` mutation, which was unused dead API
surface.

## Why

The `createOneAppToken` mutation was reachable only from the schema — no
frontend query, SDK caller, or test used it. It was also non-functional
(its input couldn't set the token `value`), a leftover from
nestjs-query's default `create.one` being left enabled. Real app tokens
(refresh, password-reset, email-verification, invitation, OAuth,
enterprise) are all created directly via the repository in ~14 services,
none of which touched this mutation.

## Notes

- ⚠️ This removes `AppToken`, `createOneAppToken`,
`CreateAppTokenInput`, and `CreateOneAppTokenInput` from the `/metadata`
schema, so the **api-breaking-changes check will flag it**


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22765?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: Weiko <corentin@twenty.com>
2026-07-10 16:14:13 +05:30
Paul Rastoin 9e20e2222a Fix front-component serving on Safari, kill stale presigned caching, and cache built bundles client-side (#22672)
## Context

Built front-component bundles are served via `GET
/rest/front-components/:id/:cacheKey`. On S3-backed storage (Twenty
Cloud) the endpoint used to 302-redirect the worker's authenticated
fetch to a presigned S3 URL. That redirect caused two bugs, and fixing
it removed the caching the redirect was accidentally providing — so this
PR also adds a proper client-side cache.

Closes twentyhq/core-team-issues#2653.

### Bug 1 — Safari 403 (Authorization header forwarded across redirect)

The renderer worker fetches the bundle with `Authorization: Bearer`. The
controller answered with a 302 to a presigned S3 URL. Per the Fetch
spec, browsers must strip `Authorization` on a cross-origin redirect.
Chrome/Firefox do, but Safari/WebKit forwards it, so S3 receives both a
query-string signature and an `Authorization` header and rejects with
`InvalidArgument: Only one auth mechanism allowed`. Result: front
components never load in Safari on S3-backed storage.

### Bug 2 — 302 cached publicly (browser-independent)

The redirect branch set no `Cache-Control`, so a CDN could cache it far
beyond the presigned URL's TTL (`STORAGE_S3_PRESIGNED_URL_EXPIRES_IN`,
900s). Consequences: any client re-served the cached 302 after 15 min
hits an expired signature (403, also affects Chrome), and the cached
redirect containing a live presigned URL is served to unauthenticated
requests (short-lived auth bypass).

### Regression this introduces — warm-load caching lost

Marking the handoff `no-store` (Bug 2 fix) is correct, but it means the
built bundle is no longer cached anywhere on the S3 path. The browser
HTTP cache cannot compensate: the presigned URL that actually returns
the bytes carries a fresh `X-Amz-Date`/`X-Amz-Signature` on every
request, so each download is a brand-new cache key and never hits. Net
effect without mitigation: every worker mount re-downloads the full
bundle.

## What changed

- **Front components return a 200 JSON body instead of a 302.** The
controller now responds `200 { url }` with `Cache-Control: private,
no-store`. The worker parses the JSON and issues a separate header-less
`fetch(url)` to S3. No redirect means the `Authorization` header is
never forwarded, making it browser-independent, and the handoff carrying
the presigned URL is never cached. The stream path (local storage) is
unchanged.
- **Client-side bundle cache in the renderer (restores warm loads).**
`fetchComponentSource` wraps the fetch chain in a `CacheStorage` layer
keyed by the **content-addressed** `/front-components/:id/:checksum.js`
URL. A hit returns the stored bundle and skips **both** the `no-store`
handoff to Twenty and the S3 download — restoring cross-session warm
loads without ever persisting a presigned credential. Because
`CacheStorage` is writable by any same-origin code (including the
untrusted component code this cache feeds), cached content is verified
against the sha-256 checksum embedded in the URL on every read, and
evicted on mismatch. Caching degrades to a plain fetch where
`CacheStorage` or WebCrypto is unavailable.
- **sha-256 checksums for built front components.** The SDK build and
workspace prefill now fingerprint built front-component bundles with
sha-256 (WebCrypto has no md5), enabling the integrity check above.
Other file folders keep md5. Legacy md5-fingerprinted URLs (32-hex)
simply bypass the cache — already-synced components keep working and
start benefiting from caching on their next build/sync.
- **WebKit e2e coverage.** Added a `webkit` project to the postcard
example's Playwright config mirroring `chrome` (shared setup +
storageState), plus iframe/worker diagnostics logging so front-component
failures surface in the test log. `TZ` is pinned to `Europe/Paris`
because WebKit on Linux ignores Playwright's `timezoneId` emulation and
rejects the runner's legacy `CET` alias, which crashed the record page
before the component could render.

### Why we hand off to S3 instead of streaming through Twenty

On S3-backed storage we deliberately **do not** proxy/stream the bundle
bytes through the API. The controller returns the presigned URL and the
worker fetches the content directly from S3, for two reasons:

- **Server CPU/bandwidth.** Streaming every bundle on every cold load
would put the API server on the hot path for all front-component
content. Handing off to S3 keeps that load off the server.
- **Domain isolation.** Front-component content is fetched from the
object-storage domain (e.g. `s3.domain.com`), a different origin than
the API and the front app. Serving untrusted/app-authored bundle content
from a separate domain than `twenty.com` keeps it off the app's origin.

The stream path is kept only as the local-storage fallback (no
S3/presign available), where these concerns don't apply.

## Examples

### The JSON handoff (S3 path)

```http
GET /rest/front-components/d3b07384-.../a1b2c3d4.js HTTP/1.1
Host: twenty.com
Authorization: Bearer <worker-token>
```

```http
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, no-store

{"url":"https://s3.domain.com/bucket/.../checkout-widget.mjs?X-Amz-Date=20260709T091500Z&X-Amz-Expires=900&...&X-Amz-Signature=AAAA1111..."}
```

The worker then fetches that presigned URL **without** headers (the
Safari fix) and gets the bundle bytes.

### Why the browser HTTP cache can't reuse it

| | Load 1 (09:15) | Load 2 (09:30) | Same key? |
|---|---|---|---|
| Twenty handoff URL | `.../a1b2c3d4.js` | `.../a1b2c3d4.js` |  but
response is `no-store` |
| Presigned `X-Amz-Signature` | `AAAA1111...` | `ZZZZ9999...` |  |
| Effective S3 URL (the HTTP cache key) |
`...&X-Amz-Signature=AAAA1111...` | `...&X-Amz-Signature=ZZZZ9999...` |
 new key → miss |

### What the CacheStorage layer stores

```
key   = https://twenty.com/rest/front-components/d3b07384-.../a1b2c3d4.js   (stable, chosen by us)
value = <bundle JS bytes>                                                   (NOT the presigned URL)
```

Keying by the stable logical URL (not the volatile URL the bytes arrived
from) is the one thing the native HTTP cache can't express. The
presigned URL is used once and discarded.

### Invalidation

No TTL and no explicit delete — invalidation is by key change. A rebuild
changes the checksum → changes the URL → guaranteed miss on the new key.
The old entry is orphaned and reclaimed by normal browser eviction
(quota/LRU; Safari ITP after 7 idle days). Global invalidation lever:
bump the cache name suffix (`front-component-source-v1`).

## Deploy note — front/server release window

Old frontend bundles (already-open tabs) hitting the new server receive
the JSON handoff where they expect raw JS and fail to render until the
tab is reloaded. The other direction is safe: the new worker against an
old server follows the 302 transparently (the content-type check falls
through to `response.text()`). Accepted as a short deploy-window
trade-off.

## Follow-ups (not in this PR)

- The client-side cache is a bridge for the `no-store` presigned
handoff. If built components are later served from a stable, non-signed,
public-by-URL path (they are already content-addressed by checksum, so
`immutable` is safe), the browser + CDN cache natively and this custom
layer can be removed.
- `GET /file/:fileFolder/:id` presigned 302s still carry no
`Cache-Control`. An explicit policy there (bounded `private, max-age`
below the presigned TTL) was prototyped in this PR and deliberately
dropped to keep the scope on front components — the file path
authenticates via a query-param token (part of any cache key), so its
exposure differs and deserves its own PR.

## Non-goals

Per the issue, file serving keeps its query-param token + 302 model.
Native browser loads (`<img>`, downloads) cannot do a two-step fetch and
already work on Safari. The public-asset redirect is left untouched
since its caching is intentional.

## Test plan

- Renderer: `fetchComponentSource.spec.ts` covers cache miss + write,
verified cache hit (no network), poisoned-entry eviction,
checksum-mismatch (never cached), non-fingerprinted and legacy-md5 URL
bypass, and the no-`CacheStorage` / no-WebCrypto fallbacks.
`fetchComponentSourceFromNetwork.spec.ts` covers the direct JS response,
the JSON handoff follow-through (header-less presigned fetch), and error
mapping.
- e2e: the postcard front-component spec now runs on both Chromium and
WebKit against prod-parity storage (S3 + Lambda).
- `oxlint` + `oxfmt` clean; typecheck passes on changed packages.

### Reproduction proof — Safari was always broken (e2e probe)

We ran the prod-parity postcard e2e suite (S3 storage + Lambda) with
WebKit against **`main` without this fix**, via a throwaway probe PR:
twentyhq/twenty#22717.

Result — [ci-privileged run
29015624468](https://github.com/twentyhq/ci-privileged/actions/runs/29015624468):

```
1 failed
  [webkit] › card-front-component.spec.ts:61 › renders the postcard name and status badge in the record preview
2 passed (1.4m)
```

`[webkit]` times out waiting for `getByTestId('postcard-card')` to
become visible (*element(s) not found*) while the Chromium run of the
same spec passes. This confirms the front component **never rendered in
Safari** on S3-backed storage prior to this PR — it is a genuine,
browser-specific bug, not a flake. The fix in this PR is expected to
turn that same `[webkit]` assertion green.

Note: running the WebKit tests in CI requires the WebKit browser binary
and its system dependencies in the e2e job (now installed via `npx
playwright install --with-deps chromium webkit`).
2026-07-10 10:12:58 +00:00
martmull 1c8b8970fd Allow CLI dev mode on catalog-synced apps without mutating the shared registration (#22756) 2026-07-10 12:10:00 +02:00
github-actions[bot] f8d3555fe7 i18n - translations (#22779)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22779?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-10 12:05:07 +02:00
github-actions[bot] 09496a0f98 i18n - translations (#22745)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22745?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>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-07-10 11:56:49 +02:00
neo773 f547da4ee9 Gate connected-account webhook subscriptions behind config (#22761)
Now gated behind IS_CONNECTED_ACCOUNT_WEBHOOK_SUBSCRIPTION_ENABLED
(default false).

Merge + deploy twentyhq/twenty-infra#780 first.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22761?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 <charlesBochet@users.noreply.github.com>
2026-07-10 15:03:53 +05:30
Abdul Rahman a360f9bdec migrate 10 modules off NestjsQueryTypeOrmModule wiring (#22763)
## Summary
Continues the incremental removal of `@ptc-org/nestjs-query`. Migrates
ten modules from `NestjsQueryTypeOrmModule.forFeature` to the standard
`TypeOrmModule.forFeature`. These modules only used `nestjs-query` for
repository registration — none register `NestjsQueryGraphQLModule` /
auto-generated
resolvers — so this is a pure module-wiring swap with no behavior or
schema change.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22763?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: Weiko <corentin@twenty.com>
2026-07-10 09:21:52 +00:00
martmull 23cae2040a Improve application asset management (#22564)
App manifests could point the logo and screenshots at either external
URLs or public folder paths, and that was handled inconsistently across
install, sync and the marketplace.

This makes assets always bundled files:

- Manifests now use `logo` and `galleryImages` (a `string[]` of public
folder paths) instead of `logoUrl` and `screenshots`. The old fields
still work but are deprecated. Gallery order comes from the array index.
Normalization (deprecated-field migration, and warning about + ignoring
external URLs) happens in `defineApplication`, so the warnings surface
at define time.
- Logo is stored as a File record (`logoFileId`).
- The registration gallery is configured via a `settings` jsonb column
on `applicationRegistration` (`{ galleryImages: string[] }`) — populated
from the manifest, read by the marketplace detail (falling back to the
legacy `screenshots` column, then the manifest). No dedicated gallery
table.
- The marketplace detail DTO and front now use `galleryImages`.

Verified against a local Postgres: the fast instance commands run with
no pending-migration diff, the schema is correct, and the server boots.
Typecheck, lint, codegen and the application unit tests pass.

Not included yet: rehosting assets into storage for npm catalog and
tarball registrations, versioned cache busting on the serving route, and
a backfill for existing installs.

<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22564?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-10 09:18:52 +00:00
Thomas Trompette b327ab09a7 feat(workflow): add universalIdentifier + applicationId to core workflowVersion (#22747)
## workflowVersion core: syncable columns

Adds `universalIdentifier` + `applicationId` (nullable) to
`core.workflowVersion`, plus the FK to `core.application` and the
`(workspaceId, universalIdentifier)` unique index, via a 2.20
add-columns fast command gated with `@WasIntroducedInUpgrade`.

Nullable for now: the already-merged Phase A backfill (#22663) inserts
version rows without these columns, so `applicationId` can't be NOT NULL
yet. Flipping to NOT NULL + `extends SyncableEntity` comes once they're
populated (backfill + dual-write follow-ups).

Schema captured and verified via `migrate:generate` (zero drift).

Independent of the core-workflow PR, but both add 2.20 upgrade commands,
so this one (ts `…480`) must merge **after** the core-workflow PR (ts
`…479`), or it gets re-timestamped on rebase.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22747?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-10 10:26:52 +02:00
Thomas Trompette 6210389221 feat(workflow): add core workflow entity (syncable) + create-table (#22746)
## Core `workflow` entity (syncable)

Part of the app-workflows work. Adds a core `WorkflowEntity extends
SyncableEntity` (`name`, `lastPublishedVersionId`, plus
`universalIdentifier`/`applicationId`/workspace from the base class) and
its 2.20 create-table fast command, gated with
`@WasIntroducedInUpgrade`.

Schema was captured and verified via `migrate:generate` (zero drift,
FK/index hashes correct), then run against a live DB.

Backfill (populate from workspace `workflow` records) and the dual-write
listener land in follow-ups.

Independent of the version-syncable-columns PR, but note: both add 2.20
upgrade commands, so this one (ts `…479`) must merge **before** the
version PR (ts `…480`) to satisfy the append-only guard.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22746?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-09 18:30:45 +02:00
Raphaël Bosi 9c405384f3 Only propose configured apps during onboarding install step (#22712)
## What

- Onboarding "Install your first apps" now proposes only apps that are
actually installable: it intersects the onboarding list with
`findManyMarketplaceApps`, which the backend already filters to listed +
configured apps (all required server variables set).
- If none are available, the step auto-skips. If the marketplace query
fails, it shows an intentional fallback (heading + Skip) instead of
silently skipping or rendering an empty install card.
- `findManyMarketplaceApps` now accepts `universalIdentifiers`, so
onboarding fetches and configuration-checks only its own apps instead of
the entire catalog.

## Why

Previously the step rendered all hardcoded apps regardless of
configuration, only borrowing logos from the marketplace, so a user
could be offered an app the admin never configured. This centralizes
onboarding availability on the marketplace's existing logic and keeps
the query bounded as the marketplace grows.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22712?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-09 18:12:11 +02:00
Paul Rastoin 60fd322b49 Centralize system field side effects + search field metadata (#22594)
## Introduction

Closes twentyhq/core-team-issues#2635 and twentyhq/core-team-issues#2642
and twentyhq/core-team-issues#2589

Object system fields (`searchVector` + its GIN index +
`searchFieldMetadata`, the reserved system fields, default relations)
were provisioned through several scattered, path-specific code paths. As
a result the **app-manifest sync path** authored objects with an
empty/`NULL` `searchVector` and **zero `searchFieldMetadata`**, so
app-owned objects shipped a broken generated search column (see #22657).
The generation logic also lived partly in imperative services rather
than in the metadata side-effect engine, and relied on non-deterministic
(`v4`) universal identifiers that `twenty apply` could not converge,
destroying manually backfilled rows.

This PR centralizes every object-creation system side effect into the
**metadata side-effect engine**, extends the engine to keep search
metadata consistent on field delete and object relabel, makes the
standard app's search identifiers deterministic, and ships upgrade
commands to reconcile existing workspaces.

## What changed

### Side effects moved into the metadata side-effect engine

New dedicated, self-contained handlers — so every write path (API and
app manifest) gets identical results, and side effects never trigger
other side effects.

**Object create / delete** (`handlers/object-metadata`)

* **`objectSystemFieldsOnCreate`** — generates the 7 reserved system
fields (`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`,
`updatedBy`, `position`).
* **`objectSearchVectorOnCreate`** — provisions the full-text search
surface as one unit: the `searchVector` `TS_VECTOR` field, its backing
GIN index, and the `searchFieldMetadata` row (for searchable objects
whose label identifier is a searchable field) that keeps `searchVector`
populated instead of `NULL`.
* **`objectSystemSideEffectsOnDelete`** — tears the above down on object
deletion.

**Search-metadata consistency on relabel / field delete** (new — these
are what close the manifest-path gaps)

* **`objectSearchVectorOnUpdate`** (`handlers/object-metadata`) — when a
searchable object is relabeled onto a new searchable field, provisions
the `searchFieldMetadata` row that indexes it. Relabeling is
**additive**: existing rows (e.g. the provisioned `name` row) are
preserved, so the previous label identifier stays searchable. Mirrors
the API update path so a manifest re-sync that changes the label
identifier reaches search parity. No-ops for junction objects (`id`
label identifier) and non-searchable field types.
* **`fieldSearchFieldMetadataOnDelete`** (`handlers/field-metadata`) —
when a field is deleted, cascade-deletes every `searchFieldMetadata` row
that indexes it. `searchFieldMetadata` is excluded from manifest
deletion inference, so this explicit cascade is what covers **both the
API and manifest paths** (the object-scoped DB cascade only fires on
object deletion). Uses the `searchFieldMetadataUniversalIdentifiers`
aggregator on the flat field for an O(k) lookup instead of scanning all
rows.

The **default `name` field and default relations are now caller-provided
default fields** (SDK autocomplete on the manifest path, input
transpiler on the API path) rather than system side effects — removing
duplicate name generation, the imperative
`build-default-*-for-custom-object` utilities, and the ad-hoc
system-field integrity validator.

### Deterministic identifiers for the standard app

The twenty-standard search GIN index and `searchFieldMetadata` now
derive deterministic universal identifiers
(`getIndexUniversalIdentifier` / `getSearchFieldUniversalIdentifier`)
instead of `v4`, so `twenty apply` converges instead of recreating.

### Upgrade commands (`2-20`) to reconcile existing workspaces

**Instance commands** (run once per instance; ordered fast → slow →
workspace):

1. **`AddIsSystemSideEffectToSearchFieldMetadata`** (fast) — adds the
`isSystemSideEffect` column to `core.searchFieldMetadata`. Defaults to
`true`, which also correctly backfills every existing row since
`searchFieldMetadata` is always system-derived (never user-authored).
2. **`BackfillNameFieldIsSystemSideEffect`** (slow) — re-flags existing
`name` fields from `isSystemSideEffect: true` → `false`, since the
default `name` field is now a caller-provided default like any other
user-owned field (it was provisioned as `true` in 2.15 → 2.19). This is
a pure data backfill, so the bulk `UPDATE` lives in `runDataMigration()`
rather than `up()` — keeping it out of the fast schema transaction
avoids holding an `ACCESS EXCLUSIVE` lock that could stall reads during
the deploy. Slow instance commands still run before every workspace
command of the version, so the fresh value is in place before the
search-reconcile workspace commands recompute the `fieldMetadata`
flat-entity cache. Scoping by name alone is safe (no engine-owned field
is named `name`); `down()` is best-effort (pre-2.15 `false` rows are
indistinguishable from flipped ones).

**Workspace commands** (idempotent, dry-run supported):

1. **`reconcile-search-vector-gin-index-universal-identifier`** —
re-owns every searchVector GIN index UID to its deterministic value (all
applications), then backfills the missing GIN index for installed-app
objects.
2. **`reconcile-search-field-metadata`** — re-owns every
`searchFieldMetadata` UID (all applications), then backfills the missing
rows for installed-app searchable objects.
3. **`rebuild-installed-app-search-vectors`** — rebuilds the
`searchVector` column of every installed-app `TS_VECTOR` field, once the
index and rows exist.

Design notes:

* **Re-own is global** (twenty-standard, workspace-custom, installed) —
a UID convergence keyed on each row's own application.
* **Backfill is installed-app only** — standard/custom objects already
have these rows via the manifest funnel.
* Re-own runs **before** backfill and is transaction-guarded; a failure
aborts that workspace to avoid a unique-identifier collision.

## Tests

* Integration: app manifest sync now asserts system fields + searchable
objects (searchVector, GIN index, searchFieldMetadata) are created; a
new relabel suite drives three manifest syncs and asserts records stay
searchable through the old + new label identifiers and lose
searchability when a field is removed; removed the obsolete
system-fields-integrity suite/snapshots.
* Unit: per-handler side-effect specs (including the new
`objectSearchVectorOnUpdate` and `fieldSearchFieldMetadataOnDelete`
handlers), and per-util specs for the re-own / backfill operation
builders and the GIN-index classifier.

## Upgrade / migration notes

* Existing workspaces converge on the next upgrade run via the `2-20`
instance + workspace commands (idempotent, dry-run supported).
* Backfill and rebuild go through the workspace-migration runner
(automatic cache invalidation); the re-own step invalidates only the
affected flat-entity maps directly.
* The cross-version upgrade CI now flushes the cache before running the
upgrade, so the new version recomputes every flat-entity map from the
database instead of reading blobs the old version serialized in an older
shape.

## Follow-up

* `object-metadata.service.ts` still carries a `TODO: remove once
default view fields move to the metadata side effect engine` — default
view fields are the next candidate to move into the engine.
* A single manifest sync cannot yet both create a field and relabel the
object onto it, because `objectMetadata.update` is ordered before
`fieldMetadata.create` in the migration runner. Tracked in
twentyhq/core-team-issues#2655; to be fixed in a follow-up.
2026-07-09 16:59:54 +02:00
Raphaël Bosi f06ba08b16 Fix onboarding locale reverting to English after signup (#22675)
During onboarding the UI reverted to English after email verification.
The chosen locale was never stored on the user, so the workspace member
seeded from it inherited `en`, and after workspace activation
`loadCurrentUser` reactivated `en` and flipped the whole UI to English
regardless of the browser language.

Two changes:

**Backend (the actual fix):** the `signUp` resolver received
`signUpInput.locale` but only used it for the verification email,
creating the user without it (so it defaulted to `en`). It now passes
the locale into `signUpWithoutWorkspace`, and the SSO create-a-workspace
path forwards `locale` as well. The user, and the workspace member
created from it at activation, now keep the chosen locale, so the
post-activation reactivation no longer forces English.

**Frontend:** carry the active locale across the workspace subdomain
redirect (in `useBuildSearchParamsFromUrlSyncedStates`) so the freshly
loaded subdomain renders `/verify` in the right language before the
current user loads, instead of briefly falling back to the browser
default. Minor, only affects the case where the chosen locale differs
from the browser language.
2026-07-09 14:08:09 +00:00
Félix Malfait 6897fff632 Rebuild email composer recipient fields as a structured chip input with person resolution and autocomplete (#22668)
# Why

The To/Cc/Bcc fields reused `FormMultiTextFieldInput`, the workflow
Tiptap tag editor, with recipients stored as a comma-separated string.
That caused every reported issue: duplicates were allowed, the field was
locked to one 32px line with a hidden horizontal scrollbar, chips did
nothing on click, `First Last <email>` could not even be typed (space
committed a tag) and was rejected by the backend when pasted, chips
could not be edited, invalid addresses only failed server-side after
pressing Send, and there was no autocomplete at all.

## The model

A recipient is `{ address, displayName? }`. Person and workspace member
are never stored in composer state; they are resolved live from the
address at render time, mirroring how `MatchParticipantService` links
`messageParticipant.handle` to `personId`/`workspaceMemberId` on the
receive side. Entities appear at the edges (autocomplete in, chip
display out); state, dedupe, validation, and send operate on addresses
only. The send path is unchanged: `SendEmailInput.to/cc/bcc` stay
comma-separated bare addresses.

# What changed

New module `activities/emails/recipients/` (the workflow editor is
untouched; its other consumers are unaffected):

- **`EmailRecipientsFieldInput`**: wrapping chip rows (up to ~3 lines,
then scroll), commit on Enter/Tab/comma/semicolon/blur, space commits
only when the buffer is already a valid email, paste parses RFC 5322
lists (names, quoted commas, semicolons, newlines), case-insensitive
dedupe with a flash on the existing chip, invalid addresses become red
chips that disable Send, double-click or keyboard editing in place with
Escape revert, Backspace select-then-delete, arrow-key chip navigation,
Ctrl/Cmd+Enter commits a pending buffer or sends when the buffer is
empty.
- **Person resolution**: chips resolve against People
(`emails.primaryEmail`, case-insensitive) and workspace members,
rendering avatar + name when known and degrading to a plain address chip
otherwise.
- **Chip menu**: person/member header, Copy email, Edit, Remove, and Add
as person for unknown addresses (creates the Person; the chip upgrades
in place).
- **Autocomplete**: blends context people (company you are composing
from, or the company behind a person/opportunity), ranked people search,
workspace members with a Team member badge, and a literal "Use this
email" row ranked first when the typed buffer is a valid address.
Suggestions exclude addresses already present in any field. Enter picks
the highlighted or top row.
- **Prefill**: replies and drafts preserve participant display names
(`getEmailDraftPrefillFromMessage`, `useReplyContext`).
- `useEmailComposerState` holds `EmailRecipient[]` per field and blocks
send on invalid recipients; the recipient-limit warning is surfaced
again in the composer.
- The Send Email engine command passes the record context so context
suggestions work from the record page action.
- `EmailsFilter` was missing from the shared `LeafFilter` union, so
nothing could filter on `emails.primaryEmail`; added (additive).
- New dependency `addressparser@1.0.1` in twenty-front, the same package
and version the server already uses to parse inbound mail headers, so
both sides parse identically. Tiny, dependency-free, browser-safe.

# Decisions and tradeoffs

- Person resolution matches on `emails.primaryEmail` only,
case-insensitively via per-address `ilike` filters (no `%` wildcards,
`%_\` escaped). `additionalEmails` is a JSONB array and not cleanly
filterable through the GraphQL filter API today; the server-side matcher
checks additional emails too, so a chip may show as a plain address even
though the send still links to the person via participant matching.
- Chip flash-on-duplicate replays its CSS animation by remounting the
chip subtree (nonce in the React key), chosen over animation-restart
hacks; the remount is invisible.
- Keyboard chip selection keeps DOM focus on the input and tracks a
virtual `selectedChipIndex` (`aria-activedescendant`) instead of roving
focus across chips: one focus point, no focus juggling, standard
combobox listbox pattern.
- `flushSync` (precedent: `Dropdown.tsx`) focuses and places the caret
after entering chip-edit mode; the alternative was a useEffect on
editing state.
- Suggestion rows `preventDefault` on mousedown so picking a suggestion
never blurs the input (blur would first commit the half-typed buffer as
a junk chip).
- Cmd/Ctrl+Enter inside a recipient field: with a non-empty buffer it
commits the buffer only; with an empty buffer it sends via an `onSubmit`
prop wired to `handleSend`. Not commit+send in one stroke: `handleSend`
holds a same-render closure over composer state, so sending in the same
event would read the pre-commit recipients. E2E also showed the side
panel's own ctrl+Enter hotkey never fires while any form field is
focused (focus-stack scoping, applies to the old composer too), which is
why the field triggers the submit itself.
- Enter with suggestions open picks the highlighted (or top) suggestion,
Gmail-style. When the typed buffer is itself a valid email, the literal
row is ranked first so Enter keeps meaning "add what I typed".
- Suggestions are disabled while editing a chip (the edit buffer holds
`Name <email>` text, a poor search query).
- Dedupe blocks within a field; across fields typed duplicates are
allowed (sometimes intentional), but suggestions exclude addresses
already present in any of To/Cc/Bcc.
- Chip menu actions never navigate: navigating the side panel (or main
view) unmounts the composer and silently destroys the draft, since
composer state is component-local with no draft persistence. "Add as
person" creates the record and shows a snackbar while the chip upgrades
in place; the person header row is informational. "Open person"
navigation should come back once drafts survive navigation.
- The reply composer gets no context record: its widget target record is
the message thread, not a person/company, and replies already prefill
participants.
- If two people share a primary email, the last fetched match wins for
chip display (no ambiguity UI).
- "Add as person" splits the display name on the first space for
firstName/lastName, the same heuristic the contact-creation manager uses
server-side.

# Deferred

- Display names on the wire (`Name <email>` in outbound headers): needs
`SendEmailInput` / `EmailComposerService.validateEmails` changes
server-side.
- Drag chips between To/Cc/Bcc; collapse-on-blur to one line with a "+N
others" summary.
- Frequency/recency ranking of suggestions from `messageParticipant`
aggregates.
- "Open person" from the chip menu, pending draft persistence across
navigation.

# Verification

Unit tests cover the parser, formatter round-trip, merge/dedupe, and the
field state machine (commit, dedupe flash, edit, cancel, keyboard
selection). Typecheck, lint, and the email module suites pass, plus the
shared and side-panel suites.

Every flow was also driven end to end with Playwright against seeded
data: prefill resolution, context and typed suggestions, keyboard
navigation and picks, dedupe flash, RFC 5322 paste, invalid chips gating
Send, wrapping, in-place editing, chip menus, clipboard copy, Add as
person with live chip upgrade, Cc/Bcc exclusions, and the Ctrl+Enter
send path (the mutation reached the server; it failed only on the seeded
account's missing refresh token, expected outside a real provider
connection).

Screenshots of each verified behavior:
https://claude.ai/code/artifact/1743f05d-422e-43d0-bbea-a34a0470c180

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

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22714?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-09 11:46:19 +02:00
Etienne a51c37dae5 feat(ai) - add light AI chat turn instrumentation (metrics + Sentry correlation) (#22692)
## Summary

Adds minimal server-side observability for AI chat turns: lifecycle
counters to measure success/failure rates, Sentry scope tags to
correlate API and worker traces, and per-LLM-call telemetry metadata for
turn/stream correlation.

- Add turn lifecycle metrics: `ai-chat/turn-started`,
`ai-chat/turn-completed`, `ai-chat/turn-failed` (with `failure_phase`
and `error_code` attributes)
- Emit counters at key points: job start, clean completion, execution
failures, enqueue failures, interrupted streams, and empty completions
- Tag Sentry scope with `streamId`, `turnId`, `threadId`, and
`workspaceId` at API entry points (`sendChatMessage`,
`retryChatMessage`, `answerAgentChatQuestion`) and worker entry
(`StreamAgentChatJob`)
- Enrich LLM `experimental_telemetry` metadata with
stream/turn/thread/workspace IDs
- Return `turnId` from streaming service methods so resolvers can tag
the scope
- Remove granular tool-learned/skill-loaded metrics in favor of the
turn-level counters

## Test plan

- [ ] Send a chat message and verify `ai-chat/turn-started` and
`ai-chat/turn-completed` increment
- [ ] Trigger a stream failure (e.g. interrupted/dead stream) and verify
`ai-chat/turn-failed` with correct `failure_phase`
- [ ] Retry a failed turn and confirm a new `turn-started` is emitted
for the retry attempt
- [ ] Answer an `ask_questions` prompt and confirm Sentry tags include
`streamId` and `turnId`
- [ ] Check Sentry spans for LLM calls include `streamId`, `turnId`,
`threadId`, `workspaceId` in telemetry metadata
- [ ] Run unit tests:
- `npx jest
packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/jobs/__tests__/stream-agent-chat.job.spec.ts`
- `npx jest
packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/agent-chat-streaming.service.claim.spec.ts`
- `npx jest
packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/agent-chat-streaming.service.retry.spec.ts`

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22692?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-09 08:24:27 +00:00
Etienne f2d0a1b90a fix(billing): only reactivate suspended workspaces when subscription is in good standing (#22687)
## Context

When a subscription's trial ends without a valid payment method, Stripe
emits
`customer.subscription.updated` (`active → past_due`) within seconds of
`trial_end`.
Our webhook handler correctly suspends the workspace for this event,
because it lands
inside the 24h "trial just ended" window checked by
`shouldSuspendWorkspace`.

However, the workspace does not *stay* suspended if an other
subscription update event arrived.

## Problem

Reactivation was gated only on the negation of the suspend heuristic:

```ts
} else if (workspace.activationStatus === WorkspaceActivationStatus.SUSPENDED) {
  await this.workspaceService.reactivateWorkspace(workspaceId);
}

<!-- This is an auto-generated description by cubic. -->
<a href="https://cubic.dev/pr/twentyhq/twenty/pull/22687?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-09 09:38:30 +02:00
Félix Malfait 0755855768 fix(billing) - skip paying upgrade invoices already settled at finalization (#22705)
## Problem

Upgrading resource credits fails with `Invoice is already paid` whenever
the one-off upgrade invoice resolves to a $0 amount due. That is the
case for internal workspaces on the `Twenty Internal - 100% FREE`
coupon, and for customers whose credit balance covers the price
difference.

Reproduced on an internal workspace (5 to 20 credits upgrade):

1. `createImmediateUpgradeInvoice` creates the invoice for the $20 diff
and finalizes it with `auto_advance: true`
2. The 100% coupon brings the amount due to $0, and Stripe settles
zero-due invoices at finalization ("Invoice was finalised and
automatically marked as paid because the amount due was US$0.00")
3. The explicit `stripe.invoices.pay()` that follows is rejected with a
400 `invalid_request_error`: "Invoice is already paid"
4. The error propagates, so the mutation aborts before
`runSubscriptionUpdate`: the Stripe subscription item stays on the old 5
credits price while the upgrade invoice already exists
5. Every retry creates a new invoice item + invoice and fails the same
way, leaving stray $0 invoices on the customer

## History

Third pass on this code path:

- #21097 treated it as a race with `auto_advance` and switched
finalization to `auto_advance: false`. Zero-due invoices are settled at
finalization regardless of that flag, so the failure remained.
- #21450 restored `auto_advance: true` and swallowed the error when
`error.code === 'invoice_already_paid'`. Stripe does not send that code
for this failure (it is not in its documented error codes; the response
only carries the message), so the guard never matched and the error was
always rethrown.

## Fix

Rely on invoice status instead of error codes:

- `finalizeInvoice` returns the finalized invoice; when it comes back
`paid` (the zero-due case), skip `pay` entirely
- if `pay` still fails (the genuine auto_advance race from #21097),
re-retrieve the invoice and only rethrow when it is actually unpaid

## Tests

Unit tests for `createImmediateUpgradeInvoice`:

- open invoice after finalization gets paid
- invoice settled at finalization skips `pay`
- `pay` failure with a meanwhile-paid invoice is swallowed
- `pay` failure with an unpaid invoice is rethrown

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22705?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-09 09:09:58 +02:00
neo773 3a5545c753 chore: remove IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED flag (#22680)
Messaging/calendar webhook subscriptions are now always on; drop the
feature flag gate and its enum/public-flag registration.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22680?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-08 23:35:35 +02:00
Thomas Trompette ca90a9358f feat(workflow): backfill workspace workflowVersion into core (phase A) (#22663)
## workflowVersion -> core, Phase A

Follows #21674 (Phase 0, merged). Base: `main`.

Populates core `workflowVersion` and keeps it in sync with the workspace
object, so a later phase can switch reads to core. Reads stay on the
workspace object in this PR.

### 1. Backfill (upgrade command)
`BackfillWorkflowVersionToCoreCommand`, a
`@RegisteredWorkspaceCommand('2.20.0', ...)`. Per workspace, reads all
workspace `workflowVersion` records and upserts them into core,
preserving ids (idempotent), dry-run aware.

### 2. Dual-write (always on, not flag-gated)
`WorkflowVersionCoreDualWriteListener` hooks
`@OnDatabaseBatchEvent('workflowVersion', CREATED/UPDATED/DELETED)`
(same mechanism as the existing workflow-version status listener) and
mirrors every mutation into core. Sync failures are logged, never break
the user's write; drift is repaired by re-running the backfill command.

Dual-write is deliberately not behind a flag: reading from core (next
phase) is only safe if core has been continuously in sync since the
backfill. An always-on mirror makes "core is fresh" an invariant, so the
read switch becomes a plain flag flip. The cost is one extra upsert on
infrequent workflowVersion writes.

`IS_WORKFLOW_VERSION_IN_CORE_ENABLED` is reserved for the read switch
(Phase B): dispatch from the `workflowAutomatedTriggerMaps` cache,
runner and builder reading trigger/steps from core. Until then it gates
nothing.

Both the backfill and the listener go through a single
`WorkflowVersionCoreSyncService` (`upsertToCore`/`deleteFromCore`): the
workspace-to-core mapping (`trigger` -> `triggers[]`, plus `steps`,
`status`, `workflowId`) and `workflowAutomatedTriggerMaps` invalidation
live in one place.

### Rollout plan (following phases)
- **B, read switch (flag per workspace):** reads move to core; writes
keep flowing workspace -> listener -> core. Rollback = flip the flag
back, workspace never stopped being source of truth.
- **C, contract (code change):** write paths write trigger/steps to core
directly; workspace `workflowVersion` stays as a thin shell
(nav/relations/search) but drops the trigger/steps columns; listener and
flag removed.

### Not in this PR
- Reconciliation tooling beyond re-running the backfill.
- The read switch (Phase B).
2026-07-08 18:45:57 +02:00
martmull 0ea0c23556 refactor(app-marketplace): rename featured to vetted (#22674)
## What

Renames the application-registration "featured" flag to "vetted" across
the backend, frontend, GraphQL schema/DTOs, and the marketplace UI.

"Vetted" better describes what the flag actually does today: it marks an
app as reviewed and approved by the Twenty team (a trust signal), rather
than "featured" which reads as spotlighting/promotion. The admin toggle
description was already "Mark this app as reviewed and approved".

## How

- Renamed `isFeatured` -> `isVetted` on the `ApplicationRegistration`
entity, DTOs (`MarketplaceApp`, `MarketplaceAppDetail`,
`UpdateApplicationRegistrationPayload`), services, GraphQL fragments,
and the settings/admin UI (labels: "Featured" -> "Vetted", "Featured
only" -> "Vetted only", etc.).
- Renamed the `MARKETPLACE_FEATURED_APPLICATIONS` constant/file to
`MARKETPLACE_VETTED_APPLICATIONS`.
- Regenerated GraphQL client artifacts (`generated-metadata`,
`generated-admin`, `twenty-client-sdk`).

### Database

The `isFeatured` column is renamed in place to `isVetted` via a single
2.20 fast instance command (`ALTER TABLE ... RENAME COLUMN`). No new
column, no data-copy backfill.

- Since all 2.19 commands (including the existing `isFeatured` backfill)
complete before any 2.20 command runs, the rename carries over the
values that backfill set.
- The entity uses `@WasRenamedInUpgrade` so the upgrade-aware layer
queries the old column name until the rename step runs during an
upgrade.

## Testing

- `nx typecheck` and `nx lint:diff-with-main` pass for twenty-server and
twenty-front.
- Ran `database:reset` on a fresh dev DB: the 2.19 `isFeatured` backfill
runs first, then the 2.20 rename; the column ends up as `isVetted` (and
`isFeatured` no longer exists), values preserved.
- Booted the server: the `@WasRenamedInUpgrade` decorator validates
against the upgrade sequence, and GraphQL introspection confirms all
four types expose `isVetted` and none expose `isFeatured`.
- Ran the three `graphql:generate` configs and the SDK metadata client
generator so the committed generated files match the generator output
(field ordering included).

## Notes

- The `api-breaking-changes` check flags the removal of the `isFeatured`
GraphQL field — that is expected and inherent to this rename.
- Translation catalogs (`locales/`) are intentionally not touched here
since they are managed via Crowdin; new English strings render via
Lingui's default-message fallback until translated.
2026-07-08 16:41:58 +00:00
Paul Rastoin 163c96c2e5 Validate range version app dev sync (#22625)
# Introduction
Also now validating the workspace version when running a sync manifest

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22625?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-08 16:37:42 +00:00
neo773 674de0056b feat(messaging): message campaign delivery stats + views (#22661)
Re-land of #22452 (reverted in #22627). Rebuilt on fresh main with
upgrade commands isolated to 2-20 only; no other version's commands
touched.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22661?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-08 15:55:20 +00:00
martmull 9423af7f67 feat(server): add public marketplace resolver for vetted app catalog (#22647)
## What

Adds a public GraphQL resolver so unauthenticated clients (the public
website) can read the listed/vetted marketplace catalog without a
workspace token.

- `MarketplacePublicResolver` (metadata schema) exposes two public
queries guarded by `PublicEndpointGuard` + `NoPermissionGuard`:
  - `publicMarketplaceApps`
  - `publicMarketplaceAppDetail(universalIdentifier)`
  
Both delegate to the existing `MarketplaceQueryService` (no new logic,
no new REST routing). The existing workspace-guarded
`findManyMarketplaceApps` / `findMarketplaceAppDetail` queries are
untouched.
- Adds a shared `ApplicationCategory` type in `twenty-shared` (known
values plus `string` for backward compatibility) used to type
`ApplicationManifest.category`. A warning is logged server-side when an
app declares a category outside the known set.

## Why

This is the backend half of the public apps marketplace on the website.
Splitting it out so the server-side catalog exposure can be reviewed
independently from the website UI.

## Follow-up

The website PR (the `/apps` marketplace UI) consumes
`publicMarketplaceApps` and should merge after this one.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22647?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: martmull <martin@twenty.com>
2026-07-08 15:43:17 +00:00
Pratik Mahajan 72d728ea8e fix(messaging): add sender name to IMAP/SMTP From headers (#22603)
Manual IMAP/SMTP outbound emails were being composed with a
bare email address in the From header, so recipients did not
see the sender's display name.
Gmail already built a proper sender header from Google profile
data, but manually configured IMAP/SMTP accounts had no
equivalent path and fell back to the raw address only.

Fix this by storing the optional sender display name in the
IMAP/SMTP/CALDAV connection parameters, exposing it through
the settings flow and metadata API, and reusing a shared
From-header formatter when composing outbound messages.
The formatter now builds a properly encoded sender header when
a name is available and falls back to the bare email address
when it is not, keeping the behavior safe for blank or missing names.
Gmail keeps using its existing Google-derived display name source;
this change only brings manual accounts up to the same header
formatting standard and removes duplicated formatting logic between
outbound drivers.

After this change, manual SMTP sends and IMAP draft creation include
the configured sender name in the From header, while blank names are
normalized away instead of producing malformed headers.
Existing manual account names are preserved when updates omit the
field, and edited accounts can still explicitly clear it through the
settings flow.

Add focused utility coverage for the shared From-header formatter.

Fixes: #22608

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

---------

Co-authored-by: neo773 <neo773@protonmail.com>
2026-07-08 20:29:25 +05:30
Thomas Trompette 48730df0d2 feat(workflow): scaffold core workflowVersion entity + trigger cache (phase 0) (#21674)
## What

**Phase 0 (scaffold)** of migrating `workflowVersion` data to **core**.
Gated by `IS_WORKFLOW_VERSION_IN_CORE_ENABLED` with **no behavior
change** — nothing reads or writes the new core entity yet.

## Plan

`workflowVersion` becomes a thin **workspace shell** over a core entity
(the `dashboard`/`pageLayout` pattern), so navigation, the metadata
relations, and the record UI keep working while the heavy data
(`triggers`, `steps`) lives in core. Trigger dispatch will derive from
active core versions via a per-workspace cache, letting us **eliminate**
the denormalized `workflowAutomatedTrigger` object. `workflow` and
`workflowRun` stay as workspace objects.

Phases: **0 — scaffold (this PR)** → A — backfill + dual-write → B —
switch reads to core → C — drop the workspace `trigger`/`steps` columns
+ the `workflowAutomatedTrigger` object.

## Included
- **Core `WorkflowVersionEntity`** (`extends WorkspaceRelatedEntity`) —
stores version data, with triggers as an **array** (`triggers:
WorkflowTrigger[]`), a long-due shape change. Storage only: dispatch
reads the primary trigger, so behavior stays single-trigger for now.
- **Fast create-table instance command** for `core."workflowVersion"`
(v2.19.0).
- **`IS_WORKFLOW_VERSION_IN_CORE_ENABLED`** feature flag.
- **Per-workspace automated-trigger cache provider** deriving
CRON/DATABASE_EVENT dispatch from the active version's trigger —
groundwork for removing `workflowAutomatedTrigger`.

## Notes
- `WorkspaceRelatedEntity`, **not** `SyncableEntity`: this is user
runtime data (like `connectedAccount`/`apiKey`/`file`), not
application-manifest metadata.
- No frontend behavior; the generated `FeatureFlagKey` enums are updated
to include the new flag.
2026-07-08 12:50:03 +00:00
martmull b733a79821 feat(server): support server-scoped files via nullable workspaceId on file table (#22587)
Part of the app settings architecture cleanup
(twentyhq/core-team-issues#2456) — PR 1 of the server-level documents
plan, reworked after the revert of #22560 (#22579). Same capability,
different shape: **no new entity** — server-level documents live in the
existing `file` table with a nullable `workspaceId`.

## Problem

All file storage is workspace-scoped (`FileEntity.workspaceId NOT NULL`,
`{workspaceId}/{app}/…` storage keys). Server-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)

**`FileEntity` gains server scope** (mirrors `KeyValuePairEntity`, which
already supports both instance-level and per-workspace rows):
- `workspaceId` uuid becomes **nullable** — NULL means server-scoped;
the entity no longer extends `WorkspaceRelatedEntity` and declares its
columns directly
- `applicationRegistrationId` nullable FK (`onDelete: CASCADE`) —
registration-owned documents follow their registration
- ownership checks: `workspaceId IS NOT NULL OR
applicationRegistrationId IS NOT NULL` and `workspaceId IS NULL OR
applicationRegistrationId IS NULL` — every row has exactly one owner
- `IDX_FILE_APPLICATION_REGISTRATION_ID_PATH_UNIQUE` UNIQUE
(`applicationRegistrationId`, `path`) — mirrors the workspace
unique-constraint pattern; workspace rows are exempt via their NULL
`applicationRegistrationId`

**New `ServerFileStorageService`** (`file-storage/services/`, exported
from the global `FileStorageModule`; `FileStorageService` moved
alongside it):
- storage keys
`server/{fileFolder}/{applicationRegistrationId}/{resourcePath}` — the
registration segment is injected by the service itself, so paths cannot
collide across registrations; scope-validation util mirroring
`validateStoragePathIsWithinWorkspaceOrThrow`; new `ServerFileFolder`
enum in twenty-shared
- `writeServerFile` (upsert on (`applicationRegistrationId`, `path`) +
driver write; throws on failure), `readServerFile`/`readServerFileById`
(missing row or bytes surfaces `FILE_NOT_FOUND`),
`checkServerFileExists`, `deleteServerFile`/`deleteByServerFileId`
(bytes best-effort, row authoritative),
`deleteByApplicationRegistrationId`
- rows are accessed through a plain repository pinned to `workspaceId:
IsNull()` on every query; workspace-file code paths still go through
`WorkspaceScopedRepository`, which never sees NULL rows

**Null-safety ripples** (workspaceId is now `string | null`):
- `WorkspaceScopedEntity` bound widened to `workspaceId: string | null`
(the wrapper always filters with a concrete id)
- `list-and-delete-orphaned-workspace-entities` now skips `workspaceId
IS NULL` rows — previously `NOT EXISTS` would have flagged server rows
as orphans and deleted them
- `PendingFileCleanupService` sweeps only `workspaceId IS NOT NULL`
rows; `application-package-fetcher` pins its tarball lookup to workspace
rows (tarball migration to server scope is a follow-up PR)

**Migration**: `allow-server-scoped-file` ships as a **2-20 fast
instance command** (2.20.0 is current since #22639; re-slotted from 2-19
per review). Command runs are tracked by name, so instances that already
executed the 2-20 `standardOverrides` drop command still pick this one
up. Its realistic timestamp sorts before that drop command's fabricated
`1825000000000`, which the
`ci:allow-upgrade-command-timestamp-exception` label covers.

## Next PRs in the plan

- PR 2: HTTP serving + token type for server files
- PR 3: application-registration manifests stored as versioned server
files (rework of draft #22556)
- PR 4 (optional): registration tarballs migrate to server scope

## Verification

- New spec `server-file-storage.service.spec.ts` (traversal table,
upsert conflict semantics, row-before-bytes reads, best-effort byte
deletion, registration cascade) + scope-validation util spec; affected
suites all green
- Typecheck (server + shared), `lint:diff-with-main`, full `oxfmt
--check src/` on both packages clean
- Fresh `database:reset` on the re-slotted branch: the 2-20 command
executes, generator then reports **no schema drift**; both ownership
checks and the composite unique verified live (dual-owner insert and
duplicate registration+path both rejected)
2026-07-08 11:56:24 +02:00
twenty-pr[bot] 3b1a0ef3e6 chore: bump version to 2.20.0 (#22639)
## Summary

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

## Checklist

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

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

Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
2026-07-07 17:40:56 +02:00
martmull 435073e9c5 Display featured applications in marketplace (#22635)
## After
<img width="1060" height="589" alt="image"
src="https://github.com/user-attachments/assets/74dfadcf-8698-4404-81c6-b309cc4cbf79"
/>
<img width="732" alt="image"
src="https://github.com/user-attachments/assets/0e1a3644-04bc-4208-aa77-3842d9db9cc8"
/>
<img width="797" alt="image"
src="https://github.com/user-attachments/assets/0456ecce-607a-4705-8a89-c77029bfb6ac"
/>

- Remove IS_MARKETPLACE_SETTING_TAB_VISIBLE feature flag
- add vetted toggle in admin app tab
- added people data labs, last contact and call recorder to default
vetted applications

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22635?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: prastoin <paul@twenty.com>
2026-07-07 17:05:54 +02:00
Raphaël Bosi 2c0e0b2eac Create billing customer at signup so onboarding rewards are credited (#22633)
## Problem

Onboarding credit rewards (install apps, import contacts, invite team)
were silently dropped. They credit the workspace balance via
`billingCustomer.increment(...)`, but no `billingCustomer` row exists
until the plan step (it's created lazily when the first subscription is
set up, which is after those steps). So the increment affected 0 rows
and the credit was lost. A user installing 3 apps saw only the trial
grant, not the expected +1.5 credits.

## Fix

Create the Stripe customer + `billingCustomer` row eagerly at signup via
a new `BillingCreditService.ensureBillingCustomer`, called from
`signUpOnNewWorkspace` after the workspace transaction commits. It is
idempotent, guarded by `IS_BILLING_ENABLED`, and non-blocking (failures
are logged, not thrown). The later subscription flow reuses this
customer (no duplicate Stripe customer), and trial eligibility is
unchanged since the customer has no subscriptions yet.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22633?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 14:51:53 +00:00
Paul Rastoin bd8bf89653 Revert "feat(messaging): message campaign delivery stats + views" (#22452) (#22627)
Revert "feat(messaging): message campaign delivery stats + views
(#22452)"

This reverts commit 2e1117d442.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22627?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 14:17:13 +02:00
Etienne 54aa52d11c feat(index): support composite unique indexes in create-many upsert conflict resolution (#22604)
## Context

The `createMany` upsert path resolved conflicts by scanning individual
field
metadata and only treating a field as a conflict target when it was
flagged
`isUnique` (plus the primary `id`). This ignored **composite unique
indexes**
(multi-column unique constraints), so upserting against a multi-field
unique
key never matched an existing row and could either insert a duplicate or
fail.

## What changed

- **Conflict groups are now derived from unique indexes**, not from
per-field
`isUnique` flags. `getConflictingFields` reads the object's index
metadata
(`flatIndexMaps`) and builds one `ConflictingFieldGroup` per unique
index
  (the primary `id` remains its own group).
- Each index group correctly expands its fields into DB columns,
handling:
- **Composite field types** — expands to the sub-columns included in the
unique constraint (or a specific sub-field when the index targets one).
- **`MANY_TO_ONE` relation fields** — resolves to the join column name.
  - **Scalar fields** — used directly.
- `ConflictingFieldGroup.baseField: string` → **`baseFields:
string[]`**, since
  a composite index spans multiple fields.
- **Clearer multi-match error message**: conflicting values are now
grouped per
index (`baseFields (fullPath: value, ...)`, groups joined by `;`) so
it's
  obvious which unique key caused the ambiguity when a payload matches
  different rows across different indexes.
- `CommonCreateManyQueryRunnerService` now fetches `flatIndexMaps` via
  `WorkspaceManyOrAllFlatEntityMapsCacheService` and passes them into
`getConflictingFields`; the cache module is wired into
`CoreCommonApiModule`.

## Tests

- New integration suite
`composite-unique-index-upsert.integration-spec.ts`:
- single composite unique index — insert, update-on-match, and
insert-when-key-differs
- **two independent composite unique indexes** — happy path (single row
matches
both) and failure path (payload matches different rows across the two
indexes → `Multiple records found with the same unique field values` /
    `BAD_USER_INPUT`).
- Updated unit specs for `get-conflicting-fields`,
`get-matching-record-id`,
  `build-where-conditions`, and `categorize-records` to reflect the
  index-driven grouping and the `baseFields[]` shape.

## Test plan

- [ ] `npx nx run twenty-server:test:integration:with-db-reset --
composite-unique-index-upsert`
- [ ] `npx nx test twenty-server -- get-conflicting-fields
get-matching-record-id build-where-conditions categorize-records`
- [ ] Manual: upsert against a composite unique index updates the
matching row instead of inserting a duplicate.

fixes
https://github.com/twentyhq/twenty/issues/22580#issuecomment-4894266699

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22604?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 13:54:33 +02:00
Abdul Rahman 81cfcecdc7 chore(server): migrate 5 modules off NestjsQueryTypeOrmModule wiring (#22595)
## Summary
Continues the incremental removal of `@ptc-org/nestjs-query`. Migrates
five core modules from `NestjsQueryTypeOrmModule.forFeature` to the
standard `TypeOrmModule.forFeature`. These modules only used
`nestjs-query` for repository registration — their resolvers are
hand-written and registered as normal providers — so this is a pure
module-wiring swap with no behavior or schema change.



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