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-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 <>; 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" 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/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; 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..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 @@ -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({ @@ -219,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, }); } 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, {