From 663ef332ad1225992f800ee8def219f772f0a821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 14 May 2026 17:02:07 +0200 Subject: [PATCH 1/6] feat(auth): resume workspace selection on /welcome with valid tokenPair cookie (#20575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary After a user completes a multi-workspace social-SSO sign-in, [auth.service.ts:988-1011](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts#L988-L1011) issues a **workspace-agnostic** access + refresh token pair and lands them on `app.twenty.com/welcome?tokenPair=…`. [SignInUpGlobalScopeFormEffect.tsx](packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx) reads the URL param, writes the cookie, pushes them to `SignInUpStep.WorkspaceSelection`. The problem: if the user revisits `app.twenty.com/welcome` later (e.g. ChatGPT pings `/authorize` and the global page-change effect redirects them to `/welcome` with `returnToPath=/authorize?…`), the existing branch is a no-op — the URL param is gone. The user sees the regular email/SSO form and has to re-authenticate, even though the workspace-agnostic cookie is still valid. This PR adds a second branch in the same `useEffect` that handles the "valid cookie, no URL param" case: ```ts if (signInUpStep !== SignInUpStep.Init) return; if (!hasAccessTokenPair) return; loadCurrentUser(); setSignInUpStep(SignInUpStep.WorkspaceSelection); ``` Single `useEffect`, no `useRef`, no async then/catch. The synchronous `setSignInUpStep(WorkspaceSelection)` is the gate — once the step transitions, subsequent effect runs early-return. Mirrors the existing URL-param branch's pattern exactly. If the cookie is stale, `loadCurrentUser` triggers Apollo's renewal middleware. Renewal of a workspace-agnostic refresh token is supported end-to-end (verified in audit, see below) — if it succeeds the user sees their workspaces; if both tokens are expired, `onUnauthenticatedError` clears the cookie and the next render lands them on the regular sign-in form. Same fallback as if the cookie had never been there. ## Behavior matrix | State on /welcome mount | Before | After | |---|---|---| | No tokenPair anywhere | Show sign-in form | Show sign-in form | | tokenPair in URL (just bounced from SSO) | Set tokens → WorkspaceSelection | (unchanged) Set tokens → WorkspaceSelection | | tokenPair in cookie, access valid | Show sign-in form ❌ | **→ WorkspaceSelection ✓** | | tokenPair in cookie, access expired, refresh valid | Show sign-in form (Apollo eventually 401s on a query) | Renewal succeeds silently → WorkspaceSelection ✓ | | tokenPair in cookie, both expired | Show sign-in form | `onUnauthenticatedError` clears cookie → fall back to sign-in form | ## Workspace-agnostic renewal: confirmed working end-to-end Audit summary: - **Refresh token carries the type**: [refresh-token.service.ts:104](packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts) preserves `targetedTokenType` in the JWT payload and returns it from `verifyRefreshToken`. - **Renewal branches on type** ([renew-token.service.ts:70-87](packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts)): ```ts const accessToken = isDefined(authProvider) && targetedTokenType === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC && !isDefined(workspaceId) ? await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken({...}) : await this.accessTokenService.generateAccessToken({...}); ``` Renewed refresh token preserves `targetedTokenType` (line 93). - **Resolver is workspace-agnostic**: `@UseGuards(PublicEndpointGuard, NoPermissionGuard)` on `renewToken` ([auth.resolver.ts:796-804](packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts)) — no `@AuthWorkspace()` requirement, callable from `app.twenty.com`. - **Frontend middleware is type-agnostic**: [apollo.factory.ts:180-209](packages/twenty-front/src/modules/apollo/services/apollo.factory.ts) just passes the refresh token blob. Net: no backend change needed. The full workspace-agnostic lifecycle (issue → cookie → renew → re-issue) already works. ## Test plan - [x] `npx oxlint` + `prettier --check` — clean. - [x] `npx nx typecheck twenty-front` — clean. - [ ] Manual: complete one full SSO flow ending on a workspace subdomain. Visit `https://app.twenty.com/welcome` directly — expect the workspace picker, not the sign-in form. - [ ] Manual: same but with tokenPair cookie cleared — expect the regular sign-in form (no regression). - [ ] Manual: sign-out from a workspace, then visit `app.twenty.com/welcome` — expect the regular form (sign-out clears the cookie via full page reload). - [ ] Manual: stale/expired tokenPair cookie — Apollo renewal kicks in transparently; if renewal fails, regular form (no infinite loop, no crash). - [ ] Manual: pair with #20572 — visit `app.twenty.com/authorize?…` with a stale workspace-agnostic cookie. Expected chain: `/authorize` renders → `PageChangeEffect` redirects to `/welcome?returnToPath=/authorize?…` → this effect lands the user on WorkspaceSelection → picking a workspace bounces to `/authorize?…` where consent renders. ## Out of scope - Fixing `lastAuthenticatedWorkspaceDomain` for custom-domain users (separate cookie-scoping issue, tracked separately). --- .../SignInUpGlobalScopeFormEffect.tsx | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx index 1b22299c542..f553de2cc57 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx @@ -1,35 +1,56 @@ import { useAuth } from '@/auth/hooks/useAuth'; +import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; import { SignInUpStep, signInUpStepState, } from '@/auth/states/signInUpStepState'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser'; import { useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { isDefined } from 'twenty-shared/utils'; export const SignInUpGlobalScopeFormEffect = () => { const setSignInUpStep = useSetAtomState(signInUpStepState); + const signInUpStep = useAtomStateValue(signInUpStepState); const [searchParams, setSearchParams] = useSearchParams(); const { setAuthTokens } = useAuth(); const { loadCurrentUser } = useLoadCurrentUser(); + const hasAccessTokenPair = useHasAccessTokenPair(); useEffect(() => { - const tokenPair = searchParams.get('tokenPair'); - if (isDefined(tokenPair)) { - setAuthTokens(JSON.parse(tokenPair)); + // Path 1: user just bounced back from social SSO with a workspace-agnostic + // tokenPair in the URL. Honor it unconditionally. + const tokenPairFromUrl = searchParams.get('tokenPair'); + if (isDefined(tokenPairFromUrl)) { + setAuthTokens(JSON.parse(tokenPairFromUrl)); searchParams.delete('tokenPair'); setSearchParams(searchParams); loadCurrentUser(); setSignInUpStep(SignInUpStep.WorkspaceSelection); + return; } + + // Path 2: user revisits /welcome with a still-valid workspace-agnostic + // tokenPair cookie left over from a prior SSO landing. Resume straight + // to workspace selection instead of forcing them through the sign-in + // form again. The step transition gates re-entry; if the cookie is + // stale, loadCurrentUser triggers Apollo's renewal -> onUnauthenticatedError + // path which clears the cookie and falls back to the normal form. + if (signInUpStep !== SignInUpStep.Init) return; + if (!hasAccessTokenPair) return; + + loadCurrentUser(); + setSignInUpStep(SignInUpStep.WorkspaceSelection); }, [ searchParams, setSearchParams, setSignInUpStep, loadCurrentUser, setAuthTokens, + signInUpStep, + hasAccessTokenPair, ]); return <>; From 94748b70429b5664f7c122af0be771695cf08d50 Mon Sep 17 00:00:00 2001 From: "twenty-pr[bot]" <281954394+twenty-pr[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 17:02:38 +0200 Subject: [PATCH 2/6] chore: bump version to 2.6.0 (#20585) ## 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 --- .../upgrade/constants/twenty-current-version.constant.ts | 2 +- .../upgrade/constants/twenty-next-versions.constant.ts | 4 +++- .../upgrade/constants/twenty-previous-versions.constant.ts | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-current-version.constant.ts b/packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-current-version.constant.ts index 0b20269715e..369818bbbdf 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-current-version.constant.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-current-version.constant.ts @@ -7,4 +7,4 @@ * |___/ */ -export const TWENTY_CURRENT_VERSION = '2.5.0' as const; +export const TWENTY_CURRENT_VERSION = '2.6.0' as const; diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-next-versions.constant.ts b/packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-next-versions.constant.ts index 89b69079cf4..6306b2de500 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-next-versions.constant.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-next-versions.constant.ts @@ -7,4 +7,6 @@ * |___/ */ -export const TWENTY_NEXT_VERSIONS = ['2.6.0'] as const; +export const TWENTY_NEXT_VERSIONS = [ + '2.7.0', +] as const; diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-previous-versions.constant.ts b/packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-previous-versions.constant.ts index 68c45245c65..20e7e801bd5 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-previous-versions.constant.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-previous-versions.constant.ts @@ -16,4 +16,5 @@ export const TWENTY_PREVIOUS_VERSIONS = [ '2.2.0', '2.3.0', '2.4.0', + '2.5.0', ] as const; From 5a1d3841f4e718a554a43f9f3e5fb551d6ba7e24 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 14 May 2026 17:14:31 +0200 Subject: [PATCH 3/6] Bump twenty-sdk, twenty-client-sdk, create-twenty-app to 2.5.0 (#20587) ## Summary - Bumps `twenty-sdk` from `2.4.2` to `2.5.0`. - Bumps `twenty-client-sdk` from `2.4.2` to `2.5.0`. - Bumps `create-twenty-app` from `2.4.2` to `2.5.0`. --- packages/create-twenty-app/package.json | 2 +- packages/twenty-client-sdk/package.json | 2 +- packages/twenty-sdk/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/create-twenty-app/package.json b/packages/create-twenty-app/package.json index 52c169d19a8..4790357d9e1 100644 --- a/packages/create-twenty-app/package.json +++ b/packages/create-twenty-app/package.json @@ -1,6 +1,6 @@ { "name": "create-twenty-app", - "version": "2.4.2", + "version": "2.5.0", "description": "Command-line interface to create Twenty application", "main": "dist/cli.cjs", "bin": "dist/cli.cjs", diff --git a/packages/twenty-client-sdk/package.json b/packages/twenty-client-sdk/package.json index bd595a53f35..e3dbe42e754 100644 --- a/packages/twenty-client-sdk/package.json +++ b/packages/twenty-client-sdk/package.json @@ -1,6 +1,6 @@ { "name": "twenty-client-sdk", - "version": "2.4.2", + "version": "2.5.0", "sideEffects": false, "license": "AGPL-3.0", "scripts": { diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index c27992df6a3..a22a6228e0f 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -1,6 +1,6 @@ { "name": "twenty-sdk", - "version": "2.4.2", + "version": "2.5.0", "sideEffects": false, "bin": { "twenty": "dist/cli.cjs" From 78b30928869df32cd15feb8b93a055984f596e46 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 14 May 2026 18:30:38 +0200 Subject: [PATCH 4/6] fix(server): batch upgrade migration inserts to stay under PG param limit (#20588) ## Summary Prod deploy of v2.5.0 fails with a query failure inserting into `core.upgradeMigration`: ``` query failed: INSERT INTO "core"."upgradeMigration" ("id", "name", "status", "attempt", "executedByVersion", "errorMessage", "isInitial", "workspaceId", "createdAt") VALUES (DEFAULT, $1, $2, $3, $4, $5, DEFAULT, $6, DEFAULT), (DEFAULT, $7, $8, $9, $10, $11, DEFAULT, $12, DEFAULT), ... (continues past $2515) ... ``` ### Root cause `UpgradeMigrationService.recordUpgradeMigration` writes one row per workspace via a single `repository.save([...rows])` call. `UpgradeMigrationEntity` has **6 user-provided columns** per row (`name`, `status`, `attempt`, `executedByVersion`, `errorMessage`, `workspaceId`), so the multi-row INSERT binds `6 * (1 + N_workspaces)` parameters. Postgres' wire protocol caps a single statement at **65,535 bind parameters** (16-bit count). That gives a hard ceiling of ~10,920 rows per call. Production has enough workspaces to overflow. --- .../services/upgrade-migration.service.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts index 2fd618d2b94..2077516ad9b 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import chunk from 'lodash.chunk'; import { isDefined } from 'twenty-shared/utils'; import { In, IsNull, type QueryRunner, Repository } from 'typeorm'; @@ -21,6 +22,8 @@ export type WorkspaceLastAttemptedCommand = { isInitial: boolean; }; +const UPGRADE_MIGRATION_SAVE_BATCH_SIZE = 1000; + @Injectable() export class UpgradeMigrationService { constructor( @@ -95,7 +98,7 @@ export class UpgradeMigrationService { where: { name, workspaceId: IsNull() }, }); - await repository.save([ + const instanceRows = [ { name, status, @@ -112,7 +115,14 @@ export class UpgradeMigrationService { workspaceId, errorMessage, })), - ]); + ]; + + for (const batch of chunk( + instanceRows, + UPGRADE_MIGRATION_SAVE_BATCH_SIZE, + )) { + await repository.save(batch); + } return; } @@ -134,7 +144,9 @@ export class UpgradeMigrationService { }); } - await repository.save(rows); + for (const batch of chunk(rows, UPGRADE_MIGRATION_SAVE_BATCH_SIZE)) { + await repository.save(batch); + } } async markAsWorkspaceInitial({ From a5880bd8d09ff52b9e41a2b7f8563ec10be94c2e Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 14 May 2026 18:39:34 +0200 Subject: [PATCH 5/6] fix(server): drop correlated subquery in getWorkspaceLastAttemptedCommandName (#20591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - The upgrade runner calls `getWorkspaceLastAttemptedCommandName` twice per workspace step. Grafana showed it averaging ~4.4s and trending upward as the `core.upgradeMigration` table grows during an in-flight upgrade. - The old query joined every outer row against a correlated subquery (`attempt = (SELECT MAX(sub.attempt) ... WHERE sub.name = m.name AND sub."workspaceId" = m."workspaceId")`). Even with the `(workspaceId, name, attempt)` index added in 2.3, each outer row triggers an index lookup — fine for a few rows, painful at production scale. - Replaced with a two-level `DISTINCT ON`: - Inner `DISTINCT ON ("workspaceId", name) ORDER BY "workspaceId", name, attempt DESC` walks `IDX_UPGRADE_MIGRATION_WORKSPACE_ID_NAME_ATTEMPT` directly and yields one row per `(workspaceId, name)` at max attempt. - Outer `DISTINCT ON ("workspaceId") ORDER BY "workspaceId", "createdAt" DESC` picks the most recent row per workspace. - Semantically identical; planner now does a single index walk + one sort instead of N correlated lookups. The same correlated-subquery shape exists in `getLastAttemptedCommandNameOrThrow`, `areAllWorkspacesAtCommand`, and `getLastAttemptedInstanceCommand`. They run far less often during an upgrade (per instance step, not per workspace step), so they're out of scope for this hotfix — happy to follow up if we want them too. ## Benchmark (prod) Run over all distinct workspaceIds in `core."upgradeMigration"`: | Variant | Execution Time | | --- | --- | | Before (correlated subquery) | **2979.659 ms** | | After (two-level DISTINCT ON) | **1225.690 ms** | ~2.4× faster, and the gap widens as the table grows over the course of an upgrade. Equivalence confirmed: the diff query below returned `0` divergent workspaces on prod. ### Variant A — original (correlated subquery) ```sql SELECT DISTINCT ON (m."workspaceId") m."workspaceId", m.name, m.status, m."executedByVersion", m."errorMessage", m."createdAt", m."isInitial" FROM core."upgradeMigration" m WHERE m."workspaceId" IN ($1, $2, ...) AND m.attempt = ( SELECT MAX(sub.attempt) FROM core."upgradeMigration" sub WHERE sub.name = m.name AND sub."workspaceId" = m."workspaceId" ) ORDER BY m."workspaceId", m."createdAt" DESC; ``` ### Variant B — new (two-level DISTINCT ON) ```sql SELECT DISTINCT ON (latest_per_name."workspaceId") latest_per_name."workspaceId", latest_per_name.name, latest_per_name.status, latest_per_name."executedByVersion", latest_per_name."errorMessage", latest_per_name."createdAt", latest_per_name."isInitial" FROM ( SELECT DISTINCT ON ("workspaceId", name) "workspaceId", name, status, "executedByVersion", "errorMessage", "createdAt", "isInitial" FROM core."upgradeMigration" WHERE "workspaceId" = ANY($1) ORDER BY "workspaceId", name, attempt DESC ) latest_per_name ORDER BY latest_per_name."workspaceId", latest_per_name."createdAt" DESC; ``` ### Equivalence check (returned 0 on prod) ```sql WITH target_ids AS ( SELECT DISTINCT "workspaceId" FROM core."upgradeMigration" WHERE "workspaceId" IS NOT NULL ), old_result AS ( SELECT DISTINCT ON (m."workspaceId") m."workspaceId", m.name, m.status, m."executedByVersion", m."errorMessage", m."createdAt", m."isInitial" FROM core."upgradeMigration" m WHERE m."workspaceId" IN (SELECT "workspaceId" FROM target_ids) AND m.attempt = ( SELECT MAX(sub.attempt) FROM core."upgradeMigration" sub WHERE sub.name = m.name AND sub."workspaceId" = m."workspaceId" ) ORDER BY m."workspaceId", m."createdAt" DESC ), new_result AS ( SELECT DISTINCT ON (latest_per_name."workspaceId") latest_per_name."workspaceId", latest_per_name.name, latest_per_name.status, latest_per_name."executedByVersion", latest_per_name."errorMessage", latest_per_name."createdAt", latest_per_name."isInitial" FROM ( SELECT DISTINCT ON ("workspaceId", name) "workspaceId", name, status, "executedByVersion", "errorMessage", "createdAt", "isInitial" FROM core."upgradeMigration" WHERE "workspaceId" IN (SELECT "workspaceId" FROM target_ids) ORDER BY "workspaceId", name, attempt DESC ) latest_per_name ORDER BY latest_per_name."workspaceId", latest_per_name."createdAt" DESC ), diffs AS ( SELECT 'only_in_old' AS bucket, o."workspaceId", o.name, o.status, o."createdAt" FROM old_result o LEFT JOIN new_result n ON n."workspaceId" = o."workspaceId" WHERE n."workspaceId" IS NULL OR n.name <> o.name OR n.status <> o.status UNION ALL SELECT 'only_in_new', n."workspaceId", n.name, n.status, n."createdAt" FROM new_result n LEFT JOIN old_result o ON o."workspaceId" = n."workspaceId" WHERE o."workspaceId" IS NULL OR o.name <> n.name OR o.status <> n.status ) SELECT COUNT(*) AS divergent_workspaces FROM diffs; ``` ## Test plan - [ ] `npx nx test twenty-server --testPathPattern upgrade-migration` - [ ] Integration tests: `npx nx run twenty-server:test:integration:with-db-reset --testPathPattern sequence-runner` - [ ] Verify on staging that the slow query disappears from the PostgreSQL Grafana board during the next upgrade run --- .../services/upgrade-migration.service.ts | 85 ++++++++++--------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts index 2077516ad9b..2f282059a93 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts @@ -231,48 +231,55 @@ export class UpgradeMigrationService { return new Map(); } - const migrations = await this.upgradeMigrationRepository - .createQueryBuilder('migration') - .select([ - 'migration.workspaceId', - 'migration.name', - 'migration.status', - 'migration.executedByVersion', - 'migration.errorMessage', - 'migration.createdAt', - 'migration.isInitial', - ]) - .where({ - workspaceId: In(workspaceIds), - }) - .andWhere( - `migration.attempt = ( - SELECT MAX(sub.attempt) - FROM core."upgradeMigration" sub - WHERE sub.name = migration.name - AND sub."workspaceId" = migration."workspaceId" - )`, - ) - .orderBy('migration.workspaceId') - .addOrderBy('migration.createdAt', 'DESC') - .distinctOn(['migration.workspaceId']) - .getMany(); + const rows = await this.upgradeMigrationRepository.manager.query< + Array<{ + workspaceId: string; + name: string; + status: UpgradeMigrationStatus; + executedByVersion: string; + errorMessage: string | null; + createdAt: Date; + isInitial: boolean; + }> + >( + ` + SELECT DISTINCT ON (latest_per_name."workspaceId") + latest_per_name."workspaceId", + latest_per_name.name, + latest_per_name.status, + latest_per_name."executedByVersion", + latest_per_name."errorMessage", + latest_per_name."createdAt", + latest_per_name."isInitial" + FROM ( + SELECT DISTINCT ON ("workspaceId", name) + "workspaceId", + name, + status, + "executedByVersion", + "errorMessage", + "createdAt", + "isInitial" + FROM core."upgradeMigration" + WHERE "workspaceId" = ANY($1) + ORDER BY "workspaceId", name, attempt DESC + ) latest_per_name + ORDER BY latest_per_name."workspaceId", latest_per_name."createdAt" DESC + `, + [workspaceIds], + ); const cursors = new Map(); - for (const migration of migrations) { - if (migration.workspaceId === null) { - continue; - } - - cursors.set(migration.workspaceId, { - workspaceId: migration.workspaceId, - name: migration.name, - status: migration.status, - executedByVersion: migration.executedByVersion, - errorMessage: migration.errorMessage, - createdAt: migration.createdAt, - isInitial: migration.isInitial, + for (const row of rows) { + cursors.set(row.workspaceId, { + workspaceId: row.workspaceId, + name: row.name, + status: row.status, + executedByVersion: row.executedByVersion, + errorMessage: row.errorMessage, + createdAt: row.createdAt, + isInitial: row.isInitial, }); } From ca1571676c0c67f026cd541fecbdc3dc09cc1c59 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 14 May 2026 18:40:41 +0200 Subject: [PATCH 6/6] fix(server): treat plaintext-under-isSecret rows as plaintext in app variable encryption migration (#20590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Prod 2.5 upgrade failed on the slow instance command `EncryptApplicationVariableSlowInstanceCommand`: ``` [Nest] LOG [InstanceCommandRunnerService] 2.5.0_EncryptApplicationVariableSlowInstanceCommand_1798000005000 starting data migration... [Nest] WARN [SecretEncryptionService] Decrypted a legacy unprefixed AES-CTR ciphertext... [Nest] ERROR [InstanceCommandRunnerService] data migration failed TypeError: Invalid initialization vector ``` ### Root cause The migration assumes every row matching `isSecret = true AND value <> '' AND value NOT LIKE 'enc:v2:%'` is legacy AES-CTR ciphertext. In prod we found multiple `isSecret = true` rows whose `value` is plaintext (e.g. `SLACK_HOOK_URL = 'https://hooks.slack.com/services/...'`) — most likely the result of `isSecret` being flipped to true on a row that already held a plaintext value, or a write path that bypassed `ApplicationVariableEntityService.update`. Those values can't decode into the 16-byte IV that AES-CTR needs, so `Buffer.from(value, 'base64')` truncates at the first non-base64 char (`:`), the buffer is < 16 bytes, and `createDecipheriv` throws. ### Fix Follow the same policy as `EncryptConnectedAccountTokensSlowInstanceCommand`: anything that isn't already in the `enc:v2:` envelope is plaintext. Concretely: 1. Try `decryptVersioned` — legacy CTR rows decrypt fine. 2. If it throws (mis-classified plaintext), log a warning naming the row id and fall back to treating `row.value` as plaintext. 3. Encrypt the resulting plaintext into the `enc:v2:` envelope and update the row. In-loop `isSecret` guard is kept (alongside the SQL filter) so non-secret rows are never touched even if the SQL filter is ever loosened. ### Integration test coverage Added one new case alongside the existing ones in `…encrypt-application-variable.integration-spec.ts`: - `treats plaintext-under-isSecret=true as plaintext and re-encrypts as v2` — seeds a row with `isSecret = true` and a URL value (`:` and `/` are not base64, so this is the exact failure shape from prod), runs the migration, and asserts the value is now `enc:v2:...` and decrypts back to the original URL. Existing cases unchanged: legacy CTR happy path, non-secret rows untouched, idempotent across re-runs, `up()` adds the CHECK constraint, `down()` removes it. ### Why this is a 2-5 edit `TWENTY_CURRENT_VERSION` is now 2.6.0, so editing a 2-5 file trips the `server-previous-version-upgrade-mutation-guard` — `ci:allow-previous-version-upgrade-mutation` label is on the PR. `up()` and `down()` are unchanged; only `runDataMigration` is modified. ## Test plan - [ ] Re-deploy 2.5 to prod and confirm `EncryptApplicationVariableSlowInstanceCommand` completes - [ ] Inspect warning log to count rows that went through the plaintext fallback - [ ] Verify resulting secret rows all satisfy `value = '' OR value LIKE 'enc:v2:%'` and the CHECK constraint is in place --- ...8000005000-encrypt-application-variable.ts | 62 +++++++++++++++---- ...t-application-variable.integration-spec.ts | 20 ++++++ 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable.ts index babaf6545fd..23f70f04f54 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable.ts @@ -1,3 +1,5 @@ +import { Logger } from '@nestjs/common'; + import { isDefined } from 'twenty-shared/utils'; import { DataSource, QueryRunner } from 'typeorm'; @@ -12,30 +14,47 @@ const VALUE_CHECK_CONSTRAINT_NAME = 'CHK_applicationVariable_value_encrypted'; const V2_ENCRYPTED_LIKE_PATTERN = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%`; +// Legacy CTR ciphertext is base64-encoded and at least 16 bytes (one IV +// block) — i.e. ≥ 22 base64 chars. Anything outside that shape is plaintext. +// Node's `Buffer.from(value, 'base64')` silently skips invalid chars, so a +// URL like `https://hooks.slack.com/...` would otherwise decode into enough +// bytes to "decrypt" to garbage without throwing. +const LEGACY_CTR_LOOKS_LIKE_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/; +const LEGACY_CTR_MIN_LENGTH = 22; + type ApplicationVariableRow = { id: string; workspaceId: string; value: string; + isSecret: boolean; }; +const looksLikeLegacyCtrCiphertext = (value: string): boolean => + value.length >= LEGACY_CTR_MIN_LENGTH && + LEGACY_CTR_LOOKS_LIKE_BASE64_RE.test(value); + @RegisteredInstanceCommand('2.5.0', 1798000005000, { type: 'slow' }) export class EncryptApplicationVariableSlowInstanceCommand implements SlowInstanceCommand { + private readonly logger = new Logger( + EncryptApplicationVariableSlowInstanceCommand.name, + ); + constructor( private readonly secretEncryptionService: SecretEncryptionService, ) {} - // Re-encrypts every secret application variable into the versioned envelope - // bound to its row's workspaceId. Non-secret rows are left untouched — - // their `value` is plaintext by design. Idempotent: the SELECT filter - // skips rows already in v2 form. + // Re-encrypts secret application variables into the v2 envelope. Rows + // marked isSecret=true with a plaintext value (instead of legacy CTR + // ciphertext) are treated as plaintext and encrypted, mirroring + // EncryptConnectedAccountTokensSlowInstanceCommand. async runDataMigration(dataSource: DataSource): Promise { let cursor = '00000000-0000-0000-0000-000000000000'; while (true) { const rows: ApplicationVariableRow[] = await dataSource.query( - `SELECT id, "workspaceId", "value" + `SELECT id, "workspaceId", "value", "isSecret" FROM "core"."applicationVariable" WHERE id > $1 AND "isSecret" = true @@ -51,13 +70,32 @@ export class EncryptApplicationVariableSlowInstanceCommand } for (const row of rows) { - // decryptVersioned handles legacy unprefixed CTR ciphertext by - // falling through to the raw-key decrypt path — exactly what we - // need to read the pre-migration rows. - const plaintext = this.secretEncryptionService.decryptVersioned( - row.value, - { workspaceId: row.workspaceId }, - ); + if (!row.isSecret) { + continue; + } + + let plaintext: string; + + if (looksLikeLegacyCtrCiphertext(row.value)) { + try { + plaintext = this.secretEncryptionService.decryptVersioned( + row.value, + { workspaceId: row.workspaceId }, + ); + } catch (error) { + this.logger.warn( + `applicationVariable row ${row.id} value not valid ciphertext; treating as plaintext. ${ + error instanceof Error ? error.message : String(error) + }`, + ); + plaintext = row.value; + } + } else { + this.logger.warn( + `applicationVariable row ${row.id} value is not base64; treating as plaintext.`, + ); + plaintext = row.value; + } if (!isDefined(plaintext)) { continue; diff --git a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000005000-encrypt-application-variable.integration-spec.ts b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000005000-encrypt-application-variable.integration-spec.ts index 7bdacc3fd18..2d630ecb18c 100644 --- a/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000005000-encrypt-application-variable.integration-spec.ts +++ b/packages/twenty-server/test/integration/upgrade/suites/2-5-instance-command-slow-1798000005000-encrypt-application-variable.integration-spec.ts @@ -164,6 +164,26 @@ describe('2-5 slow instance command 1798000005000 - EncryptApplicationVariableSl expect(row.value).toBe(plaintext); }); + it('treats plaintext-under-isSecret=true as plaintext and re-encrypts as v2', async () => { + const plaintext = + 'https://hooks.slack.com/services/T09QGPB2ZP1/B09QUQ5LY2Z/abc'; + const id = await seedRow({ isSecret: true, value: plaintext }); + + await command.runDataMigration(dataSource); + + const [row] = await dataSource.query( + `SELECT "value" FROM "core"."applicationVariable" WHERE id = $1`, + [id], + ); + + expect(row.value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)).toBe( + true, + ); + expect( + secretEncryptionService.decryptVersioned(row.value, { workspaceId }), + ).toBe(plaintext); + }); + it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => { const plaintext = 'already-v2-secret'; const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext, {