## Summary
Adds a new `connected-account-connection-parameters` site to the
`secret-encryption:rotate` CLI introduced in #20613, so the nested
password envelopes inside `connectedAccount.connectionParameters` (IMAP
/ SMTP / CALDAV — encrypted at-rest in #20673) are re-encrypted under
the current `ENCRYPTION_KEY` alongside every other at-rest secret site.
Without this, rotating `ENCRYPTION_KEY` on a 2.7+ instance would
silently leave IMAP / SMTP / CalDav passwords on the old key id.
### Why a new handler
A dedicated handler is required (rather than reusing
`ColumnRotationSiteHandler`) because the envelope lives at
`connectionParameters->'<PROTOCOL>'->>'password'`, not in the whole
column, and up to three independent envelopes may need rotating per row.
The handler:
- Uses the same cursor-based, idempotent, online pattern as the existing
handlers, with a SQL predicate that skips rows where every non-null
protocol password is already on the current key id.
- Threads \`workspaceId\` into HKDF, matching how
\`EncryptConnectionParametersSlowInstanceCommand\` backfilled.
- Rebuilds only the protocols whose passwords are not yet current, so a
partial mid-row failure cannot cause unnecessary re-encryption on
resume.
- Guards the UPDATE with jsonb-level deep equality (\`IS NOT DISTINCT
FROM CAST(:json AS jsonb)\`) so optimistic concurrency is unaffected by
Postgres's internal jsonb key ordering vs. JSON.stringify ordering.
- Refuses to rotate plaintext passwords (counted as \`errors\`) —
operators must finish the 2.7 slow instance command
(\`EncryptConnectionParametersSlowInstanceCommand\`) before running
rotation.
### Sites covered (now)
| Site | Location | Scope |
| --- | --- | --- |
| \`connected-account-access-token\` | \`connectedAccount.accessToken\`
| workspace |
| \`connected-account-refresh-token\` |
\`connectedAccount.refreshToken\` | workspace |
| **\`connected-account-connection-parameters\`** (new) |
\`connectedAccount.connectionParameters.{IMAP,SMTP,CALDAV}.password\` |
workspace |
| \`application-variable\` | \`applicationVariable.value\` (isSecret) |
workspace |
| \`application-registration-variable\` |
\`applicationRegistrationVariable.encryptedValue\` | instance |
| \`signing-key-private-key\` | \`signingKey.privateKey\` | instance |
| \`totp-secret\` | \`twoFactorAuthenticationMethod.secret\` | workspace
|
| \`sensitive-config-storage\` | \`keyValuePair.value\` (sensitive
STRING configs) | instance |
# Introduction
Jobs were refreshing token and returning them as plain text, resulting
to underlying code flow failure as expecting encrypted tokens
## Next
We should define a strong typescript signature to avoid such things to
happen again, or least have an explicit naming
New API keys are created successfully but the API keys table can keep
showing a stale pre-create result, so users think the key vanished. This
blocks key management from the expected UI flow.
Fix: Updated the API key creation mutation to explicitly synchronize
Apollo cache for the API keys list:
- In `SettingsDevelopersApiKeysNew.tsx`, imported `GetApiKeysDocument`.
- Changed `useMutation(CreateApiKeyDocument)` to:
- `refetchQueries: [GetApiKeysDocument]`
- `awaitRefetchQueries: true`
Why: the list page (`SettingsApiKeysTable`) reads from
`GetApiKeysDocument`, and creation previously did not invalidate/refetch
that query. With this change, successful creation refreshes the list
query so the new key appears when the user returns to APIs & Webhooks.
Validation attempted:
- `npx nx lint:diff-with-main twenty-front` → failed due missing Nx
modules in this environment.
- `npx nx typecheck twenty-front` → failed due missing Nx modules in
this environment.
Authored by Sonarly by autonomous analysis (run 44851).
Co-authored-by: sonarly-bot <sonarly@sonarly.com>
## Problem
The cron-trigger cache key (`module:workflow:workflow-cron-triggers`)
can get stuck without a TTL, silently halting **all** cron-triggered
workflows for a whole tenant until the key is manually deleted from
Redis.
Repro path:
1. Cache miss → DB-scan branch runs.
2. Inner loop writes triggers via `hashSet` (creates the key, **no TTL
yet**).
3. Worker crashes / OOMs / gets killed by a deploy between any `hashSet`
and the trailing `expire(1h)` call.
4. Key now exists with TTL = `-1` and a partial set of fields.
5. Next tick: `hashGetValues` returns those fields →
`cachedValues.length > 0` → **cache-hit branch** → `expire` is never
called.
6. Key has no TTL, so it never auto-expires. The DB-scan branch never
runs again. New / missing triggers are never picked up. Workflows go
silent.
Observed in production: cache key with `TTL: no limit` and 121 fields.
Deleting the key restored normal behaviour (next tick rebuilt with TTL
~3600).
## Fix
Set the TTL right after first value is added
## Monitoring
Added a "Cache miss" log count in workflow dashboard, counted among the
last 6 hours. Turns green if >= 5
<img width="1627" height="721" alt="Screenshot 2026-05-21 at 16 46 13"
src="https://github.com/user-attachments/assets/8262dd5f-fbbd-43c9-aede-c0ce5d6a0f59"
/>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Move the browsing context out of the system prompt and injecting it
directly into the last user message instead.
Previously (before this PR), browsing context change, update system
prompt then break whole conversation history ... and caching. Now,
browsing context is sent with last message only if changed.
"Benchmark" this PR vs main :
- same conv with 3-4 turns - 60% -> 85% cache ratio || 0.31 credits ->
0.13
Customer story page shows the following error, which I believe leads to
an internal server on the individual customer story pages.
<img width="636" height="75" alt="image"
src="https://github.com/user-attachments/assets/fc9ede75-fd3b-4538-8211-8182f4a99b9b"
/>
This PR replaces remote URLs of those images with local copies to avoid
a 404 issue. Will test once deployed on dev to confirm if the error is
resolved, but locally, I do not see console errors any longer after this
change.
There is some duplicated copy that I found upon audit which can be made
DRY, but I will resolve it in a separate PR to keep this PR
single-responsibility.
## 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
## Checklist
- [ ] Verify version constants are correct
Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
## Summary
- ECR Inspector still flags `prod-twenty` for the High-severity CVEs
that PR #20603 was meant to fix (8x `postgresql18-18.3-r0`,
`nghttp2-1.68.0-r0`, `curl-8.17.0-r1`, plus the related Medium `curl`
CVE).
- Root cause: PR #20603 pinned the `node:24.15.0-alpine3.23` digest to
invalidate the buildx GHA cache once, but the cache layer was first
repopulated (on the PR branch) before Alpine 3.23 published `18.4-r0` /
`1.69.0-r0` / `8.19.0-r0`. Every build since — including today's prod
v2.6.2 — hits `#26 [twenty-server 2/19] RUN apk add --no-cache curl jq
postgresql-client / #26 CACHED` and ships the stale packages.
- Pinning minimum versions in the `apk add` spec changes the RUN text →
forces a new buildx cache key → apk re-resolves against the current
Alpine mirror. apk also refuses to install anything below the floor, so
the image can't silently regress if a stale layer ever matches the key
again.
## Summary
- **New fast migration**
`2-7-instance-command-fast-1779600000000-finalize-role-permission-flag-cutover.ts`:
- `DROP CONSTRAINT IDX_ROLE_PERMISSION_FLAG_FLAG_ROLE_ID_UNIQUE`
- `ALTER COLUMN permissionFlagId SET NOT NULL`
- `DROP COLUMN flag`
- `down()` repopulates `flag` from the catalog via `permissionFlagId`
and restores the old unique.
- **Entity**: `RolePermissionFlagEntity` hides the `flag` column by
using the new decorator + drops old `@Unique` decorator;
`permissionFlagId` and the `permissionFlag` relation become
non-nullable.
- **Deletes** the synthesizer
`synthesize-flat-permission-flag-from-flag.util.ts` and every fallback
branch that used it
(`from-role-permission-flag-entity-to-flat-role-permission-flag.util.ts`,
`from-flat-role-permission-flag-to-role-permission-flag-dto.util.ts`,
`permissions.service.ts`,
`workspace-roles-permissions-cache.service.ts`,
`fromRoleEntityToRoleDto.util.ts`,
`flat-role-permission-flag-validator.service.ts`,
`role-permission-flag.service.ts:getEffectiveUniversalIdentifier`).
- **Write path**: ~~drops `flag` from `CreateRolePermissionFlagInput`,
the create util, and the application-manifest converter.~~
- **Metadata configs**: ~~removes `flag` from
`all-entity-properties-configuration-by-metadata-name.constant.ts`
(rolePermissionFlag block)~~ and flips `permissionFlag.isNullable` to
`false` in `all-many-to-one-metadata-relations.constant.ts`.
### Why the `flag` field stays declared in the entity
The decorator (`@WasRemovedInUpgrade`) is the right tool for the
lifecycle marker, but it's a **reflect-metadata** runtime decorator —
TypeScript can't see it at compile time. So while
the adapter
([`UpgradeAwareEntityMetadataAdapter`](packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter.ts))
now correctly flips
`isSelect`/`isInsert`/`isUpdate` to `false` once the drop migration's
cursor is crossed, the *static* TypeScript types derived from
`RolePermissionFlagEntity`
(`UniversalFlatRolePermissionFlag`, `FlatRolePermissionFlag`,
`MetadataEntityPropertyConfiguration<'rolePermissionFlag'>`, etc.) still
see `flag` as a required scalar property — because the entity declares
it.
That means every producer of one of those derived types must include
`flag`:
-
`from-create-role-permission-flag-input-to-flat-role-permission-flag-to-create.util.ts`
plumbs it through.
- `from-permission-flag-to-universal-flat-role-permission-flag.util.ts`
(the application-manifest converter) sets `flag: permissionFlag.flag`.
- `all-entity-properties-configuration-by-metadata-name.constant.ts` has
a `flag` entry under `rolePermissionFlag`.
- `CreateRolePermissionFlagInput` keeps the `flag` field.
- `RolePermissionFlagService.upsertPermissionFlags` passes `flag:
permissionFlag.key as PermissionFlagType` to the create util.
Explored phantom-brand approach (`RemovedInUpgrade<T>` wrapper on the
field type, key-filter mapped type applied inside `ScalarFlatEntity` /
`UniversalFlatEntityFrom`) but previous commands could have `flag ===
undefined` (downcast from the brand since we can't compare with
UpgradeMigrationName like we do with a decorator). That's a
**silent-read** failure mode: compiles fine, comparisons against `flag`
silently always-false, no error surfaces. Probably worth too much risk
for what's a small amount of plumbing?
The eventual full deletion of `flag` (entity field included) is a future
cleanup once we drop cross-upgrade support for versions ≤ 2.6
Note: Not sure if this PR (and even the decorator) is really needed in
the end, seems we need to keep a lot of code in place to handle legacy.
Maybe a simple noop [At]Deprecated is enough @charlesBochet (and a
migration to set the column nullable if that was not the case before +
remove associated constraints)
# Introduction
Following connected account permissions refactor and encryption
Removing the old workspace schema twenty standard application
connectedAccount objects and related standard fields and index
- a lot of deadcode
- instance command backfill cleaning the connected account object from
workspaces
## Summary
- Re-resolves the transitive `@xmldom/xmldom` dependency to `0.8.13` to
fix four high-severity Dependabot alerts.
- yarn.lock-only change: all four upstream consumers
(`@node-saml/node-saml`, `plist`, `xml-crypto`, `xml-encryption`) accept
`^0.8.x`, so the previous `0.8.10` / `0.8.11` entries collapse onto a
single `0.8.13` resolution. No `package.json` change needed.
## Alerts fixed
- XML node injection through unvalidated comment serialization (high)
- XML node injection through unvalidated processing instruction
serialization (high)
- XML injection through unvalidated DocumentType serialization (high)
- Uncontrolled recursion in XML serialization leads to DoS (high)
All four advisories are patched in `0.8.13`, the latest release in the
`0.8.x` line.
## Summary
Fixes the reply account resolution path so email replies use the
connected account associated with the thread's message channel instead
of blindly selecting the first connected account.
This targets the failure mode reported in #20658 where replying can
surface `SMTP is not configured for connected account` even though the
thread's actual IMAP/SMTP account has SMTP configured.
## Changes
- Query `myMessageChannels` alongside `myConnectedAccounts` in
`useEmailThread`
- Resolve the latest message's `messageChannelId` to its
`connectedAccountId`
- Return the matching connected account for reply context instead of
`myConnectedAccounts[0]`
- Add a regression test proving the hook chooses the thread channel's
account when the first account is different
## Validation
- `git diff --check`
I could not run the full frontend test locally in this workspace because
dependency installation failed with `ENOSPC: no space left on device`
while Yarn was cloning a dependency into the Windows temp/cache path.
The code change is intentionally narrow and covered by the added hook
regression test.
---------
Co-authored-by: neo773 <neo773@protonmail.com>
## Summary
- Set `twenty-website`'s `dev` script to run Next.js on port `3002` by
default.
- Set `twenty-website`'s `start` script to use the same default port.
## Why
`twenty-website` previously inherited Next.js' default port `3000`,
which is also Twenty's backend/server default. The main Twenty frontend
already defaults to `3001`, so using `3002` for the website avoids local
port collisions when running the website next to the app server and
frontend.
This keeps the local convention sequential:
- `3000`: Twenty backend/server
- `3001`: Twenty frontend app
- `3002`: Twenty website
## Validation
- Parsed `packages/twenty-website/package.json` successfully with Node.
- Ran `git diff --check` for the changed package file.
- Verified Next.js supports the `--port` option for `next dev`.
---------
Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
## Context
In the record-page edit mode, the "Add widget" section has three menu
items:
`Fields group`, `Field`, and `More widgets`. `Fields group` creates the
widget, focuses it, and opens its settings side panel. `Field` only
added the widget to the draft — no focus, no panel — which left the user
without visible confirmation or a way to immediately edit the new
widget.
## Change
Updated `useCreateRecordPageFieldWidget` to mirror
`useCreateRecordPageFieldsWidget`:
After appending the new widget to the draft, it now sets
`pageLayoutEditingWidgetIdComponentState` to the new widget's id and
navigates the side panel to `SidePanelPages.RecordPageFieldSettings`
(with `focusTitleInput: true`, `resetNavigationStack: true`).
This matches the behavior of clicking an existing field widget in
`useOpenWidgetSettingsInSidePanel` (`WidgetType.FIELD` branch).
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).
This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.
**Please review before merging** — verify no critical models were
incorrectly deprecated.
Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
Bumps @recallai/desktop-sdk from 2.0.8 to 2.0.15.
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
## Summary
When an existing user accepts an invite into a workspace whose
`activationStatus` is not `ACTIVE` (e.g. `SUSPENDED`, `INACTIVE`,
`PENDING_CREATION`), the throw in
`throwIfWorkspaceIsNotReadyForSignInUp` returns:
> User is not part of the workspace
The message describes the symptom (they aren't a member yet) instead of
the cause (the workspace can't accept new members), which makes invitees
assume their invite is broken when the real issue is the target
workspace's state.
The sibling branch a few lines above — for brand-new users hitting the
same non-ACTIVE workspace — already returns `"Workspace is not ready to
welcome new members"`. This PR reuses the same message in the
existing-user branch so both paths give a consistent, accurate
explanation.
Single file, two string literals.
## Test plan
- [ ] Sign in via Google with an existing Twenty account, accepting an
invite to a `SUSPENDED` workspace → confirm the new message is shown
instead of "User is not part of the workspace".
- [ ] Confirm the happy path (sign-in to an `ACTIVE` workspace via
invite) is unchanged — early-return on `ACTIVE` is untouched.
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
# Introduction
Only display failing unit tests trace in the ci-server server-test jobs
So it's possible to identify what unit test are failing without having
to re run them locally
## Summary
Adds the \`secret-encryption:rotate\` CLI command, which re-encrypts
every at-rest secret stored in an \`enc:v2:\` envelope under the current
\`ENCRYPTION_KEY\`. The command is **online** and **resumable**: a SQL
filter skips rows already on the current keyId, so interrupting it
(Ctrl-C, container restart, …) and re-running picks up where it left off
without re-rotating earlier rows.
### Sites covered (one handler each)
| Site | Table.column | Scope |
| --- | --- | --- |
| \`connected-account-tokens\` | \`connectedAccount.{accessToken,
refreshToken}\` | workspace |
| \`application-variable\` | \`applicationVariable.value\` (isSecret
only) | workspace |
| \`application-registration-variable\` |
\`applicationRegistrationVariable.encryptedValue\` | instance |
| \`signing-key-private-keys\` | \`signingKey.privateKey\` | instance |
| \`sensitive-config-storage\` | \`keyValuePair.value\` (isSensitive +
STRING configs) | instance |
| \`totp-secrets\` | \`twoFactorAuthenticationMethod.secret\` |
workspace |
Each handler:
- Filters at SQL level on \`value LIKE 'enc:v2:%' AND value NOT LIKE
'enc:v2:<primaryKeyId>:%'\` to enforce idempotency without re-decrypting
already-rotated rows.
- Uses cursor-based batching (default **200**, capped **5000**).
- Threads \`workspaceId\` into HKDF for workspace-scoped sites; runs
instance-scoped for the rest.
### CLI flags
| Flag | Description |
| --- | --- |
| \`-s, --site <site>\` | Limit to a single site. |
| \`-b, --batch-size <n>\` | Override per-batch row count. |
| \`-d, --dry-run\` | Decrypt + re-encrypt in memory, skip the
\`UPDATE\`. |
The runner logs progress via Nest \`Logger\` (per-site start,
completion, final summary) and exits non-zero when any site reports
\`errors > 0\`. \`FALLBACK_ENCRYPTION_KEY\` must be set to the previous
\`ENCRYPTION_KEY\` during rotation; the runner warns when it is unset.
Operator documentation lives in #20611 (docs PR).
As title but I also refactored it a little to match our current file and
code conventions since the code was very old
Reported by a cloud customer
---------
Co-authored-by: martmull <martmull@hotmail.fr>
## Summary
`PermissionsException` thrown by `SettingsPermissionGuard` (and other
permission code paths) was bubbling up through every typed REST
exception filter and landing in the global `UnhandledExceptionFilter`,
which falls back to **500** for anything that isn't an `HttpException`.
So a forbidden user (e.g. an API key whose role doesn't have
`DATA_MODEL`) calling `GET /rest/metadata/objects` got:
```
HTTP/1.1 500 Internal Server Error "Entity performing the request does not have permission"
```
GraphQL already had the right plumbing via
`permissionGraphqlApiExceptionHandler` (`ForbiddenError` → 403,
`UserInputError` → 400, `NotFoundError` → 404). This PR mirrors it on
the REST side.
## What
- New util `permissionRestApiExceptionCodeToHttpStatus` mapping every
`PermissionsExceptionCode` → HTTP status, with `assertUnreachable` to
force explicit handling of future codes.
- New filter `PermissionsRestApiExceptionFilter`
(`@Catch(PermissionsException)`) that delegates to
`HttpExceptionHandlerService.handleError(...)` with the resolved status.
- Wired `PermissionsRestApiExceptionFilter` (placed first, so the typed
filter wins over any sibling catch-all) into `@UseFilters(...)` of every
REST controller that uses `SettingsPermissionGuard` or whose service can
throw `PermissionsException`:
- `object-metadata`, `field-metadata`, `webhook`, `api-key`
- `view`, `view-sort`, `view-group`, `view-filter`, `view-filter-group`,
`view-field`
- `page-layout`, `page-layout-widget`, `page-layout-tab`
- `front-component`, `ai-generate-text`
- Unit tests covering 403 / 400 / 404 / 500 mappings.
## Mapping
| Code | Status |
|------|--------|
| `PERMISSION_DENIED`, `NO_AUTHENTICATION_CONTEXT`,
`ROLE_LABEL_ALREADY_EXISTS`, `CANNOT_UNASSIGN_LAST_ADMIN`,
`CANNOT_UPDATE_SELF_ROLE`, `CANNOT_DELETE_LAST_ADMIN_USER`,
`ROLE_NOT_EDITABLE`, `CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT`,
`CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT` | **403** |
| `INVALID_ARG`, `INVALID_SETTING`,
`CANNOT_GIVE_WRITING_PERMISSION_ON_NON_READABLE_OBJECT`,
`CANNOT_GIVE_WRITING_PERMISSION_WITHOUT_READING_PERMISSION`,
`ONLY_FIELD_RESTRICTION_ALLOWED`,
`FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT`,
`FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT`,
`EMPTY_FIELD_PERMISSION_NOT_ALLOWED`,
`ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET`, `ROLE_CANNOT_BE_ASSIGNED_TO_USERS`
| **400** |
| `ROLE_NOT_FOUND`, `OBJECT_METADATA_NOT_FOUND`,
`FIELD_METADATA_NOT_FOUND`, `FIELD_PERMISSION_NOT_FOUND`,
`PERMISSION_NOT_FOUND` | **404** |
| All remaining "internal" codes (rethrown as-is in GraphQL) | **500** |
## Before
<img width="507" height="216" alt="Screenshot 2026-05-19 at 19 26 07"
src="https://github.com/user-attachments/assets/21d633aa-7ee8-4923-94e4-7ad57258a29e"
/>
## After
<img width="610" height="385" alt="Screenshot 2026-05-19 at 19 26 01"
src="https://github.com/user-attachments/assets/0103b7ee-7df7-4aef-999a-73c22901afd2"
/>
## Summary
Adds review apps for the marketing site. Every PR that touches
`packages/twenty-website/**` or `packages/twenty-shared/**` gets a
per-version Worker preview URL, sticky-commented on the PR, auto-cleaned
up when the PR closes.
Same Cloudflare machinery skew protection rides on, just used for
previews — no extra plan, no extra services. Cleaner than the
GitHub-Actions-runner + Cloudflare-tunnel pattern: previews persist for
the life of the version, accessible from anywhere, no warm-up.
## Files
- **`.github/workflows/website-pr-preview.yaml`** — on PR
open/sync/reopen: builds the Worker with a per-PR `DEPLOYMENT_ID`, runs
`wrangler versions upload --tag pr-<N>` (no production traffic),
sticky-comments the preview URL. Skipped on fork PRs because GitHub
doesn't pass secrets to forks anyway.
- **`.github/workflows/website-pr-preview-cleanup.yaml`** — on PR close:
walks the Worker version list via the CF API, deletes anything tagged
`pr-<N>` (with message-based fallback if the annotation key changes),
updates the sticky comment.
- **`open-next.config.ts`** — `maxNumberOfVersions: 10 → 50` to leave
room for PR previews on top of skew protection's prod-version retention.
## How it looks on a PR
The bot leaves a sticky comment like:
> 🔍 **Website preview** is up at
**https://abc12345-twenty-website-dev.twentyhq.workers.dev**
>
> | | |
> |---|---|
> | Version | `abc12345-...` |
> | Commit | `<sha>` |
> | Bindings | shared with the `dev` Worker (R2 cache + secrets) |
>
> Updates on every push. Auto-deleted when the PR closes.
On close it becomes:
> 🧹 Website preview for this PR was cleaned up after close.
## Twenty repo credentials already provisioned
- `secret CLOUDFLARE_API_TOKEN` — same scoped token the `twenty-infra`
workflow uses
- `var CLOUDFLARE_ACCOUNT_ID` = `67b2bbe4381006564d2b0aa6ce6177be`
- `var CF_PREVIEW_DOMAIN` = `twentyhq` (no `.workers.dev` suffix —
OpenNext appends it;
[opennextjs-cloudflare#811](https://github.com/opennextjs/opennextjs-cloudflare/issues/811))
## Known limitations
- **Shared dev bindings**: PR previews use the dev Worker's R2 bucket +
secrets (Stripe test key, JWT private key). Fine for a read-mostly
marketing site; if two simultaneous PRs ever fight over ISR cache state
we can prefix R2 keys per-PR later.
- **Fork PRs don't get previews**. GitHub Actions doesn't pass
`secrets.*` to fork-PR runs (security), and the wrangler upload requires
the CF token. To enable forks, would need to switch to
`pull_request_target` and gate on a maintainer label — not done here
because the security tradeoff isn't worth it for a marketing-site
preview.
- **Version cap**: 50 versions is the new ceiling, and
`maxVersionAgeDays: 14` auto-prunes anything older. Cleanup-on-close
should keep us well under in steady state.
## Test plan
- [ ] CI on this PR triggers the preview workflow itself; check that the
sticky comment appears with a working URL
- [ ] Hit the URL, click around — should look like a fresh
marketing-site build with this PR's changes
- [ ] Close (don't merge) → cleanup workflow should run; sticky comment
switches to the "cleaned up" message; the version is gone from `wrangler
versions list --name twenty-website-dev`
## What
One-line token swap on the same-repo dispatch step in
[`preview-env-dispatch.yaml`](.github/workflows/preview-env-dispatch.yaml#L40):
`secrets.GITHUB_TOKEN` → `secrets.CI_PRIVILEGED_DISPATCH_TOKEN`.
## Why
Regression from [#20476](https://github.com/twentyhq/twenty/pull/20476)
("security: harden CI against supply-chain attacks"), merged 2026-05-12.
That PR replaced
```yaml
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
...
```
with a raw `gh api` call but kept `GITHUB_TOKEN`:
```yaml
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api repos/"$REPOSITORY"/dispatches -f event_type=preview-environment ...
```
The auto-provisioned `GITHUB_TOKEN` can't fire `repository_dispatch` via
`gh api` even when the workflow declares `permissions: contents: write`.
The action used a different code path that worked; the CLI requires a
token with `repo` scope. So every dispatch from this workflow has
returned `403 Resource not accessible by integration` since that PR
merged — except for runs the `author_association` / `preview-app` label
gate skips entirely (which then show "success" because no jobs ran).
Recent failed example:
https://github.com/twentyhq/twenty/actions/runs/26162974597/job/76959379235?pr=20769
## The fix
`secrets.CI_PRIVILEGED_DISPATCH_TOKEN` already exists in repo secrets
and is **already used** by the immediately-following cross-repo dispatch
step in the same file. Using it for the same-repo dispatch too matches
the surrounding code and is consistent with the original hardening
intent (use a scoped PAT, not the auto-provisioned token).
## Test plan
- [ ] Merge this PR
- [ ] Next PR open / sync / reopen on a member's branch → check that
`Preview Environment Dispatch` succeeds (no 403)
- [ ] Confirm `Preview Environment Keep Alive` workflow gets triggered
(the downstream effect of the dispatch)
- [ ] Confirm the tunnel URL sticky comment lands on the PR
Discovered while testing an unrelated PR
([#20762](https://github.com/twentyhq/twenty/pull/20762)). Independent
fix.
- check size while reading stream instead of checking after reading all
stream
- move MAX_TARBALL_UPLOAD_SIZE_BYTES to config variables
- increase MAX_TARBALL_UPLOAD_SIZE_BYTES default from 50Mb to 100Mb
# Introduction
This PR is a followup of https://github.com/twentyhq/twenty/pull/20673
It aims to unify the authentication/permissions layer with all the
connectedAccount interactions across the application
## Deprecate
- findAll
- findById
## Email sync
An user can only sync the message of his own connected account
## Workflow email
- Related https://github.com/twentyhq/private-issues/issues/478
- Only reauthorize owned account
## Summary
- Fixes a regression from #20208 where creating a new CODE workflow step
shows no input fields
- The split-triggers PR removed `SEED_LOGIC_FUNCTION_INPUT_SCHEMA` and
replaced `toolInputSchema` with `workflowActionTriggerSettings`, but
`CodeStepBuildService.createCodeStepLogicFunction` was not updated to
pass the seed schema — causing `logicFunctionInput` to default to `{}`
and no fields to render
- Adds `SEED_WORKFLOW_ACTION_TRIGGER_SETTINGS` constant (matching the
seed template's `{ a: string, b: number }` params) and passes it when
creating the seed logic function
## Test plan
- [x] Unit test updated to assert `logicFunctionInput` contains `{ a:
null, b: null }` on code step creation
- [x] Create a new CODE step in the workflow builder and verify input
fields `a` and `b` appear immediately
Co-authored-by: Cursor <cursoragent@cursor.com>
## Summary
The original goal of this whole migration: **cross-deployment skew is
now handled by OpenNext's per-version routing instead of by users having
to refresh.**
A client holding a stale tab from deployment X requests assets with
`?dpl=X` — the Worker compares X to the current `DEPLOYMENT_ID`, looks
it up in `CF_DEPLOYMENT_MAPPING`, and routes to the matching old Worker
version via its per-version preview URL
(`<old-version>-twenty-website-<env>.twentyhq.workers.dev`). The old
version serves the old assets / RSC payloads / Server Actions
consistently.
**Verified end-to-end on dev**:
| | Marker in HTML |
|---|---|
| Current Worker (`twenty-main.com/`) | `9npeiytir8EPOtW71cqDZ` |
| Stale request (`twenty-main.com/?dpl=<previous-deploy-id>`) |
`B9OC_TNl1vaGcJ5oUUty6` |
| Direct hit on old preview URL | `B9OC_TNl1vaGcJ5oUUty6` ← matches the
skew-routed response |
## Changes
**`open-next.config.ts`** — enable skew protection
```ts
const baseConfig = defineCloudflareConfig({ incrementalCache: r2IncrementalCache });
export default {
...baseConfig,
cloudflare: {
...baseConfig.cloudflare,
skewProtection: {
enabled: true,
maxNumberOfVersions: 10,
maxVersionAgeDays: 14,
},
},
};
```
(`defineCloudflareConfig` doesn't accept `skewProtection` directly — has
to be merged in)
**`next.config.ts`** — `deploymentId: process.env.DEPLOYMENT_ID`. CI
sets `DEPLOYMENT_ID` per-build; Next bakes it into prerendered HTML,
`?dpl=…` on asset URLs, Server Actions, and RSC fetch headers.
**`wrangler.jsonc`**:
- `compatibility_date: 2026-04-15` (was `2025-01-15`; build was warning)
- `assets.run_worker_first: true` — Worker must intercept asset requests
so the skew handler can route stale `/_next/static/*` to the old
version. CF edge cache absorbs hot paths so this isn't a 5×
billable-invocation tax
- `preview_urls: true` — required; skew routes via the per-version
preview URL which only exists when previews are enabled
- Per-env `services: [{ binding: WORKER_SELF_REFERENCE, service:
twenty-website-<env> }]` — OpenNext's recommended setup for
fire-and-forget ISR revalidation
- Per-env `vars`: `CF_WORKER_NAME` + `CF_PREVIEW_DOMAIN` (bare
`twentyhq`, *not* `twentyhq.workers.dev` — OpenNext appends
`.workers.dev` itself, see
[opennextjs-cloudflare#811](https://github.com/opennextjs/opennextjs-cloudflare/issues/811))
- Kept `global_fetch_strictly_public` in compat flags — without it, CF's
optimised intra-account routing self-loops the cross-version fetch and
522s out. With it, the fetch takes the public-Internet path which routes
correctly.
**`public/_headers`** — deleted (with `run_worker_first: true` the
assets pipeline doesn't process it; Next sets the same `Cache-Control:
immutable` on `/_next/static/*` anyway).
## Companion infra PR
https://github.com/twentyhq/twenty-infra/pull/__ — wires the four CF env
vars (`DEPLOYMENT_ID`, `CF_WORKER_NAME`, `CF_PREVIEW_DOMAIN`,
`CF_ACCOUNT_ID`, `CF_WORKERS_SCRIPTS_API_TOKEN`) into the deploy
workflow.
## Known limitation
Skew routing only works for Worker versions deployed AFTER this PR
(older versions don't have `preview_urls: true` and don't have
`DEPLOYMENT_ID` bindings OpenNext can read). Users on tabs older than
the first post-merge deploy still fall through to the current Worker
(same behaviour as today).
OpenNext marks `skewProtection` as **experimental** in their type docs
("might break on minor releases") — worth keeping an eye on.
closes
https://discord.com/channels/1130383047699738754/1505967920163983502
Update logic-function docs to match the real `DatabaseEventPayload`
shape.
The docs now show database event payloads as record-level events with
`recordId` and `properties.before/after/diff/updatedFields`, including
compact examples for created, updated, and destroyed events. Route
payload type imports now use the preferred `twenty-sdk/logic-function`
surface.
Also clean up the shared payload type wrapper so it models event
metadata without over-promising actor fields; `userId`,
`userWorkspaceId`, and `workspaceMemberId` remain optional through the
underlying event type.