Workflow Search Records action built its fields list from
flatObjectMetadata.fieldIds — only the source object's own fields. The
shared dispatcher then couldn't resolve the relation target field by id
and silently dropped the filter, so a configured one-hop traversal
(e.g. People where Company → Name Contains "Airbnb") wasn't applied.
Collect relationTargetFieldMetadataId from the input record filters
and add them to the field id list before resolving against
flatFieldMetadataMaps.
Every call site of computeRecordGqlOperationFilter was passing only the
source object's fields (objectMetadataItem.fields). The dispatcher needs
the relation target field by id to nest the inner filter under the
source field's GraphQL key — without it, the target lookup returns
undefined and the filter is silently dropped, producing { and: [] }.
The user-visible symptoms: relation-traversal chips show in the UI but
the GraphQL request carries an empty filter, and typing in the value
input doesn't trigger a refetch (variables unchanged because the filter
is being dropped at compute time).
Introduce augmentFieldsWithRelationTargets which extends the base field
list with the target fields referenced by the record filters, resolved
from flattenedFieldMetadataItemsSelector (workspace-wide flat field
list). Wire it through every computeRecordGqlOperationFilter caller in
the frontend (record table table/aggregate/empty/SSE paths, record
index group + total count, graph widget, calendar date range, context
store filters via three callers, parent view query variables).
Stacked on top of #20533 — merge that one first.
## Summary
Splits `turnRecordFilterIntoRecordGqlOperationFilter` into a thin
dispatcher and a private `buildDirectFieldGqlOperationFilter`. Same
observable behaviour, but:
- The two ordering-constraint comments that #20533 introduced go away
(\"must run before the emptiness shortcut\", \"drop rather than fall
through to legacy relation-by-record\"). They were both flagging a real
code smell: dispatch and direct-filter logic were interleaved in the
same function body and depended on a flag inside the input.
- Removes the self-recursion + \"inject target into fieldMetadataItems\"
hack.
- Removes three duplicated `fieldMetadataItems.find(...)` lookups that
only existed to compose error-message labels — the resolved
`fieldMetadataItem` was already in scope.
- Renames `correspondingFieldMetadataItem` → `fieldMetadataItem` inside
the extracted function (the variable IS the field; the longer prefix
predated the split).
## Why
Discussed in #20533: the conditional ordering between the
relation-traversal branch and the per-type switch was easy to get wrong,
and the legacy `case 'RELATION':` foot-gun (parsing the filter value as
a UUID list when it's actually a text value) was guarded by a defensive
`return` rather than by structure.
After this PR the dispatcher picks exactly one branch up front and
`buildDirectFieldGqlOperationFilter` only ever runs against the field
the filter operates on — no \"is this the source field or the target
field?\" ambiguity inside the switch.
## Test plan
- [x] `npx nx build twenty-shared` succeeds (no type regressions).
- [x] `jest packages/twenty-shared` — 1212 tests pass.
- [x] `jest view-query-params.service.spec.ts` — 7 tests pass, including
the relation-traversal round-trip case.
- [ ] CI green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
- useApplyAdvancedFilterSourceField is only reached on leaf source fields
(callers branch to the composite sub-menu hook or relation target hook
before calling it). Removed the now-unreachable composite "any
subfield" fallback and its three imports.
- The three callers that push the focus stack for a leaf RELATION/SELECT
field (AdvancedFilterFieldSelectMenu, AdvancedFilterRelationTargetFieldSelectMenu,
SettingsRolePermissions...FieldSelectFieldMenu) shared the same 8-line
block — extracted into usePushFocusForLeafFieldValuePicker.
mapViewFiltersToFilters used to fall back to the source field's filterType
and label when the relation target couldn't be found in
fieldMetadataItems. The resulting RecordFilter still carried the
unresolved relationTargetFieldMetadataId, and the dispatcher would later
drop the GraphQL filter anyway — leaving a chip in the UI that didn't
match the query.
Treat an unresolvable target the same as an unresolvable source: drop
the filter at mapping time. Race conditions during view change (the same
race the source-field handling documents) are the expected reason for a
transient miss and don't warrant a thrown error.
- useResetFilterDropdown now clears
objectFilterDropdownIsSelectingRelationTargetField, mirroring the
composite-field reset. Without this, dismissing the dropdown mid-pick
(Escape, click-away) left the flag true and the next open jumped
straight to the relation target sub-menu.
- useApplyAdvancedFilterSourceField: tighten the defaultSubFieldName
type. getDefaultSubFieldNameForCompositeFilterableFieldType can
return undefined when the composite type isn't filterable by any
subfield path, so coalesce to null to satisfy the
CompositeFieldSubFieldName | null type expected by RecordFilter.
The backend supports a single-hop relation traversal. If the user picked
a many-to-one relation field as the target, the dispatcher would
substitute the target id into fieldMetadataId and recurse — the inner
call has relationTargetFieldMetadataId set to null, so it falls through
to the per-type RELATION case which expects a UUID list, not a primitive
value. The UI offered a traversal that the backend silently demoted to
filter-by-id.
Filter many-to-one fields out of the target list so only one-hop
traversals can be composed.
The CREATE path hardcoded relationTargetFieldMetadataUniversalIdentifier
to null while passing the FK id through. The migration system's
resolveUniversalRelationIdentifiersToIds then renormalises by treating
the null universal identifier as authoritative and nullifying the FK id,
silently dropping the relation traversal on save.
Mirror the UPDATE path: include relationTargetFieldMetadataId in the
foreignKeyValues passed to resolveEntityRelationUniversalIdentifiers and
read the resolved universal identifier from its result.
When the user picked a relation or composite source field and then backed
out of the sub-menu, the immediate upsertRecordFilter call left an
orphaned partial filter in the chip list (a relation-by-id filter with no
UUIDs, or a composite filter with the "any subfield" fallback). The chip
appeared functional but couldn't actually filter anything.
The source-field handler now only stages the source choice in dropdown
state for the sub-menu paths and lets the sub-menu's hook do the upsert
on the final pick. The leaf path keeps the immediate upsert.
## 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"
/>
- useApplyAdvancedFilterSourceField for the source field choice
(handles composite "any subfield" fallback for the initial pick)
- useApplyAdvancedFilterCompositeSubField for the composite subfield
selection (no relation-target branching)
- useApplyAdvancedFilterRelationTargetField for the relation traversal
target selection (derives filterType + label from target)
Each menu component calls its own dedicated hook — no shared
isRelationTraversal branching, no shared subFieldName fallback path.
The old useSelectFieldUsedInAdvancedFilterDropdown hook is removed.
- view-query-params and common-group-by include relation target field metadata
in the fields list passed to the dispatcher so the traversal branch can
resolve the target field by id
- ObjectFilterDropdownInnerSelectOperandDropdown swaps the throwing lookup
for the non-throwing getFieldMetadataItemById so a deleted target field
renders nothing instead of crashing
- lint: rename setIsSelectingRelationTargetField and isSelectingCompositeField
to match their underlying component state names
## 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.
## 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)
- RecordFilter only carries relationTargetFieldMetadataId; derive label/type at render
- Split AdvancedFilterSubFieldSelectMenu into Composite + RelationTargetField components
- Dedicated objectFilterDropdownIsSelectingRelationTargetField signal (no RELATION sentinel)
- mapViewFiltersToFilters takes a single fieldMetadataItems list
- Server view-query-params and common-group-by stop embedding resolved relation target
- Lift focus-stack push out of useSelectFieldUsedInAdvancedFilterDropdown
## 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.
Two review nits:
1. `RELATION_SUB_MENU_FIELD_TYPE` and `ObjectFilterDropdownSubMenuFieldType`
lived inside the state file. They aren't state — moved to
`object-filter-dropdown/constants/` and `record-filter/types/`
respectively, alongside the rest of the constants and types.
2. The relation sub-menu branch in `AdvancedFilterSubFieldSelectMenu`
computed `targetObjectMetadataId` with an empty-string sentinel so
the always-running `useFilterableFieldMetadataItems` hook could
accept it. Fragile (the `''` sentinel is unnamed, untyped, and
relies on the selector silently returning `[]`). Extracted the
relation branch into its own `AdvancedFilterRelationSubMenu`
component that only mounts when actually in relation mode — the
hook now always receives a real object id, no sentinel.
Also: turned `isManyToOneRelationField` into a generic type guard so
callers can read `field.relation.targetObjectMetadata.id` after the
check without a non-null assertion.
Per review feedback: the relation sub-menu case wasn't actually different
from the composite sub-menu case — they're both "another dropdown that
manages its own focus, source-field push would shadow it." The previous
implementation skipped the push only for relation traversal because that
was the only case where the smell was visible (composite source fields
aren't RELATION/SELECT so the original conditional push didn't fire for
them). Generalising:
- AdvancedFilterFieldSelectMenu: skipFocusPush = subMenuType !== null
- AdvancedFilterSubFieldSelectMenu: skipFocusPush = true (always —
the next dropdown is for a sub-field or target field, never the source).
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>
useSelectFieldUsedInAdvancedFilterDropdown was pushing fieldMetadataItem.id
onto the focus stack whenever the source field was RELATION or SELECT —
originally for the entity / option picker that opens next. With relation
traversal, a RELATION source now opens the sub-menu instead, and the
sub-menu's SelectableList uses advancedFilterFieldSelectDropdownId as its
focusId. The mismatched push at the top of the stack shadowed the
sub-menu's hotkey scope and disabled keyboard navigation.
Add a skipFocusPush opt-out, set by:
- AdvancedFilterFieldSelectMenu when the next step is the relation
sub-menu (case 1, entering)
- AdvancedFilterSubFieldSelectMenu when committing with a resolved
relationTargetFieldMetadataItem (case 2, leaving — the target's value
picker will set up its own focus when opened)
Role-permission callers keep the original push behaviour (they go
directly to a value picker, no sub-menu).
The other foreign keys on viewFilter referencing fieldMetadata / view /
viewFilterGroup all use ON DELETE CASCADE. The new column was using
SET NULL, which required a runtime "drop filter when target gone" path
and left a half-broken row in the table. Align to CASCADE so a deleted
field cleans up its filters atomically, matching the pre-existing
convention on this entity.
In ViewWidgetUpsertService.computeViewFilterOperations the hasChanged
branch correctly detected a change to relationTargetFieldMetadataId but
the entry pushed to filtersToUpdate only spread existingFilter and never
applied the new value (or recomputed its universal identifier). The user's
change was silently ignored. Update path now mirrors the create branch:
includes relationTargetFieldMetadataId and resolves the matching
universal identifier alongside the source field and view-filter-group.
Asserts that the metadata GraphQL Create + Update mutations correctly
persist and surface `relationTargetFieldMetadataId` on view filters:
- create with relationTargetFieldMetadataId set → returns it on the DTO
- create without it → defaults to null
- update relationTargetFieldMetadataId → new value is reflected on the DTO
- update an unrelated property (value) → relationTargetFieldMetadataId
is preserved (regression test for the universal-identifier-recompute
fix in fromUpdateViewFilterInputToFlatViewFilterToUpdateOrThrow)
Uses the standard Person.company MANY_TO_ONE relation pointing at
Company.name / Company.domainName so the test runs against seed data.
- from-update-view-filter-input-to-flat-view-filter-to-update-or-throw:
recompute relationTargetFieldMetadataUniversalIdentifier when the
editable property changes. Without this, workspace migration sync
diffs would miss or mishandle relation-target changes on existing
filters (Create already handled this; Update did not).
- common-group-by-query-runner.service: when converting saved view
filters to record filters for dashboard widget aggregates, look up
the relation target field and embed it on the RecordFilter (same
pattern as view-query-params.service). Without this, saved relation
traversal filters are silently dropped from group-by queries.
- AdvancedFilterValueInput: when reopening a filter chip's value
dropdown, restore relationTargetFieldMetadataIdUsedInDropdown from
the filter so the operand picker reflects the target's type (not
the relation field's RELATION type).
- useGetRecordFilterDisplayValue: read SELECT / MULTI_SELECT options
from the target field (when relation traversal is in play) so chip
labels show the target field's option labels, not the source's.
Replace the cross-object field lookup that bled into ~9 callers with a
self-contained filter shape: RecordFilter now carries an optional
relationTargetField: { id, name, type, label } resolved at construction
time. The shared turnRecordFilterIntoRecordGqlOperationFilter reads the
target directly off the filter and injects it into the recursive lookup
array, so downstream callers no longer need to pass the flattened
cross-object metadata.
Effect on the diff:
- RecordFilter shape gains relationTargetField (shared + frontend types).
- turnRecordFilterIntoRecordGqlOperationFilter uses recordFilter.
relationTargetField directly; no caller-supplied lookup needed.
- Filter construction sites embed the resolved target:
mapViewFiltersToFilters (frontend),
useSelectFieldUsedInAdvancedFilterDropdown (frontend),
view-query-params.service.ts (server).
- Revert 9 consumer callers to objectMetadataItem.fields:
useFindManyRecordIndexTableParams, useGraphWidgetQueryCommon,
useRecordIndexGroupCommonQueryVariables,
useRecordIndexGroupsAggregatesGroupBy,
RecordTableEmptyHasNewRecordEffect,
useAggregateRecordsForRecordTableColumnFooter,
RecordTableVirtualizedSSESubscribeEffect,
useGetRecordIndexTotalCount, useRecordCalendarQueryDateRangeFilter.
- computeContextStoreFilters drops its optional flattened-fields param;
reverts 5 callers (useUpdateMultipleRecordsActions,
useRecordIndexLazyFetchRecords, useFindManyRecordsSelectedInContextStore,
RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect,
buildHeadlessCommandContextApi).
- getQueryVariablesFromFiltersAndSorts reverts to single-object fields.
- mapRecordFilterToViewFilter derives the persisted id from
recordFilter.relationTargetField?.id.
Net −79 LOC and the filter is now self-contained.
- turnRecordFilterIntoRecordGqlOperationFilter now drops the filter instead
of falling through to the legacy relation-by-record path when
relationTargetFieldMetadataId is set but the target field isn't in the
provided fieldMetadataItems. The legacy path parses the value as a UUID
list, which would silently mishandle target-field values like "Acme" and
could broaden destructive operations to every record.
- Update remaining callers of computeRecordGqlOperationFilter /
computeContextStoreFilters to pass the flattened cross-object field list
so the target field is resolvable: useGetRecordIndexTotalCount,
getQueryVariablesFromFiltersAndSorts, useUpdateMultipleRecordsActions,
useRecordIndexLazyFetchRecords, useFindManyRecordsSelectedInContextStore,
RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect,
buildHeadlessCommandContextApi, useRecordCalendarQueryDateRangeFilter.
- computeContextStoreFilters accepts an optional flattenedFieldMetadataItems
param that falls back to objectMetadataItem.fields.
- ViewQueryParamsService now carries relationTargetFieldMetadataId onto
RecordFilter and includes the target field in the fields array passed to
computeRecordGqlOperationFilter so server-side flows (dashboard widgets,
workflows, etc.) execute the relation traversal branch instead of falling
back to legacy relation-by-record matching. Add an integration-style spec
asserting the nested filter shape.
- Prettier fix in filterSortableFieldMetadataItems.
Address PR review comments:
- Move isManyToOneRelationField to @/object-metadata/utils (global helper).
Refactor prefillRecord, sanitizeRecordInput, filterSortableFieldMetadataItems
and spreadsheetImportHasNestedFields to reuse it.
- Drop narrative/redundant comments on RecordFilter, ViewFilter shape,
RELATION_SUB_MENU_FIELD_TYPE sentinel and relationTargetFieldMetadataIdUsed
state — names are self-describing.
Simplifications:
- Unify the two select-filter handlers in AdvancedFilterSubFieldSelectMenu
behind a single keyword-args entry point.
- Collapse isRelationDrillDown/isCompositeDrillDown flags in
AdvancedFilterFieldSelectMenu into a single computed sub-menu type.
- Drop the redundant `'relationTargetFieldMetadataId' in viewFilter` check
in mapViewFiltersToFilters now that ViewFilter shape carries the field.
- Drop the over-engineered useMemo around effectiveFieldMetadataItem in
ObjectFilterDropdownInnerSelectOperandDropdown — the underlying lookup
is a cheap dictionary read.
- Reuse the same relationTargetFieldMetadataId for both the filter object
and the dropdown state setter in useSelectFieldUsedInAdvancedFilterDropdown.
Main bumped current version to 2.6.0; the upgrade-mutation guard requires
new commands to live in the current version's directory. Move the file,
rename the timestamp prefix, and bump the @RegisteredInstanceCommand version.
## 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
## 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
## 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.
## 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`.
Adding the relationTargetFieldMetadata many-to-one relation gives viewFilter
one more parent-pointing edge, which shifts it ahead of commandMenuItem/
fieldPermission/viewField in the children-first ordering.
## Summary
- Moves current version to previous versions array
- Sets TWENTY_CURRENT_VERSION to the new version
- Updates TWENTY_NEXT_VERSIONS with the next minor version
## Checklist
- [ ] Verify version constants are correct
Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
## Summary
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).
- Add relationTargetFieldMetadataId to 17 ViewFilter DocumentNode constants
(codegen was missing these AST entries even though the TS shapes had the
field).
- Update useApplyCurrentViewFiltersToCurrentRecordFilters expectation to
include relationTargetFieldMetadataId: null in the produced RecordFilter.
- Add relationTargetFieldMetadataUniversalIdentifier to viewFilter
propertiesToCompare snapshot.
Earlier patch used an overly broad sed that also matched ViewSort inline
shapes (since ViewSort also has subFieldName, viewId). ViewSort doesn't
have this column - only ViewFilter does.
The ViewFilter DTO / inputs gained `relationTargetFieldMetadataId` in
this PR but I had forgotten to re-run the schema generators that mirror
the GraphQL surface into typed clients:
- twenty-front/src/generated-metadata/graphql.ts — add the column to
CreateViewFilterInput, UpdateViewFilterInputUpdates,
UpsertViewWidgetViewFilterInput, the ViewFilter type, and the 22+
inline `Array<{ __typename: 'ViewFilter', ... }>` shapes used by
query/mutation result types and fragments.
- twenty-client-sdk/src/metadata/generated/{schema.graphql,schema.ts,
types.ts} — same field on each of the four shapes (type +
Create / UpdateUpdates / UpsertViewWidget inputs) plus the
ViewFilterGenqlSelection helper and the four introspection-data
entries in types.ts.
Diff matches what the codegen+sdk generators would emit (validated
field-by-field against the diff CI's server-validation step
produced).
## 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`
The server-validation step (`database:migrate:generate`) emits a pending
migration whenever the entity-derived schema differs from what existing
migrations produced. The original migration drifted in three ways that
TypeORM's generator flagged:
- FK constraint name was a human-readable
`FK_VIEW_FILTER_RELATION_TARGET_FIELD_METADATA_ID` instead of the
hash that `DefaultNamingStrategy.foreignKeyName` produces (sha1 of
`<tableName>_<columns>` truncated to 27 chars) →
`FK_dbe259395cbd9a54c1c17d12b0b`.
- The FK lacked `ON UPDATE NO ACTION`, which TypeORM emits by default.
- Operation order was column → FK → index; TypeORM emits
column → index → FK.
Also dropped the `IF NOT EXISTS` guards on column / index / constraint
since TypeORM-generated migrations don't use them.