ca0f04923ceada4befb23152c0790f1e5cb9596d
12126
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ca0f04923c | chore(filters): prettier format | ||
|
|
be3b3c95f1 |
refactor(filters): selector returns Map; collapse server resolvers to one-liners
Selector pivot:
- fieldMetadataItemByIdMapSelector returns Map<string, FieldMetadataItem>
(conventional shape — selector returns data, not behavior).
- Callers pull the map and inline `(id) => fieldMetadataItemByIdMap.get(id)`
when passing to the dispatcher. No naming gymnastics, no
selector-holding-a-function pattern.
Server resolver simplifications:
- find-records.workflow-action, build-row-level-permission-record-filter,
view-query-params, common-group-by-query-runner, convert-chart-filter:
drop the inline `{id, name, type, label, options}` rebuild — the
dispatcher's FieldShared only reads id/name/type/label and
findFlatEntityByIdInFlatEntityMaps already returns a superset.
Each resolver collapses to `(id) => findFlatEntityByIdInFlatEntityMaps({...})`.
|
||
|
|
ff2f86848c |
refactor(filters): introduce findFieldMetadataItemByIdSelector and drop createFindFieldMetadataItemById helper
The convenience helper was a sign that callers were each reindexing the same workspace-wide flat field metadata array into the same lookup map. The fix is upstream: derive the lookup once in a Jotai selector and remove the helper. - New `findFieldMetadataItemByIdSelector` (twenty-front) builds the id index once from `flattenedFieldMetadataItemsSelector` and returns a `FindFieldMetadataItemById` callback. - All 10 Jotai-using callers (record-table SSE, record-table empty, table-column-footer, record-index group-by, group common variables, table params, graph widget, calendar date range, total count) pull the callback from the selector instead of indexing themselves. - Two pure utilities (`computeContextStoreFilters`, `getQueryVariablesFromFiltersAndSorts`) now take `findFieldMetadataItemById` directly; their 6 callers pass it from the selector (or `store.get` for the non-React path). - `useFindManyRecordsSelectedInContextStore` also routes the `deletedAt` lookup through the resolver instead of re-flattening. - `turnAnyFieldFilterIntoRecordGqlFilter` (twenty-shared) and `buildRowLevelPermissionRecordFilter` (twenty-server) inline the resolver — the server one collapses to a one-liner against `flatFieldMetadataMaps`, dropping the per-predicate field-id remapping it used to do. - Dispatcher tests inline `Map`-backed resolvers. - `createFindFieldMetadataItemById` and its exports are deleted. |
||
|
|
f1ce22db97 |
fix(test): use empty ObjectFilter + filter-in-memory for metadata id lookup
ObjectFilter / FieldFilter don't expose nameSingular / name as filterable
columns, so the previous targeted queries returned a 400 ("Field
\"nameSingular\" is not defined by type \"ObjectFilter\""). Use the
established pattern from successful-view-filter-relation-traversal:
fetch all objects with fieldsList inlined, then find by name in memory.
|
||
|
|
90ea800f16 |
feat(filters): extend chart filter type for relation traversal + add regression-guarding integration tests
Charts couldn't model one-hop relation-traversal filters because ChartRecordFilter lacked relationTargetFieldMetadataId. The frontend chart-filter UI reuses the shared AdvancedFilterSidePanelContainer (which already produces relation-traversal filters), and the page-layout widget configuration is stored as JSONB — so adding the field on the shared type is the only change needed to plumb traversal end-to-end. Tests added (both regression-guard the silently-dropped-filter bug): - find-records-relation-traversal-workflow.integration-spec.ts: runs a real workflow with a FIND_RECORDS step configured with a one-hop traversal filter (Person where Company → Name CONTAINS "AirbnbWorkflowTest"), asserts only matching people come back. - chart-data-relation-traversal-filter.integration-spec.ts: queries barChartData with and without a relation-traversal filter, asserts the aggregate count is 2 (Airbnb-only) when filtered, 3 unfiltered. Without the dispatcher fix the filtered count would also be 3. |
||
|
|
ee4fe62a13 |
fix(filters): drop chart-util's strip of non-existent relationTargetFieldMetadataId + regenerate barrel
ChartRecordFilter does not currently include relationTargetFieldMetadataId (relation traversal isn't supported in chart filters yet), so reading recordFilter.relationTargetFieldMetadataId in the chart converter was a typecheck error in the first place. |
||
|
|
95b64f2eda | chore(filters): prettier format | ||
|
|
11e408a3da |
refactor(filters): make dispatcher own field resolution to prevent dropped relation traversals
Replace the dispatcher's `fields` parameter with a `findFieldMetadataItemById` resolver callback. Both source-field and relation-target-field lookups now go through the same resolver, so callers no longer need to remember to pre-augment their `fields` list with relation targets — if they pass a workspace-wide resolver (or any resolver that can find the target), traversal filters just work. This eliminates a whack-a-mole pattern that had silently caused multiple bugs: the previous API took a `fields: FieldShared[]` array, and any caller that forgot to include relation-target field ids would silently drop relation traversal filters at the GraphQL-compute step (the dispatcher couldn't resolve the target, so it dropped the whole filter). 16+ call sites needed to know this; several didn't, including the workflow Search Records action in the previous commit. Changes: - twenty-shared: dispatcher (`computeRecordGqlOperationFilter`, `turnRecordFilterGroupIntoGqlOperationFilter`, `turnRecordFilterIntoRecordGqlOperationFilter`, `turnAnyFieldFilterIntoRecordGqlFilter`) now takes `findFieldMetadataItemById: (id) => FieldShared | undefined`. Adds `createFindFieldMetadataItemById(fields)` convenience constructor for callers with an array on hand. - twenty-front: all 10 call sites pass `createFindFieldMetadataItemById(flattenedFieldMetadataItems)` (workspace- wide resolver from the existing selector). Deletes the now-obsolete `augmentFieldsWithRelationTargets` helper. - twenty-server: all 5 call sites use the resolver pattern with `findFlatEntityByIdInFlatEntityMaps` against `flatFieldMetadataMaps`. Reverts the workflow whack-a-mole code from the previous commit (the proper fix supersedes it) and drops the stripping of `relationTargetFieldMetadataId` in the chart-data filter converter. |
||
|
|
eaccc68ca1 |
fix(filters): persist relationTargetFieldMetadataId on save-as-new-view + workflow find-records
Two relation-traversal bugs sharing the same shape: 1. Saving an advanced filter as a new view dropped the relation target. useCreateViewFromCurrentView built the create-filter input without relationTargetFieldMetadataId, so the saved view's filter was missing the traversal — on reload it appeared as "Company contains 'air'" instead of "Company → Name contains 'air'". 2. Workflow Search Records action silently dropped configured one-hop traversals. The fields list was built from flatObjectMetadata.fieldIds only, so the shared dispatcher couldn't resolve the target field on the related object and dropped the filter. For (2), collect relationTargetFieldMetadataId from the input record filters and union them into the fieldIds list before resolving against flatFieldMetadataMaps — mirroring the fix already applied to other filter-compute call sites. |
||
|
|
45ac3e8218 |
fix(front): align currency icon vertically with amount text (#20646)
## Summary - Replaced inline `<span>` wrapping the currency icon with a Linaria styled component using `display: flex` and `align-items: center` - The icon was misaligned with the amount text in table views and settings because the inline span didn't vertically center the SVG icon ## Changes - `packages/twenty-front/src/modules/ui/field/display/components/CurrencyDisplay.tsx` Fixes #20640 |
||
|
|
cd09690d5d |
fix(server): correct OpenAPI schema for phones.additionalPhones (#20631)
Fixes #20629 Problem The OpenAPI schema for PHONES composite fields documented additionalPhones as string[], but the actual runtime type (defined in phones.composite-type.ts) is Array<{ number: string, countryCode: string, callingCode: string }>. This caused generated SDK types and API docs for create/update payloads to be incorrect. Root cause A hardcoded mistake in convert-object-metadata-to-schema-properties.util.ts — the FieldMetadataType.PHONES branch set additionalPhones.items to { type: 'string' } instead of an object schema. Changes packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts - Changed additionalPhones.items from { type: 'string' } to { type: 'object', properties: { number, countryCode, callingCode } }, matching AdditionalPhoneMetadata. packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts - Updated all three inline snapshot occurrences (for ObjectName, ObjectNameForResponse, ObjectNameForUpdate) to expect the correct object shape instead of string. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
6b3064e2ba |
fix(server): add relationTargetFieldMetadataId column early in upgrade sequence (#20664)
## Summary Cross-version upgrade fails at the 2.3 `DropMessageDirectionFieldCommand` stage: ``` [QueryFailedError] column ViewFilterEntity.relationTargetFieldMetadataId does not exist at WorkspaceFlatViewFilterMapCacheService.computeForCache ``` (see https://github.com/twentyhq/twenty-infra/actions/runs/25929264129/job/76219964380) Same shape as #20584 (subFieldName), one column over. ### Root cause 1. The 2.3 `DropMessageDirectionFieldCommand` builds a workspace migration that deletes a `fieldMetadata` (the `direction` field). 2. `WorkspaceMigrationRunnerService.run` walks the metadata cascade graph and pulls `viewFilter` into the dependency set because `viewFilter` is the inverse one-to-many of `fieldMetadata`. 3. That maps to cache keys → `flatViewFilterMaps` gets requested → `WorkspaceFlatViewFilterMapCacheService.computeForCache` runs. 4. `computeForCache` does `viewFilterRepository.find({ where: { workspaceId }, withDeleted: true })` with no `select`, so TypeORM emits a SELECT that includes `relationTargetFieldMetadataId` — column only added by the 2.6 fast instance command `1798000005000`, not yet run at the 2.3 stage. 💥 ### Why v2.5.0 / v2.5.1 passed They didn't include #20527 (one-hop relation filters, May 14), which added `relationTargetFieldMetadataId` to `ViewFilterEntity` and the 2.6 instance command. The CI base image (v1.22) seeded the DB, then the v2.5.0/v2.5.1 container ran upgrade commands against an entity that didn't yet know about this column.v2.5.2 |
||
|
|
a321e24839 |
fix(server): scope workspace findOne in incrementMetadataVersion (#20660)
## Summary Cross-version upgrade fails at the 2.1 `GateExportImportCommandMenuItemsByPermissionFlagCommand` stage: ``` [GateExportImportCommandMenuItemsByPermissionFlagCommand] Found 3 command menu item(s) to update for workspace ... error: column WorkspaceEntity.isInternalMessagesImportEnabled does not exist ``` (see https://github.com/twentyhq/twenty-infra/actions/runs/25929264129/job/76219964380) ### Root cause Same class of bug as #20581 and #20583, one layer deeper in the call graph. 1. The 2.1 workspace command emits a `commandMenuItem` migration (3 items differ from the current standard expressions). 2. After the migration commits, `WorkspaceMigrationRunnerService.invalidateCache` walks the related-for-validation metadata for `commandMenuItem`, which includes `objectMetadata`. That puts `flatObjectMetadataMaps` in the keys set. 3. `getLegacyCacheInvalidationPromises` sees `flatObjectMetadataMaps` in the keys and calls `WorkspaceMetadataVersionService.incrementMetadataVersion(workspaceId)`. 4. `incrementMetadataVersion` did a bare `findOne` on `WorkspaceEntity` with no `select` → TypeORM emits a SELECT for every column declared on the entity → hits `isInternalMessagesImportEnabled` (added by #20457), whose DB column is only created by the 2.5 fast instance command `1778525104406-add-is-internal-messages-import-enabled`, which has not run yet at the 2.1 stage. 💥 ### Fix The function only reads `workspace.metadataVersion`, so narrow the `select` to `['id', 'metadataVersion']`. No behavior change. ```diff async incrementMetadataVersion(workspaceId: string): Promise<void> { const workspace = await this.workspaceRepository.findOne({ + select: ['id', 'metadataVersion'], where: { id: workspaceId }, withDeleted: true, }); ``` |
||
|
|
3717df34be |
fix(twenty-front): anchor body text color to theme var (#20622)
Fixes #20607. also fixes https://github.com/twentyhq/twenty/issues/20627 Front Components rendered via Remote DOM produced black-on-dark text in dark mode for any unstyled element. `body` already anchored `background` to a theme var; the matching `color` rule was missing, so unstyled subtrees fell through to browser default `#000`. before - <img width="850" height="526" alt="CleanShot 2026-05-16 at 16 31 49@2x" src="https://github.com/user-attachments/assets/ca21359c-d1d1-4367-831e-f694673757e5" /> <img width="840" height="1694" alt="CleanShot 2026-05-16 at 16 32 02@2x" src="https://github.com/user-attachments/assets/ed0891b5-1b97-4499-bb52-9e31c4cabf11" /> after - <img width="828" height="514" alt="CleanShot 2026-05-16 at 16 30 50@2x" src="https://github.com/user-attachments/assets/a22d1f1b-a79b-454c-8c05-5c7c00157b2c" /> <img width="852" height="1674" alt="CleanShot 2026-05-16 at 16 31 07@2x" src="https://github.com/user-attachments/assets/9b1dc19e-ccc5-472b-9184-59fa9b8832f8" /> |
||
|
|
140dceebd1 |
fix(front): use theme-aware color for side panel title (#20645)
## Summary
- Added `color: ${themeCssVariables.font.color.primary}` to
`StyledPageInfoTitleContainer` in `SidePanelPageInfoLayout.tsx`
- The "Update records" title had no explicit color, so it didn't adapt
to dark mode and was nearly invisible against the dark background
- Now correctly uses the theme-aware primary font color
## Changes
-
`packages/twenty-front/src/modules/side-panel/components/SidePanelPageInfoLayout.tsx`
Fixes #20627
|
||
|
|
4d2ceaf70a |
i18n - translations (#20661)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
6b49a14b9f |
feat(auth): set 50-character maximum length on passwords (#20655)
## Summary - Cap password length at 50 characters in the shared regex used by sign-up, password reset, and password change (both `twenty-front` and `twenty-server`). - Update the user-facing validation message on sign-up and password reset to mention both the 8 min and 50 max bounds. - Extend the `PASSWORD_REGEX` unit test to cover the new upper bound. The cap also prevents unbounded inputs from reaching bcrypt, which silently truncates passwords above 72 bytes and can mask user-visible bugs. ## Test plan - [x] `npx jest src/modules/auth/utils/__tests__/passwordRegex.test.ts` passes (8-char min and 50-char max). - [ ] Sign up with a 51-character password — form rejects with "Password must be between 8 and 50 characters". - [ ] Sign up with an 8–50 character password — succeeds. - [ ] Password reset rejects a 51-character password with the same message. - [ ] Existing users with longer passwords (if any pre-exist) can still sign in (the regex only gates write paths: sign-up, change, reset). |
||
|
|
62b347fc74 |
chore: sync AI model catalog from models.dev (#20620)
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> |
||
|
|
cf4b4455d3 |
fix(server): normalize composite defaultValues in manifest converter (unblock app re-install on 2.5-normalized workspaces) (#20615)
## Context The runtime create-field path and the v2.5 `NormalizeCompositeFieldDefaultsCommand` workspace upgrade both run composite `defaultValue`s through `nullifyEmptyCompositeDefaultValue`. The manifest install/sync path was the only write path that skipped it: [`fromFieldManifestToUniversalFlatFieldMetadata`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts) passed `fieldManifest.defaultValue` through verbatim. For the SDK-emitted ACTOR system fields (`createdBy` / `updatedBy`), `twenty-sdk` ships `{ name: "''", source: "'MANUAL'" }`. After the runtime or the 2.5 normalize command stores them, the workspace row holds the canonical four-key form `{ context: null, name: null, source: "'MANUAL'", workspaceMemberId: null }`. The next install computes its TO map from the manifest, still gets the raw two-key shape, and diffs it against the normalized FROM. The dispatcher emits a `defaultValue` update on each system actor field; the flat-field-metadata validator rejects it with `FIELD_MUTATION_NOT_ALLOWED`, blocking every re-install of any application that defines a custom object on a v2.5-normalized workspace. ## Fix Normalize composite `defaultValue`s inside the converter, reusing the same `nullifyEmptyCompositeDefaultValue` helper the three other write paths already share: - [`get-default-flat-field-metadata-from-create-field-input.util.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts) — `createOneObject` and `createOneField` GraphQL paths. - [`sanitize-raw-update-field-input.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/sanitize-raw-update-field-input.ts) — `updateOneField` GraphQL path. - [`2-5-workspace-command-1778000001000-normalize-composite-field-defaults.command.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000001000-normalize-composite-field-defaults.command.ts) — the upgrade backfill that introduced the divergence. After the fix, the four write paths agree on the canonical shape, so re-installs are no-ops on system actor fields regardless of when the 2.5 normalize command ran. Non-composite types pass through unchanged. ## Test New spec `from-field-manifest-to-universal-flat-field-metadata.util.spec.ts` covers: - Empty-name actor defaults are normalized to the four-key canonical shape. - The converter is idempotent: feeding its own output back in produces the same result (so two consecutive syncs of the same manifest never emit a `defaultValue` update). - When the manifest omits `defaultValue`, the converter falls back to `generateDefaultValue` and normalizes the result. - Non-composite defaults pass through unchanged. ``` PASS src/engine/core-modules/application/application-manifest/converters/__tests__/from-field-manifest-to-universal-flat-field-metadata.util.spec.ts fromFieldManifestToUniversalFlatFieldMetadata composite defaultValue normalization ✓ normalizes empty-name actor defaults to the canonical four-key shape ✓ is idempotent: re-running the converter on its own output yields the same defaultValue ✓ falls back to the generated default and normalizes it when defaultValue is omitted ✓ leaves non-composite defaults untouched Tests: 4 passed ``` ## CI gap that let this through The integration suites covering manifest install (`appDevOnce` against the test workspace) never re-installed an existing app on a workspace whose composite fields had already been put through the 2.5 normalize command. They synced once, then ran assertions on the resulting state; the second sync that would have re-triggered the `defaultValue` diff was never exercised. If we want to catch this class of regression at the integration level too, we'd add a test that (1) syncs an app whose manifest includes an ACTOR system field with the raw SDK shape, (2) invokes `NormalizeCompositeFieldDefaultsCommand` directly on the test workspace, (3) re-syncs the same manifest, and (4) asserts no `FIELD_MUTATION_NOT_ALLOWED` errors. The unit-level idempotency check in this PR is the minimal version of that same coverage. Happy to ship that integration spec in a follow-up if it'd help. |
||
|
|
268fccca29 |
i18n - translations (#20609)
Created by Github action Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
c938fbf4d6 |
feat(twenty-front): relation traversal in filter dropdown (stacked) (#20533)
**Stacked on #20527** https://github.com/user-attachments/assets/48995655-401a-4c35-8094-e88da8408bdd ## Summary Surfaces the one-hop relation traversal added in #20527 through the existing **composite sub-field dropdown pattern**. Clicking a MANY_TO_ONE relation field in the "+ Filter" picker now opens the same second-level dropdown that composite fields (FULL_NAME, ADDRESS, CURRENCY, etc.) already use — populated with the target object's filterable fields. Picking one (e.g. `Company → Name`) builds a filter that serializes to the nested GraphQL filter the backend now accepts: `{ company: { name: { ilike: "%X%" } } }`. No new components. The whole feature reuses `AdvancedFilterSubFieldSelectMenu` + the existing `subFieldNameUsedInDropdownComponentState` + the existing `MenuItem hasSubMenu` indicator. Only the conditions that gate the sub-menu (and the sub-menu's content for relations) were broadened. ## What landed | File | Change | |---|---| | `ObjectFilterDropdownFilterSelectMenuItem` | Sub-menu chevron now shows on MANY_TO_ONE relations (`isManyToOneRelationField` util). | | `AdvancedFilterFieldSelectMenu` | Relation clicks open the sub-menu alongside composite clicks. | | `AdvancedFilterSubFieldSelectMenu` | New branch: when the sub-menu type is `'RELATION'`, render the target object's filterable fields via `useFilterableFieldMetadataItems(targetObjectMetadataId)`. Composite logic untouched. | | `objectFilterDropdownSubMenuFieldType` state | Widened to accept a `'RELATION'` sentinel. Role-permissions sub-field menu narrows it back out (it doesn't traverse relations). | | `useSelectFieldUsedInAdvancedFilterDropdown` | New optional `targetFieldMetadataItem` arg. When present, the stored RecordFilter's `type` is the target field's type so the operand picker and value input render the target's operands (`'TEXT'` operators when filtering `company.name`, etc.). | | `turnRecordFilterIntoGqlOperationFilter` (shared) | When the filter targets a `RELATION` field with a `subFieldName`, synthesize a field-metadata for the target, recurse to build the inner filter, then wrap it under the relation field's name → `{ relationName: { targetFieldName: { ...operator } } }`. | `RecordFilter.subFieldName` stays narrowly typed as `CompositeFieldSubFieldName` so the wide downstream consumers (`shouldShowFilterTextInput`, composite handlers in the serializer, etc.) don't change. The relation target field's name is stored through a narrowly-scoped cast at the dropdown's storage point — the serializer checks `filter.type === 'RELATION'` before interpreting it as a target field name, so the cast can't be mis-read by composite-only code paths. ## Test plan - [ ] Open a table view on People, click "+ Filter", click "Company" → sub-menu opens with Company's filterable fields - [ ] Pick "Name" → operand picker shows TEXT operators (Contains, Equals, …) - [ ] Type "Airbnb" → filter applies, table shows people whose company name contains "Airbnb" - [ ] Verify network tab: the GraphQL filter variable is `{ company: { name: { ilike: "%Airbnb%" } } }` - [ ] Same flow with a composite target field (e.g. `Company → annualRecurringRevenue → amountMicros`) — should work end-to-end (backend supports composite-within-relation; #20527 has an integration test covering this) - [ ] Composite fields (FULL_NAME, ADDRESS) still open their normal sub-menu and filter correctly — no regression - [ ] Role-permissions field-select sub-field menu is unaffected (it bails out early on the RELATION sentinel) ## Out of scope - ONE_TO_MANY traversal (no backend support yet) - Aggregates (`people.count > 5`) - Persisting relation-traversal filters into a saved view (ViewFilter has no `relationPath` column yet; that's a separate slice) - REST API DSL changes - AI Tools 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
eca92ca559 |
fix(server): rebuild unique phone indexes drops legacy non-empty partial WHERE clause (#20606)
## Summary `RebuildUniquePhoneIndexesCommand` reuses each index's existing `indexWhereClause` when recreating the physical index. For workspaces whose unique phone indexes have a legacy clause like `"primaryPhoneNumber" != ''` (created before PR #18024 hardened the validator allowlist), the recreate path fails at `validateAndReturnIndexWhereClause` because the clause isn't in `ALLOWED_INDEX_WHERE_CLAUSES`. Two workspaces are hitting this on the 2.5 upgrade: - `3a797122-…` — `"companyPhonePrimaryPhoneNumber" != ''` - `ea74716f-…` — `"phonesPrimaryPhoneNumber" != ''` ## Fix Detect the legacy `"<col>" != ''` shape via a strict regex. When it's there, before the existing drop+create, do three things inside the workspace transaction: 1. **Normalize the data** that the legacy partial clause was masking — `UPDATE "<schema>"."<table>" SET "<col>" = NULL WHERE "<col>" = ''` for every column the index covers. Without this the next step would fail because the new plain-unique index would see duplicate `''` values across the rows the old partial clause was excluding. 2. **Null out `core."indexMetadata".indexWhereClause`** so the metadata row matches what the UI would have created (`indexWhereClause: null`) and doesn't carry the validator-rejected clause forward to any future re-emit. Uses the same workspace `queryRunner` (Postgres lets one connection write across schemas). 3. **Recreate** with an overridden flat index where `indexWhereClause: null`. `createIndexInWorkspaceSchema` → `indexManager.createIndex` → `validateAndReturnIndexWhereClause` short-circuits on null, no allowlist check. End state matches the shape a fresh "toggle unique in Settings UI" creates: plain unique index, no `WHERE`, NULL semantics doing the "exclude empty phones" work via PG's default NULL-distinct behaviour. For indexes whose clause is already allowlisted (`"deletedAt" IS NULL`) or null, behaviour is unchanged — just the column-list widening this command already does. |
||
|
|
0c20b8bc88 |
i18n - translations (#20605)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
75b9b2fe5d |
feat(admin-panel): signing keys management tab with usage tracking (#20586)
## Summary - Adds a new admin-only **Security** tab to the Admin Panel (alongside General/Apps/AI/Config/Health) containing a **Signing Keys** section. The tab is intentionally introduced now so the upcoming **Encryption rotation** work can land as a sibling section. - Lists every JWT signing key with key id, `createdAt`, `revokedAt`, current/active/revoked status, and a **7-day verification count** read from Redis. A trailing row aggregates **legacy HS256** verifications so it is clear when the deprecated path is still in use. - Lets an admin **revoke** a public key. Revoking the current key drops `isCurrent`, sets `revokedAt`, nulls the encrypted `privateKey` and clears the in-process cached current key; the existing lazy path in `JwtKeyManagerService.getCurrentSigningKey()` then mints a fresh current key on the next sign. ## Backend - `SigningKeyVerifyCounterService` — bucketed Redis counter under the existing `EngineMetrics` namespace. 1-day UTC-aligned buckets, 8-day TTL refreshed on every increment, batched read via `mget`. Failures are swallowed and logged at `warn` so a Redis hiccup cannot break auth. - `JwtWrapperService.verifyJwtToken` records verifies **after success** for both ES256 (`kid` as identifier) and HS256 (the literal `legacy` identifier). - `JwtKeyManagerService.listSigningKeys()` and `revokeSigningKey(id)`: list ordered by `isCurrent DESC, createdAt DESC`; revoke is idempotent, validates the UUID, invalidates the public-key cache, and resets the cached current-key promise. - `AdminPanelResolver.getSigningKeys` (query) and `revokeSigningKey` (mutation) are both decorated with `@UseGuards(AdminPanelGuard)` so they are admin-only, like the 35 existing admin-only methods on this resolver. `privateKey` is never returned over GraphQL. ## Frontend - New `SECURITY` tab id wired into `SettingsAdminContent` and `SettingsAdminTabContent` (gated by `canAccessFullAdminPanel`). - `SettingsAdminSecurity` / `SettingsAdminSigningKeysTable` strictly reuse existing admin-panel components: `Section`, `H2Title`, `Table`/`TableRow`/`TableCell`/`TableHeader` from `@/ui/layout/table`, `Tag`/`Button` from `twenty-ui`, and `ConfirmationModal` mirroring the queue retry/delete modals. Only one minimal styled helper for the monospaced UUID rendering. - `useRevokeSigningKey` uses `useApolloAdminClient`, refetches `GetSigningKeys`, shows success/error snackbars (same pattern as `useRetryJobs`/`useDeleteJobs`). <img width="1293" height="881" alt="image" src="https://github.com/user-attachments/assets/7cf98664-950b-4451-af85-27781a8e9a9c" /> |
||
|
|
218799636f |
fix(docs): replace removed Mintlify build command (#20578)
## Summary Closes #20565. The Twenty docs package still pointed contributors at the removed `mintlify build` command. This switches the docs workflow to a `validate` command, which matches the supported Mintlify CLI command for validating the documentation build, and updates the README wording to match. ## Changes - Replaced the `twenty-docs` package `build` script with a `validate` script. - Renamed the Nx docs target from `build` to `validate` and kept it wired to `mintlify validate`. - Updated the README validation command to `npx nx run twenty-docs:validate`. ## Verification ```bash $ npx -y mintlify validate --help usage: mintlify validate [options] Options: -t, --telemetry Enable or disable anonymous usage telemetry [boolean] --groups Mock user groups for validation [array] --disable-openapi Disable OpenAPI file generation [boolean] [default: false] -h, --help Show help [boolean] -v, --version Show version number [boolean] Examples: mintlify validate validate the build ``` ```bash $ npx -y mintlify build Unknown command: build ``` I also started `npx -y mintlify validate --disable-openapi`; the CLI recognized the command and began validating, but this Windows environment could not finish Mintlify framework extraction because it hit an EPERM symlink error inside the local `.mintlify` cache. |
||
|
|
14acd77626 |
fix(docker): pin node:24-alpine to 24.15.0-alpine3.23 digest (#20603)
## Summary - ECR Inspector flagged 9 CVEs on the `prod-twenty` image — 8 PostgreSQL CVEs on `postgresql18-18.3-r0` (pulled in transitively by `apk add postgresql-client`) and CVE-2026-27135 on `nghttp2-1.68.0-r0` (pulled in by `curl` / `aws-cli`). - Alpine 3.23 already ships patched `postgresql18-18.4-r0` and `nghttp2-1.69.0-r0`, but the GHA buildx cache was reusing the stale `apk add` layer because `FROM node:24-alpine` had not moved. - Pinning the base image to `node:24.15.0-alpine3.23@sha256:8e2c930f…` forces a layer cache miss, picks up the patched apk packages, and gives Dependabot/Renovate a stable target for future digest bumps. Applied to both [packages/twenty-docker/twenty/Dockerfile](https://github.com/twentyhq/twenty/blob/charles/trusting-solomon-259ec8/packages/twenty-docker/twenty/Dockerfile) (4 stages → ECR `prod-twenty`) and [packages/twenty-docker/twenty-website-new/Dockerfile](https://github.com/twentyhq/twenty/blob/charles/trusting-solomon-259ec8/packages/twenty-docker/twenty-website-new/Dockerfile) (2 stages). ## Test plan - [ ] CI builds both images successfully on amd64 + arm64 - [ ] After merge + deploy, re-run ECR Inspector on the new `prod-twenty` image and confirm the 9 CVEs (CVE-2026-6473/6474/6475/6476/6477/6478/6479/6637 + CVE-2026-27135) are gone - [ ] Smoke-test the staging deployment (server boot, DB migrations via `psql` in the entrypoint) |
||
|
|
d94d2eb67c |
[Website] Make product stepper visuals interactive. (#20602)
We had low-res screenshots for each step in the stepper. Replaced them with interactive components. https://github.com/user-attachments/assets/d03ff924-a1dd-467f-ba19-cece0ecb3486 |
||
|
|
45bea6f991 |
feat(secret-encryption): drop APP_SECRET from approved-access-domain validation and session cookies (#20580)
## Summary Continues retiring `APP_SECRET` as a hot signing secret (after the TOTP migration in #20577). This PR moves the last two cryptographic uses of `APP_SECRET` off it: 1. **Approved-access-domain validation tokens** — was a one-shot `sha256(JSON.stringify({id, domain, key: APP_SECRET}))` HMAC with no built-in expiry. Now a JWT signed by the workspace `signingKey` with a 7-day expiry and claims bound to `approvedAccessDomainId`, `workspaceId`, and `domain`. 2. **Express-session cookie signing** — was `sha256(APP_SECRET || 'SESSION_STORE_SECRET')`. Now `HKDF(ENCRYPTION_KEY, info='twenty:hmac:v1:session-cookie')` with `FALLBACK_ENCRYPTION_KEY` supported for rotation. ### Approved-access-domain — strict cutover - `ApprovedAccessDomainService.mintValidationToken` issues a JWT via `JwtWrapperService.signAsyncOrThrow` (workspace `signingKey`, asymmetric ES256 with kid-based rotation built in). - `validateApprovedAccessDomain` verifies the JWT, asserts `type === APPROVED_ACCESS_DOMAIN`, cross-checks `claim.approvedAccessDomainId` against the URL's `approvedAccessDomainId`, then re-checks `domain` and `workspaceId` against the stored row. Any failure maps to `APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID`. - **No legacy fallback:** any pending invitation link minted with the old SHA hash will fail validation and must be re-sent. Volume is small and admins can re-issue from settings — this is the cleanest cutover. ### Session cookies — bridged cutover - `resolveSessionCookieSecretsOrThrow` returns an array `[HKDF(ENCRYPTION_KEY), HKDF(FALLBACK_ENCRYPTION_KEY)?, sha256(APP_SECRET || 'SESSION_STORE_SECRET')?]`. - `express-session` signs new cookies with the first secret and verifies against any entry, so in-flight cookies signed under the legacy SHA keep verifying until `maxAge` (30 min) expires. - New `deriveInstanceHmacKey` HKDF utility uses a dedicated `twenty:hmac:v1:` info prefix — distinct from the AEAD subkey prefix `twenty:enc:v2:` — so HMAC and encryption subkeys can never collide for the same raw `ENCRYPTION_KEY`. - TODO comment marks the legacy slot for removal post-2.5. ### Notes on rotation behaviour - Rotating `ENCRYPTION_KEY` while keeping the old value in `FALLBACK_ENCRYPTION_KEY` keeps cookies signed under either key verifying. New cookies sign under the new key. After all in-flight cookies expire (≤30 min), the fallback slot can be dropped from env. - Rotating the workspace `signingKey` (already supported by `JwtKeyManagerService`) keeps already-issued approved-access-domain JWTs verifying via `kid` until their 7-day expiry. ## Test plan - [x] Unit tests for `ApprovedAccessDomainService` cover: happy path, JWT verify failure, wrong token type, JWT id ≠ input id, JWT-claimed domain ≠ row, missing row, already-validated row. - [x] Unit tests for `resolveSessionCookieSecretsOrThrow` cover: throws without keys, primary order (`ENCRYPTION_KEY` → APP_SECRET fallback), `FALLBACK_ENCRYPTION_KEY` placement, empty-string vars treated as unset, legacy slot omitted when `APP_SECRET` missing, HKDF domain separation across purposes. - [x] `nx lint:diff-with-main twenty-server` — clean. - [x] Full test surface across approved-access-domain, secret-encryption, session-storage — 78/78 pass. - [ ] CI green. - [ ] Manual smoke: boot with a dummy `ENCRYPTION_KEY`, confirm sign-in succeeds (session cookie works), create + validate an approved-access-domain end-to-end through the UI. |
||
|
|
dd9027680e |
chore: sync AI model catalog from models.dev (#20601)
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> |
||
|
|
ca1571676c |
fix(server): treat plaintext-under-isSecret rows as plaintext in app variable encryption migration (#20590)
## 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 placev2.5.1 |
||
|
|
a5880bd8d0 |
fix(server): drop correlated subquery in getWorkspaceLastAttemptedCommandName (#20591)
## 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
|
||
|
|
78b3092886 |
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.
|
||
|
|
5a1d3841f4 |
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`. |
||
|
|
94748b7042 |
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 <github-action-deploy@twenty.com> |
||
|
|
663ef332ad |
feat(auth): resume workspace selection on /welcome with valid tokenPair cookie (#20575)
## 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 `<workspace>/authorize?…` where consent renders. ## Out of scope - Fixing `lastAuthenticatedWorkspaceDomain` for custom-domain users (separate cookie-scoping issue, tracked separately). |
||
|
|
09daccc3f9 |
fix(server): add subFieldName column early in upgrade sequence (#20584)
## Summary Cross-version upgrades from pre-2.3 still fail after #20581 / #20583 — different column, structurally similar problem: ``` column ViewSortEntity.subFieldName does not exist at WorkspaceFlatViewSortMapCacheService.computeForCache (...flat-view-sort/services/workspace-flat-view-sort-map-cache.service.js:40) ... triggered indirectly by DropMessageDirectionFieldCommand (2.3 workspace command) ``` (see https://github.com/twentyhq/twenty-infra/actions/runs/25862573418/job/75997337604) ### Why narrowing the `select` doesn't fit here In the previous two PRs the offender was a bare `findOne` on `WorkspaceEntity` — easy to narrow. Here the chain is: 1. The 2.3 `DropMessageDirectionFieldCommand` builds a workspace migration that deletes a `fieldMetadata` (the `direction` field). 2. `WorkspaceMigrationRunnerService.run` walks the metadata cascade graph (`getMetadataRelatedMetadataNames`) and pulls `viewSort` into the dependency set because `viewSort` is the inverse one-to-many of `fieldMetadata` (deleting a field cascades to view sorts that reference it). 3. That maps to cache keys → `flatViewSortMaps` gets requested → `WorkspaceFlatViewSortMapCacheService.computeForCache` runs. 4. `computeForCache` does `viewSortRepository.find({ where: { workspaceId }, withDeleted: true })` with no `select`, so TypeORM emits a SELECT that includes `subFieldName` — the column doesn't exist in DB yet (added by a 2.5 instance command much later in the sequence). 💥 Narrowing the cache provider's select would silently drop `subFieldName` from the cache for runtime use too, until something invalidates it. Brittle, and would re-break the next time anyone adds a `viewSort` column. ### Structural fix Ensure the column exists in DB before any 2.3 workspace command can trigger that cascade. Within a version, the upgrade runner sorts: fast instance → slow instance → workspace, so a new 2.3 fast instance command lands before `DropMessageDirectionFieldCommand`. - **Add** `2-3/2-3-instance-command-fast-1747234200000-add-sub-field-name-to-view-sort.ts` — `ALTER TABLE ... ADD COLUMN IF NOT EXISTS "subFieldName"`. Comment in the file explains the cascade and why this lives in 2.3 instead of 2.5. - **Make idempotent** the existing `2-5/...-add-sub-field-name-to-view-sort.ts` — switched to `ADD COLUMN IF NOT EXISTS` / `DROP COLUMN IF EXISTS` so it's a no-op on cross-upgrade paths while still creating the column on fresh-from-2.5 installs. - Register the new command in `instance-commands.constant.ts`. The 2.5 command body change is semantically preserving (idempotent), and v2.5.0 hasn't shipped to any production DB yet — so this doesn't violate the "never rewrite committed instance commands" rule in spirit. ### Note on the previous two PRs #20581 and #20583 narrowed `select` on `WorkspaceEntity` for `isInternalMessagesImportEnabled`. That's a band-aid that works because there's a small, enumerable set of bare `workspaceRepository.findOne` call sites. It could in principle be replaced with the same pattern as this PR (early 2.x instance command that adds the workspace column). Not doing that here to keep the diff tight, but happy to follow up if preferred. ## Test plan - [ ] Re-run twenty-infra cross-version-upgrade CI and confirm 2.3 workspace commands complete - [ ] Verify the new 2.3 instance command and the modified 2.5 instance command are both idempotent (running upgrade twice should not error) - [ ] Verify a fresh install path still ends with `subFieldName` present on `core.viewSort`v2.5.0 |
||
|
|
484037c179 |
fix(server): scope workspace findOne in ApplicationService (#20583)
## Summary Cross-version upgrade still fails after #20581: ``` column WorkspaceEntity.isInternalMessagesImportEnabled does not exist at ApplicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow (application.service.ts:84) at UpdateGlobalObjectContextCommandMenuItemsCommand.runOnWorkspace (1-23-…) at BackfillRecordPageLayoutsCommand.runOnWorkspace (1-23-…) ``` (see https://github.com/twentyhq/twenty-infra/actions/runs/25861366732/job/75993012161) ### Root cause Same class of bug as #20581, different location. `ApplicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow` does: ```ts await this.workspaceRepository.findOne({ where: { id: workspaceId }, withDeleted: true, }); ``` No `select`, so TypeORM emits a SELECT for every column declared on `WorkspaceEntity`. PR #20457 added `isInternalMessagesImportEnabled` to the entity; its DB column is only created by the 2-5 fast instance command `1778525104406-add-is-internal-messages-import-enabled`. Many workspace commands across versions 1-21 → 2-3 call this service (notably the 1-23 commands shown in the stack), and they all run before the 2-5 instance command — so the bare findOne hits a column that doesn't exist yet and the upgrade aborts. ### Fix The function only reads `workspace.id` (passed to cache) and `workspace.workspaceCustomApplicationId`. Narrow the select to just those. The `workspace: WorkspaceEntity` input variant of the function is unchanged — only the path where we fetch the workspace ourselves is narrowed. Callers don't see the workspace entity (the function only returns `{ twentyStandardFlatApplication, workspaceCustomFlatApplication }`). ### Why not edit the committed 1-23 workspace commands Same reasoning as #20581: the fix lives in the service that does the read, so future column additions to `WorkspaceEntity` don't risk re-breaking every caller. Per `CLAUDE.md`, instance command `up`/`down` is immutable; this isn't an instance command. ## Test plan - [ ] Re-run the failing cross-version-upgrade job and confirm it gets past 1-23 - [ ] Verify the function still resolves the standard + custom applications correctly for a workspace (no behavior change in returned shape) |
||
|
|
a5982b644c |
fix(server): scope workspace findOne in 1-21 backfill-datasource command (#20581)
## Summary Cross-version upgrades from pre-1-21 instances currently fail with: ``` error: column WorkspaceEntity.isInternalMessagesImportEnabled does not exist ``` (see https://github.com/twentyhq/twenty-infra/actions/runs/25857499266/job/75979993686) ### Root cause The 1-21 workspace command `backfill-datasource-to-workspace` does: ```ts const workspace = await this.workspaceRepository.findOne({ where: { id: workspaceId }, }); ``` No `select`, so TypeORM emits a SELECT for every column declared on `WorkspaceEntity`. PR #20457 added `isInternalMessagesImportEnabled` to the entity, but its DB column is only created by the 2-5 fast instance command `1778525104406-add-is-internal-messages-import-enabled`. On a fresh cross-version upgrade, the runner reaches the 1-21 workspace segment before that 2-5 instance command runs, the bare `findOne` issues SELECT on a column that doesn't exist yet, and the upgrade aborts. ### Fix Narrow the select to just the columns this command actually reads (`id`, `databaseSchema`). The query now ignores entity columns added later in the upgrade sequence. ### Why edit a committed workspace command Per `CLAUDE.md`, committed *instance* command `up`/`down` logic is immutable. Workspace commands are idempotent backfills — adding a `select` narrows the read but doesn't change behavior, so it's safe. ### Audit Verified this is the only unguarded `workspaceRepository.find*` across the entire upgrade subtree: - `WorkspaceIteratorService.iterate` uses `select: ['databaseSchema']` - `WorkspaceVersionService.getActiveOrSuspendedWorkspaceIds` uses `select: ['id']` - `UpgradeStatusService.loadActiveOrSuspendedWorkspaces` uses `select: ['id', 'displayName']` ## Test plan - [ ] Re-run the failing cross-version upgrade job and confirm it gets past 1-21 - [ ] Verify the 1-21 backfill still correctly skips workspaces with a non-empty `databaseSchema` and backfills those without |
||
|
|
4054ede5bb |
i18n - translations (#20582)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
af4765effe |
feat(twenty-server): one-hop relation filters in GraphQL API (#20527)
## Summary
Adds support for filtering records by fields on a related MANY_TO_ONE
object via the GraphQL API. Backend only — no frontend, no REST, no
view-filter persistence yet.
```graphql
{
people(filter: { company: { name: { like: "%Airbnb%" } } }) {
edges { node { id } }
}
}
```
### Where the work lands
- **Schema** — `relation-field-metadata-gql-type.generator.ts` now emits
`{relationName}: TargetFilterInput` alongside the existing
`{joinColumnName}: UUIDFilter` for MANY_TO_ONE relations. Mirrors the
order-by generator that already does this for sort. Lazy thunks in
`object-metadata-filter-gql-input-type.generator.ts` handle the cycle
between filter inputs.
- **Arg processor** — `FilterArgProcessorService` no longer hard-rejects
accessing a relation by its name. When the value is a nested object on a
MANY_TO_ONE field, it recurses into the target object's metadata so each
leaf still gets validated and coerced. Depth-capped at 1.
- **Query parser** — new `parseRelationSubFilter` branch in
`graphql-query-filter-field.parser.ts`. When triggered: looks up the
target object metadata, calls `ensureRelationJoin` against the outer
query builder, and recurses via a child
`GraphqlQueryFilterConditionParser` scoped to the target.
`and`/`or`/`not` inside the relation filter keep working because the
child dispatches through the same `parseKeyFilter`.
- **Shared join utility** — `ensureRelationJoin.util.ts` is a single
function that inspects `queryBuilder.expressionMap.joinAttributes` for
the alias before adding a `LEFT JOIN`. Rewired the existing inline
`qb.leftJoin` calls in the order parser and group-by service to use it,
so filter-driven joins no longer collide with sort-driven joins on the
same relation.
### Out of scope (explicit)
- ONE_TO_MANY reverse traversal (needs EXISTS subqueries)
- Aggregates (`company.people.count > 5` — needs HAVING)
- View-filter storage (no `relationPath` column on `ViewFilterEntity`)
- REST DSL changes
- Frontend filter-picker UX
- Nesting deeper than one hop (parser and arg-processor both reject)
### Open question for review
Permissions. The order-by-on-relation code path already lets users sort
People by Company.name without a Company read-permission check, and this
PR matches that behavior for filters — felt wrong to add a stricter gate
only on the filter side. If we want object-permission gating on the
relation target, it should be a follow-up that covers both paths
consistently. The only attack surface today is existence inference via
timing, identical to what sort already exposes.
## Test plan
- [x] `tsc --noEmit` — clean for changed files (5 unrelated pre-existing
errors on main untouched)
- [x] `oxlint --type-aware` + `prettier --check` — 0 errors on all 17
changed/new files
- [x] `jest filter-arg-processor.service.spec` — 229 tests pass (the new
optional `flatObjectMetadataMaps` arg is backwards-compatible)
- [x] Integration test (`filter-by-relation-field.integration-spec.ts`,
6 cases) — needs to be verified against a seeded test DB. Could not
exercise the happy path in my isolated worktree; depth-2 rejection
passed there.
- [ ] EXPLAIN ANALYZE on the integration test query to confirm the FK on
`person.companyId` is indexed for both standard and custom MANY_TO_ONE
relations.
### Integration test cases
1. Filter People by `company.name = "Airbnb"` (exact match)
2. Filter People by `company.name like "%irbnb%"`
3. Non-matching filter returns empty
4. Combined with a scalar filter at root via `and`
5. **Combined with `orderBy` on the same relation** — proves the
join-dedupe works (without `ensureRelationJoin`, TypeORM throws
"duplicate alias")
6. Depth-2 nesting (`company.accountOwner.name`) returns
`INVALID_ARGS_FILTER`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
a941f6fe01 |
feat(server): migrate TOTP secret encryption to SecretEncryptionService (#20577)
## Summary
Removes the last `APP_SECRET`-derived at-rest encryption site by
migrating `core.twoFactorAuthenticationMethod.secret` from
`SimpleSecretEncryptionUtil` (AES-256-CBC with key derived from
`sha256(APP_SECRET + userId + workspaceId + 'otp-secret' +
'KEY_ENCRYPTION_KEY')`) to the versioned `enc:v2:` envelope
(ENCRYPTION_KEY → HKDF-SHA256 bound to `workspaceId` → AES-256-GCM).
- New `decrypt-legacy-aes-cbc.util.ts` faithfully reproduces the
pre-migration CBC derivation byte-for-byte;
`SecretEncryptionService.decryptVersioned` dispatches to it when callers
pass `legacyAesCbcPurpose`, with a dedicated one-shot WARN log family.
- `TwoFactorAuthenticationService` now uses `encryptVersioned` /
`decryptVersioned` (passing the legacy purpose so existing rows still
decrypt). `SimpleSecretEncryptionUtil` and its spec are deleted;
`TwoFactorAuthenticationModule` imports `SecretEncryptionModule` in
their place.
- `TwoFactorAuthenticationMethodEntity` gets a `@Check` decorator
(`CHK_twoFactorAuthenticationMethod_secret_encrypted`) restricting
`secret` to the `enc:v2:` envelope; the matching 2.5 slow instance
command (`1798000009000-encrypt-totp-secrets`) cursor-paginates `JOIN`ed
`userWorkspace` rows to recover the legacy `userId`, re-encrypts to
`enc:v2`, and applies the CHECK constraint in `up()`.
### Deviation note
The plan suggested wiring a workspace-only legacy derivation directly
into `decryptVersioned`. In practice the production rows are
user-and-workspace-scoped (the legacy purpose is
`\${userId}\${workspaceId}otp-secret`), so a workspace-only derivation
could not recover them. The PR keeps the public `decryptVersioned` API
intact and adds an optional `legacyAesCbcPurpose` so callers that can
reconstruct the legacy context (the 2FA service and the slow command)
opt in.
### Final state of remaining `APP_SECRET` usages
- HS256 JWT verify (read-only, self-retiring once asymmetric migration
completes).
- Express-session cookie signing.
- Approved-access-domain HMAC (signing root, not at-rest).
- Zero-friction fallback in `resolveEncryptionKeysOrThrow`
(intentional).
No production at-rest data is encrypted with `APP_SECRET`-derived keys
anymore.
## Test plan
- [x] `npx jest src/engine/core-modules/secret-encryption
src/engine/core-modules/two-factor-authentication` — 170 unit tests
pass, including new unit tests for the legacy CBC util and the new
`SecretEncryptionService` fallback branch.
- [x] `npx jest --config ./jest-integration.config.ts
test/integration/upgrade/suites/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets.integration-spec.ts`
— 4 integration tests cover legacy-CBC seed → slow command → `enc:v2`
round-trip, idempotency, CHECK constraint enforcement on `up()`, and
rollback via `down()`.
- [x] `npx oxlint --type-aware` and `npx prettier --check` clean on all
touched files.
- [ ] CI on this PR (server validation, tests, lint, typecheck).
|
||
|
|
fc53f18a9f |
Twenty discord integration (#20530)
<img width="1290" height="777" alt="Screenshot 2026-05-14 at 8 55 02 AM" src="https://github.com/user-attachments/assets/37cde89b-b4c3-438a-8ccf-39621f9799b4" /> https://github.com/user-attachments/assets/cd8af6f9-f2e5-47cf-bf1f-57590bb358d1 https://github.com/user-attachments/assets/ff53c6b7-ee1a-42db-bbc5-d7df5b3fa6b0 https://github.com/user-attachments/assets/ddca2c16-774d-4672-aa61-05e878d8b2b7 |
||
|
|
ddaba26abe |
chore(.vscode): add remaining packages to VSCode workspace (#20570)
## Context This PR extends the multi-root VSCode workspace configuration introduced in #2937 by adding the remaining packages from the `packages/*` directories to the `twenty.code-workspace` folders array. ## Problem Previously, some packages were not added to the multi-root workspace configuration. As a result, when opening the repository as a vscode workspace, those packages were hidden from the VSCode Explorer because they were not part of the configured workspace folders. ## Benefits - Prevents packages from being hidden when the repository is opened as a vscode workspace. - Improves consistency and navigation across the monorepo workspace experience. ## Related PR - #2937 |
||
|
|
42975a4168 |
fix(server): decouple SDK client generation from workspace activation (#20514)
`activateWorkspace` enqueues SDK gen job inside `WorkspaceManagerService.init()` introduced by https://github.com/twentyhq/twenty/pull/19271 But if enqueue call fails it crashes cuz it doesn't have try catch so created workspace is in corrupted state <img width="636" height="812" alt="image" src="https://github.com/user-attachments/assets/09acd042-46d0-4225-adc0-c74ea770785d" /> FIx: Move SDK enqueue out of `init()` Call after `activateAndInitializeUpgradeState` succeeds, wrap in try catch. Mirror preInstalledAppsService.installOnWorkspace pattern. Assuming enqueue failure if Redis is unavailable we fallback to `SdkClientArchiveService.downloadArchiveBufferOrGenerate` which generates it on the fly Around 19 workspaces in prod affected with status `ONGOING_CREATION` |
||
|
|
dbc033b29b |
fix(auth): exclude /authorize from MinimalMetadataGater loading gate (#20572)
## Summary Fixes the blank `/authorize` page reported when a user reopens the OAuth consent screen on `app.twenty.com` after a prior multi-workspace SSO sign-in. ### Reproduction 1. ChatGPT (or any MCP client) opens `https://app.twenty.com/authorize?client_id=…` while signed out. 2. User picks "Continue with Google", lands in workspace selection, picks a workspace, authorizes the app. Works. 3. Some time later, ChatGPT re-opens `https://app.twenty.com/authorize?client_id=…`. 4. **Observed:** fully blank page, no console errors. Deleting the `tokenPair` cookie unblocks it. ### Root cause The multi-workspace social-SSO branch ([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)) lands the user on `app.twenty.com/welcome?tokenPair=…` with a workspace-agnostic token. `SignInUpGlobalScopeFormEffect` writes that into the host-scoped `tokenPair` cookie on `app.twenty.com`, and nothing clears it after the user proceeds to a workspace subdomain. On the next visit to `app.twenty.com/authorize?…`, `MinimalMetadataGater` sees `hasAccessTokenPair === true` and renders `<UserOrMetadataLoader />` instead of `<Authorize />`. The loader never goes away because: - `IsMinimalMetadataReadyEffect` waits for `metadataStore.status === 'up-to-date'`. - `MinimalMetadataLoadEffect` only loads metadata when `hasAccessTokenPair && isActiveWorkspace`, and the default domain has no workspace context. Skeleton loader stays forever → user perceives "blank page". Some users get rescued by `WorkspaceProviderEffect` auto-redirecting them to their last-authenticated workspace subdomain, but that cookie is set with `domain: .twenty.com` and silently fails to persist for users on custom domains — so the bug is most visible there. ### Fix `/authorize` only issues `findApplicationRegistrationByClientId`, which is a `PublicEndpointGuard` query. It doesn't need workspace metadata. Add it to the gater's excluded-paths list alongside the existing pre-auth pages (`SignInUp`, `Verify`, `Invite`, …) so the page renders immediately regardless of token state. This is a one-line, minimum-blast-radius fix. Two related cleanups are separate concerns and not addressed here: - Clearing the workspace-agnostic `tokenPair` cookie on `app.twenty.com` after workspace selection. - Fixing `lastAuthenticatedWorkspaceDomain` propagation for custom-domain users. ## Test plan - [x] `npx oxlint` + `prettier --check` on the touched file — clean. - [x] `npx nx typecheck twenty-front` — clean. - [ ] Manual: with a stale `tokenPair` cookie set on `app.twenty.com`, open `https://app.twenty.com/authorize?client_id=<valid>&…` — consent screen renders, no blank. - [ ] Manual: signed-out → open the same URL — still redirects to `/welcome` and back through the flow. - [ ] Manual: signed-in on a workspace subdomain → open `https://app.twenty.com/authorize?…` — `WorkspaceProviderEffect` auto-redirect still kicks in (unchanged behavior). |
||
|
|
72f857fc10 |
[Website] Refine feature card scroll entrance to a subtle opacity fade (#20574)
Thomas mentioned the animations need to be subtle. Therefore, this PR removes transform-based slide/rotate animations in favor of a clean 0.6s opacity fade for a polished, professional appearance. Before: https://github.com/user-attachments/assets/bede627f-d4b3-4126-8eb0-c1d5a9b4d16a After: https://github.com/user-attachments/assets/85cb604b-a8c9-4730-85d3-8edb8ef0fc71 |
||
|
|
a47e1e0e5e |
Fix time consuming search ilike fallback (#20544)
## Context
When the tsvector full-text search returns 0 hits on the first page,
SearchService falls back to ILIKE '%word%' over searchVector::text. The
leading wildcard makes the GIN index unusable, so it seq-scans the
table.
On large searchable custom objects (e.g. a workspace with ~500k rows in
_logs) a single fallback can take 2–3s, multiplied across all searchable
objects in one request.
## Implementation
Wrap the fallback query in a tiny TypeORM transaction and apply a
Postgres per-statement timeout via set_config('statement_timeout', ms,
true) (= SET LOCAL). On timeout, Postgres throws 57014 (QUERY_CANCELED);
we catch it, warn-log with workspace/object context, and return [] for
that object
## Note
This PR bounds the slow fallback and doesn't make it fast. The right
structural fix is to let the fallback use an index. Since tsvector does
not work with certain language (which is the reason why the ILIKE
fallback was implemented in the first place), we should probably use the
pg_trgm extension instead (@FelixMalfait)
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
|
||
|
|
0d5617d446 |
chore(server): drop unused postgresCredentials feature (#20573)
## Summary Drops the `postgresCredentials` legacy feature: a never-finished "postgres proxy" that would have let users query their workspace data over a standard Postgres connection. Nothing — frontend, e2e, Zapier, docs, other server code — calls these mutations/query. ## History - **Introduced** June 2024 (#5767, Thomas Trompette) as "first step for creating credentials for database proxy", alongside the Postgres FDW / remote-server work and the custom `twenty-postgres-spilo` image. Planned follow-ups (provisioning a DB on the proxy, mapping users, exposing it as a remote server) never landed. - **Abandoned** January 2026 (#17001, Weiko) when the sibling "remote integration" feature was removed as a BREAKING CHANGE — "not maintained for more than a year and never officially launched". The spilo image was then replaced with vanilla `postgres:16` (#19182, March 2026), retiring the FDW infrastructure entirely. - This PR finishes the cleanup: removes the orphaned module, the `allPostgresCredentials` relation, `JwtTokenTypeEnum.POSTGRES_PROXY` + payload, the reserved metadata keywords, and adds a 2.5.0 fast instance command that drops `core.postgresCredentials` (reversible `down`). Regenerated frontend GraphQL types + SDK metadata client. ## Test plan - [x] `tsgo --noEmit` clean on twenty-server + twenty-front; lint + prettier clean on touched files. - [x] `database:migrate:generate` reports no pending schema diff; server boots and serves the new schema. |
||
|
|
61683d8bda |
[Website] Replace product page hero visual with interactive CRM depicting AI chat in action. (#20566)
Before: <img width="1439" height="518" alt="image" src="https://github.com/user-attachments/assets/4d294a1b-c5a0-43b2-9895-61a8ee19da62" /> After: https://github.com/user-attachments/assets/c019586f-ef9f-4ae0-8afe-14f08e8cb057 |
||
|
|
ab705b14d7 |
i18n - translations (#20569)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |