## Context
Upgrading `twenty-last-contact` on a production workspace failed with a
Cloudflare error 1015 ("You are being rate limited"). The
`backfill-last-contact` post-install function runs on every version
upgrade and fired 20 concurrent update mutations per batch with no pause
between batches, on top of paginated full-collection reads. On a
workspace with real email/calendar history that burst trips Cloudflare's
rate limit, and since the client SDK throws on any non-2xx response, a
single 429 killed the whole install/upgrade hook mid-backfill.
## Changes
- New `executeWithRetry` util: retries rate-limit (429 / Cloudflare
1015) and transient gateway/network errors (502/503/504, timeouts,
connection resets) with exponential backoff and jitter, capped at 5
attempts. Honors a `retry_after` hint when present in the response body.
Non-retryable errors still throw immediately.
- All backfill queries and mutations are wrapped with it.
- Update batch concurrency reduced from 20 to 10 to keep bursts under
the rate limit in the first place.
- Bumped app version to 1.1.1 with a changelog entry.
## Test
- Added unit tests for `executeWithRetry` (success passthrough,
retry-then-succeed, non-retryable passthrough, retry exhaustion,
`retry_after` handling).
- `yarn test:unit` (28 passed), `yarn typecheck`, `yarn lint` all green
in the app package.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01AtnkEfbpFhLp5qCJbSmZpE)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22811?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. -->
## Problem
The automated `i18n - translations` PR (branch `i18n`) conflicts on
`twenty-emails` `.po` files on **every** cycle. Each time, the msgid
*set* is identical to main — only the entry **order** differs.
## Root cause
`twenty-emails` uses explicit message ids (`js-lingui-explicit-id`).
With lingui's default ordering, **`lingui extract` is non-idempotent for
this catalog** — two consecutive runs on identical source produce
different `.po` orderings:
```
# no source change between runs
lingui extract # run 1
lingui extract # run 2 -> ~170 lines reordered vs run 1
```
So the order produced by the `i18n-push` extract on main, the order
stored in Crowdin, and the order the `i18n-pull` bot downloads never
agree, and the translation PR re-conflicts perpetually.
`twenty-front`/`twenty-server` use hashed ids and are already idempotent
— this is isolated to emails.
## Fix
Set `orderBy: 'messageId'` in `twenty-emails/lingui.config.ts`. Verified
this makes extraction idempotent — two consecutive extracts now produce
byte-identical output.
This commit includes the one-time reorder of the existing catalogs into
the stable order. Generated `.ts` output is unchanged (already
order-independent). After merge, one push cycle syncs Crowdin to the
stable order, after which the recurring conflicts stop.
## Test plan
- `nx run twenty-emails:lingui:extract` twice → no diff on the second
run.
- `nx run twenty-emails:lingui:compile` → succeeds, generated `.ts`
unchanged.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22803?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. -->
# Context
`yarn twenty plan` fails when the app has never been installed in the
target workspace:
```
Sync failed with error: Application "f5ce204f-..." is not installed in workspace "10f39a9d-...". Install it first.
Hint: run `yarn twenty dev --once` to register the app in this workspace, then retry.
```
This forces developers to apply before they can plan, which defeats the
purpose of `plan`. Planning a not-yet-installed app is well defined: the
from-state is empty, so the plan is simply "create everything".
## Why it failed
The dry-run sync required the application row to exist in two places:
1. `ApplicationSyncService.synchronizeFromManifest` threw
`APP_NOT_INSTALLED` when the app row was missing, because the dry-run
needs an owner `FlatApplication` to anchor the from → to metadata diff.
2.
`WorkspaceMigrationFlatEntityMapsService.computeAllInvolvedApplicationIds`
threw when the owner app id was absent from `flatApplicationMaps`, even
though the only hard dependency of a build is the twenty standard
application.
`apply` never hit this because it registers the app row as a side effect
before syncing (and even swallows this exact error on its pre-apply
plan).
# What this PR does
Keeps `plan` strictly read-only, no registration or app row is created:
- **`application-sync.service.ts`**: on dry-run, resolve the owner to
the installed application when it exists (unchanged behavior), otherwise
build a virtual, non-persisted `FlatApplication` from the manifest. Its
freshly generated id matches no existing metadata, so the from-state
slice resolves to empty and every manifest entity shows up as a create.
- **`workspace-migration-flat-entity-maps.service.ts`**: relax the guard
so only the twenty standard application is required. A missing owner app
just contributes an empty from-slice instead of throwing. Installed apps
take the exact same path as before (`applicationId` defined → identical
behavior).
Front components run untrusted third-party React in a Web Worker. That
worker previously shared the host origin, so it could reach
origin-scoped storage (the metadata-store IndexedDB, the
`twenty-sign-out` BroadcastChannel), cookies, and same-origin resources.
This runs the worker inside a `sandbox="allow-scripts"` (no
`allow-same-origin`) iframe, giving it an opaque origin where the
browser denies localStorage, cookies, IndexedDB, and BroadcastChannel
outright. The worker is kept inside the iframe (rather than a bare
iframe) so untrusted code always runs off the main thread; the
remote-dom render path is unchanged.
- **Transport:** host ↔ iframe ↔ worker over a re-transferred
`MessagePort` (`ThreadMessagePort`); a small bootstrap script is inlined
into the iframe via `srcdoc` (bundled at build time by a prebuild step)
and relays the port to the worker it spawns. Messages across the
boundary use a typed discriminated union with a single parse/guard.
- **Network:** under the opaque origin, direct fetches to the Twenty API
would be `Origin: null`, so the component source and SDK modules are
fetched through an allowlisted, credential-omitting `hostFetch` bridge
and blobbed inside the worker. The allowlist is single-sourced on the
host (http(s) origins only) and carried in the render context. The
bridge is mandatory (rendering fails closed if it is missing), refuses
redirects except for GET/HEAD to the known file-storage URLs, and caps
response body size.
- **SDK loading:** SDK client modules now load inside the worker through
the bridge, replacing the host-side SDK-blob state/effect/provider with
a pure `getSdkClientUrls` URL builder.
- **Isolation tests:** a unit test locks the sandbox attribute
(`allow-scripts`, never `allow-same-origin`); a browser test asserts the
worker actually gets an opaque origin with storage denied, probing
cookies by writing one rather than reading an empty jar.
Also adds a "List Companies" seed front component that queries workspace
data via the SDK client (exercising the bridge end-to-end),
single-sources the command-menu confirmation-modal result event name and
detail type in `twenty-shared` (previously a hand-synced duplicate), and
decomposes the renderer (bridge, sandbox, worker orchestration) into
small single-purpose utils with unit tests.
## How it works
```mermaid
sequenceDiagram
autonumber
participant Host as Host window (twenty-front · host origin)
participant Frame as Sandboxed iframe (allow-scripts · opaque origin)
participant Worker as Worker (untrusted component · opaque origin)
participant API as Twenty API (host origin)
rect rgb(238,242,248)
Note over Host,Worker: 1 — Boot handshake
Host->>Frame: create iframe sandbox="allow-scripts", srcdoc = inlined bootstrap script
Host->>Host: MessageChannel + ThreadMessagePort(port1)<br/>exports = host API + hostFetch
Frame-->>Host: READY
Host->>Frame: INIT + transfer port2
Frame->>Worker: spawn inlined Worker + re-transfer port2
Worker->>Worker: ThreadMessagePort(port)<br/>exports = render / updateContext
Note over Host,Worker: Port now entangles Host ↔ Worker directly
end
rect rgb(246,240,248)
Note over Host,Worker: 2 — Render
Host->>Worker: render(connection, { componentUrl, sdkClientUrls, hostFetchOrigins, token })
Worker->>Worker: override globalThis.fetch<br/>(Twenty origins → hostFetch)
end
rect rgb(248,244,238)
Note over Worker,API: 3 — Network via hostFetch bridge (opaque Origin:null cannot reach the API directly)
Worker->>Host: hostFetch(componentUrl, Bearer)
Host->>Host: origin allowlist + credentials:'omit'
Host->>API: fetch(componentUrl)
API-->>Host: source
Host-->>Worker: { status, headers, body }
Worker->>Host: hostFetch(sdkClientUrls.core / .metadata)
Host-->>Worker: SDK module sources
Worker->>Worker: blob each source in its own opaque origin → import() → run untrusted React
end
rect rgb(238,248,242)
Note over Worker,Host: 4 — Render mirror
Worker->>Host: remote-dom mutations (RemoteConnection)
Host->>Host: RemoteReceiver → RemoteRootRenderer → host DOM
end
Note over Worker: Opaque origin ⇒ browser denies localStorage,<br/>cookies, IndexedDB, BroadcastChannel
```
## Problem
After deleting a custom object (e.g. `meeting`), the app crashes with
"Sorry, something went wrong" on pages that load records referencing
that object through a morph relation. The console shows:
```
Target object metadata item not found for target (morph target meeting)
```
It reproduces on the machine that used the object before deletion but
not on a fresh machine, which points at a stale client metadata store
rather than a server issue.
## Root cause
Every field carries its own server-provided `morphRelations` array; a
morph relation field (note/task/timeline targets, etc.) lists every
object it can point to, including the deleted one. When an object is
deleted, `useDeleteOneObjectMetadataItem` and the SSE `delete` handler
only remove the deleted **object** and its own fields from the metadata
store. The sibling morph fields on other objects keep their now-dangling
`morphRelations` entry pointing at the deleted object.
Those stale entries were only meant to be cleaned up later by a
collection-hash-triggered `network-only` refetch. When that
reconciliation does not win, `generateDepthRecordGqlFieldsFromFields`
can't resolve the deleted morph target in `objectMetadataItems` and
throws, crashing the page.
## Fix
Clean `morphRelations` entries referencing the deleted object from the
field metadata store at deletion time, so the store stays
self-consistent immediately instead of relying on an async refetch.
Applied in both paths that handle object deletion:
- `useDeleteOneObjectMetadataItem` (the client performing the deletion)
- `MetadataStoreSSEEffect` delete handler (other tabs/clients receiving
the event)
The throw in `generateDepthRecordGqlFieldsFromFields` is intentionally
left in place so any genuine future metadata inconsistency still
surfaces rather than being silently swallowed.
## Test
Added a unit test for the cleaning util covering: morph relations
targeting the deleted object are removed, only changed fields are
returned, non-morph fields are untouched, and nothing is returned when
no relation targets the deleted object.
## Problem
The Phase 0 core index \`IDX_WORKFLOW_VERSION_ONE_ACTIVE_PER_WORKFLOW\`
is on \`(workflowId) WHERE status='ACTIVE'\`, with **no
\`workspaceId\`**. But \`core.workflowVersion\` is a shared multi-tenant
table, so this enforces "one active version per workflowId **globally
across all workspaces**" instead of per workspace.
The version backfill fails on staging with:
\`\`\`
duplicate key value violates unique constraint
"IDX_WORKFLOW_VERSION_ONE_ACTIVE_PER_WORKFLOW"
Detail: Key ("workflowId")=(8b213cac-...) already exists.
\`\`\`
across several different workspaces that share the same workflowId
(seeded/cloned data): workspace A's active version claims the
workflowId, and every other workspace's insert collides. Every other
index on this table includes \`workspaceId\`; this one dropped it when
copied from the per-tenant workspace entity.
## Fix
Index becomes \`(workspaceId, workflowId) WHERE status='ACTIVE'\` — one
active version per workflow **per workspace**, matching the table's
multi-tenant design and the intended invariant. New 2-20 fast instance
command drops and recreates the index (Phase 0's command is
merged/append-only).
## Test
Reset + reproduce the exact scenario against the fixed index:
- two workspaces with the same workflowId, both ACTIVE → **insert
succeeds** (previously collided)
- a second ACTIVE version for the same workflow within one workspace →
**still blocked** (invariant preserved)
Zero \`migrate:generate\` drift, typecheck + lint clean. After this
deploys, re-run \`upgrade:2-20:backfill-workflow-version-to-core\`.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22795?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. -->
## Summary
Follow-up to #22719 (merged), which added partner-marketplace CTAs to
four **high-intent, low-traffic** docs pages (SSO, both migration
guides, implementation services).
Reviewing the docs' **top-visited pages** showed none of those four rank
in the top ~20 — they're the high-intent tail, which is correct, but
small reach. This PR extends the same pattern to three **high-traffic
pages that also carry buying intent**, without touching the pure
top-of-funnel intros/quickstarts (volume without intent → a CTA there is
just noise).
Same conventions as #22719: Mintlify-native `<Tip>` callouts,
partner-first with `contact@twenty.com` secondary, directory deep-linked
via `?categories=<scope>` and tagged with `?ref=docs-*`. No new snippet;
no `docs.json`, navigation, or translation (`l/`) changes.
## Pages changed — screenshots (one per page)
> Preview locally with `npx mintlify dev` from `packages/twenty-docs`,
or use the Mintlify PR preview once it posts. Paths below are under
`docs.twenty.com`.
### 1. `/user-guide/workflows/overview` (~593 views)
New `## Need Help?` two-bullet `<Tip>` → **Done for you** (Solutioning
partner) / **Onboarding pack** (Workflow Creation). Maps 1:1 to the
named onboarding service.
_screenshot:_
<img width="1440" height="818" alt="Screenshot 2026-07-10 at 14 34 32"
src="https://github.com/user-attachments/assets/231f681d-b5be-4b1e-9b6a-a4947a9fca37"
/>
### 2. `/user-guide/data-model/overview` (~954 views)
Replaced the plain "Need Help?" line with a two-bullet `<Tip>` → **Done
for you** (Solutioning partner) / **Onboarding pack** (Data Model
Design). Keeps the existing Implementation Services link.
_screenshot:_
<img width="1436" height="817" alt="Screenshot 2026-07-10 at 14 34 14"
src="https://github.com/user-attachments/assets/c0fe1064-1030-4062-91c7-24644ac31654"
/>
### 3. `/developers/self-host/capabilities/docker-compose` (~2421 views)
New `## Managed Hosting` single-line `<Tip>` → *find a certified Twenty
hosting partner* (Hosting), contact fallback. Framed as a lighter
"prefer not to run it yourself?" alternative — deliberately low-pressure
for the DIY self-host audience.
_screenshot:_
<img width="1437" height="815" alt="Screenshot 2026-07-10 at 14 33 39"
src="https://github.com/user-attachments/assets/a37207cd-2aaa-4aba-848d-cbf06a1e1321"
/>
## Notes for reviewers
- Page-selection rationale: intent × volume. Kept the four intent-tail
pages from #22719; added the highest-traffic pages that also carry a
natural partner-buying moment (self-host → Hosting; workflows /
data-model → Solutioning). Intros/quickstarts/contribute pages
intentionally left untouched.
- **Attribution caveat (unchanged from #22719):** twenty.com's analytics
(Cloudflare Web Analytics) is path-based, so `?ref=` is not measurable
yet. Per-page measurement via a `/go/*` redirect Worker remains a
planned, separate follow-up (out of scope here).
- `mintlify validate` passes.
Opened as a draft.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22808?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. -->
## Summary
Removes the `NestjsQueryGraphQLModule.forFeature` block from
`IndexMetadataModule`, continuing the incremental migration off
`@ptc-org/nestjs-query`.
- Drops the dead auto-generated read surface: `index` and
`indexMetadatas` queries, the `IndexConnection` /
`IndexObjectMetadataConnection` types, and the `Index.objectMetadata`
field. No client consumes these — the frontend reads indexes via
`ObjectMetadata.indexMetadatas`.
- Keeps `IndexMetadataDTO` nestjs-query-compatible (`@Authorize`,
`@FilterableField`, `@QueryOptions`, `@IDField`) because
`ObjectMetadataDTO` still references it via
`@CursorConnection('indexMetadatas')` until object-metadata is migrated.
- Hand-written `createOneIndex` / `deleteOneIndex` mutations and the
`indexFieldMetadataList` resolve-field are unchanged.
- Deletes the now-obsolete `index-metadatas` integration test and
regenerates the GraphQL schema artifacts (frontend + client-sdk).
## Breaking change
This is an intentional GraphQL schema breaking change
(`api-breaking-changes` CI will flag it)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22775?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. -->
## Summary
PR #22785 merged with a Cursor attribution (05afc49e) even though the
`check-blocked-contributors` job ran and passed. The PR's commits were
clean, but the description ended with Cursor's attribution footer, which
starts with "Made with" followed by a markdown link to cursor.com, while
the script only matched the "Generated with" wording. Since the repo
squash-merges, the PR description became the merged commit message,
carrying the footer (plus the co-author trailer GitHub appends at squash
time) into main's history.
This broadens the signature pattern in
`scripts/check-blocked-contributors.ts` to also cover the "Made"/"Built"
wordings.
Note: this description intentionally avoids quoting the banned strings
verbatim, since the check scans it too (and it would become the squash
commit message).
## Test plan
Verified the new pattern against:
- The exact footer from the PR #22785 description - now caught
- The co-author trailer from the merged squash commit - caught by
existing patterns
- The "Generated with" and "Built with" wordings, with and without the
"Agent" suffix in the link label - caught
- Negative cases: prose mentioning the word cursor and a plain markdown
link to cursor.com - not flagged
## What
- Bump `twenty-partners` from `1.2.0` to `1.2.10`.
- Fix the red integration tests by pointing the "Partner Applications"
view `createdAt` column at the real field metadata id.
## Why the integration tests were red
The `twenty-partners` CI job spins up `twentycrm/twenty-app-dev:latest`
and runs the integration suite. The suite's global setup does a dev sync
of the app, which failed:
```
Dev sync failed: viewField: INVALID_VIEW_DATA: Field metadata not found
(universalIdentifier: 835c9a7e-72ec-46c5-8d90-39a02998f561)
```
The `partner-applications.view.ts` `createdAt` column referenced
`PARTNER_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER = 421cbcea-...`, an
invented id. `createdAt` is a reserved system field auto-created on the
custom `partner` object, and since 2.19 its universal identifier is
derived deterministically by the server from the application id, the
object id and the field name. The invented id matched nothing, so the
sync rejected the dangling view field and the app never registered,
failing every integration test.
## Fix
Set `PARTNER_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER` to the
deterministically derived value `746e2944-28d0-545e-9832-a46516e1d9a0`
(application id `e662fc1f-...` + partner object id `39101b39-...` +
field name `createdAt`). This matches the same derivation the server
uses for standard object system fields, verified against the
`twenty-shared` opportunity `createdAt` snapshot.
## Problem
Fixes#22383. Overflowing relation & multi-select chip cells didn't show
the "+N" overflow button, so hidden records/values were unreachable.
There were several distinct causes behind this, addressed below.
Before
<img width="263" height="34" alt="Capture d’écran 2026-07-10 à 11 43
13"
src="https://github.com/user-attachments/assets/1001d47b-0f54-4fbd-a5cb-83a5c32c35ac"
/>
After
<img width="263" height="34" alt="Capture d’écran 2026-07-10 à 11 41
54"
src="https://github.com/user-attachments/assets/d4f5cf2f-fda4-422e-b72d-2df3fc81a06f"
/>
## Changes
**1. Overflow detection missed right-edge-clipped chips**
`isFirstOverflowingChildElement` used `childElement.offsetLeft >
containerElement.clientWidth` (left edge past the container), which is
false when a chip is only right-edge clipped. Switched to a right-edge
check: `childElement.offsetLeft + childElement.offsetWidth >
containerElement.clientWidth`.
**2. Multi-select never used the overflow list when unfocused**
`MultiSelectFieldDisplay` rendered a plain clipped `MultiSelectDisplay`
when not focused, and only used `ExpandableList` on focus. That
non-focused fallback painted over the focused "+N" and hid it. It now
always renders through `ExpandableList` with
`isChipCountDisplayed={isFocused}`, matching
`RelationFromManyFieldDisplay`, so the count shows on focus only (not
idle).
**3. The hover portal was never actually focused**
`FieldFocusContextProvider` silently ignored its `isFocused` prop (`({
children }: any)` + hard-coded `useState(false)`), so
`RecordInlineCellAnchoredPortal`'s `<FieldFocusContextProvider
isFocused={true}>` had no effect and the hovered cell's display always
saw `isFocused=false`. That's why "+N" never appeared on hover for any
multi-value field. The portal now uses the existing
`FieldFocusStaticFocusedProvider`, fulfilling the intent — so relations,
multi-select, emails and phones all surface their "+N" on hover.
**4. "+N" was hard to read on multi-select**
The "+N" chip has a transparent background (shared component), and the
hover portal let the base layer's colored option chips bleed through it.
Gave the hover portal content an opaque `background.primary` so nothing
bleeds through; the "+N" component itself is untouched, so
relations/emails/phones keep their existing look.
## Test plan
- Hover a relation or multi-select field whose values overflow the cell:
the "+N" button appears (and is readable), and clicking it lists the
hidden records. When not hovered, no "+N" shows.
- Verify fully-overflowing rows still show the correct "+N" count.
## Summary
Quick follow-up to #22672.
`frontComponentCacheStorageService.read` returned
`cachedResponse.text()` without awaiting it, so the promise escaped the
surrounding `try/catch`. A cache entry with an unreadable body (corrupt
or partially-evicted `CacheStorage` entry) would reject in
`fetchComponentSource` and break component rendering entirely, instead
of being treated as a cache miss with a network fallback.
- `await` the body read inside the `try/catch` so decoding failures
degrade to a network fetch
- Add a regression test: cached body read rejects → source is still
served from the network
## Test plan
- `fetchComponentSource.spec.ts` — new test `falls back to the network
when the cached response body is unreadable`; full renderer suite passes
(14 tests)
Made with [Cursor](https://cursor.com)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22785?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: Cursor <cursoragent@cursor.com>
## 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>
## 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.
Closestwentyhq/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`).
## 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>
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">``<img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</a>
## 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. -->
Projects scaffolded with `create-twenty-app` now include two additional
seed files:
- `CHANGELOG.md` with an initial `0.1.0` entry matching the template's
package version
- `SETUP.md` with step-by-step local setup instructions (prerequisites,
install, local server, dev sync, verification commands)
Both files live in `src/constants/template/`, so they flow through the
existing `fs.copy` scaffolding and the vite `copy-assets` build step
with no code changes. Verified `dist/constants/template/` contains both
files after `nx build create-twenty-app`.
The scaffolded `README.md` was also simplified into a marketable front
page for the app being built: a pitch placeholder, a features section,
and links to `SETUP.md` for setup instructions and `CHANGELOG.md` for
history, instead of duplicating dev commands.
Also:
- Adds a regression test asserting the template directory contains both
seed files
- Updates `project-structure.mdx` docs to list the new files in the
scaffold directory tree
---------
Co-authored-by: Martin <martin@twenty.com>
## Problem
Fixes#22708.
`twenty-sdk` 2.19.0 CLI commands run through the ESM entrypoint (`node
dist/cli.mjs dev --once`, `app:uninstall`, ...) crash on startup:
```
Error: Calling `require` for "fs" in an environment that doesn't expose the `require` function.
```
## Root cause
The 2.19.0 release switched bundling from esbuild to **rolldown** (Vite
7 → 8). Rolldown **inlines CommonJS dependencies** into the ESM output
(`dist/cli.mjs` grows from ~6k to ~136k lines). Those third-party CJS
modules — e.g. `typescript`, pulled in via `ts-morph` — call
`require(...)` and read `__filename` / `__dirname` at load time. None of
those exist in an ES module:
- `require(...)` is routed through rolldown's interop shim, which
**throws** when `require` is absent (i.e. in a `.mjs` file).
- `__filename` / `__dirname` are simply `ReferenceError: … is not
defined in ES module scope`.
esbuild (2.18.0) injected these CJS globals for node-targeted ESM
output; rolldown does not. Because the offending usage lives in
**bundled third-party CJS**, prefixing our own imports could not fix it.
## Fix
Add a banner to the **ESM output only** in `vite.config.node.ts` that
recreates the CJS globals from `import.meta.url`:
```js
import { createRequire as __twentyCreateRequire } from 'node:module';
import { fileURLToPath as __twentyFileURLToPath } from 'node:url';
import { dirname as __twentyDirname } from 'node:path';
const require = __twentyCreateRequire(import.meta.url);
const __filename = __twentyFileURLToPath(import.meta.url);
const __dirname = __twentyDirname(__filename);
```
This is the same `createRequire` pattern the repo already uses for the
`twenty-oxlint-rules` ESM build. The CJS output already provides all
three, so the banner is not applied there.
This PR also prefixes the SDK CLI's own Node-builtin imports with
`node:` (using native ESM imports instead of the interop shim for our
own code) — good hygiene and guarded by a unit test, but note the
**banner is the actual bug fix**.
## Verification (built and run locally)
- Built the node bundle and reproduced the crash on the pre-fix build
(`Calling require for "fs"`), then a follow-on `__filename is not
defined` once `require` was restored.
- With the banner, ran the previously-crashing commands against the
built `dist/cli.mjs`:
- `--help` → prints usage, exit 0
- `dev --once` → reaches "Checking server… Cannot reach Twenty server"
(normal, no local server)
- `app:uninstall` → reaches the interactive confirmation prompt
- CJS bin (`dist/cli.cjs`) still works.
## Tests
- `cli-esm-bundle-startup.integration.spec.ts` — runs the **built**
`dist/cli.mjs --help` and asserts no require/ESM-scope crash.
Demonstrated **red without the banner, green with it**. It runs in the
`sdk-test` job (which builds the SDK before tests); it fails loudly in
CI if the artifact is missing and skips locally when unbuilt, so it is
never silently green in CI.
- `node-builtin-import-protocol.test.ts` — guards the `node:`-prefix
hygiene across the CLI source.
Note: the existing `sdk-e2e-test` never caught this because it runs the
CLI via `tsx` on the TypeScript **source**, which has no rolldown shim —
only the bundled `.mjs` reproduces the crash.