f8e3fd110d35778bef7341ef563c2607d065bd2e
4256 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f8e3fd110d |
Bound an application by its own role as well as the user's (#23680)
## Why
When an application acts on someone's behalf its token carries `userId`
and `userWorkspaceId` alongside `applicationId`, and the application
then received **that person's permissions in full**. The role it
installs with was never consulted, so it was not a bound on what the
application could do for them. It was meant to be an intersection.
`permissions.service.ts` made this visible: the branches are `apiKeyId →
userWorkspaceId → applicationId` and each returns early, so with both
present the user branch won and `application.defaultRoleId` was never
read. The same was true on the object and row-level paths, for different
reasons.
## What had to change
Three independent causes, all of which blocked the intersection from
existing or from being enforced.
**The application was thrown away before anything could use it.**
`workspace-auth-context.middleware.ts` built a `type: 'user'` context
when both principals were present, and `UserWorkspaceAuthContext` had no
slot for an application. It now carries an optional one.
Additive rather than a new union member on purpose. Nothing in the
server exhaustively checks this union (no `assertUnreachable`, one
`switch`, in Sentry tagging), so a sixth member would have compiled fine
and then fallen through actor attribution, that switch, and
`metadata-event-emitter.ts` silently. The additive change leaves all
four type guards returning identical booleans.
**Role resolution returned a single id.** The rule itself now lives in
one place, `resolveRoleIdsForUser`: a user's role, narrowed by the
application's if it declared one, never the same id twice.
`resolveRoleIdsFromAuthContext` and `resolveRolePermissionConfig` build
`{ intersectionOf: [...] }` from it, which `getRepository` already
applied over N roles. A user with no role still resolves to nothing, so
an application can never stand in for a missing user role.
**Row-level security ignored all of it.** RLS was re-derived from a
single role at query time, so it would have been unaffected by any
intersection. Each role is now compiled on its own and the resulting
filters are ANDed.
That last choice matters. Merging the raw predicates and groups first
would have been wrong: `computeRecordGqlOperationFilter` honours only
the first parentless group, so concatenating two roles' groups makes one
role's predicates vanish, **widening** access. Compiling per role and
ANDing needs no synthetic groups, no re-parenting and no `twenty-shared`
type change, and reuses the single-role logic untouched.
Subscriptions go through the same rule. An event stream resolved only
the subscriber's role, so a stream opened by an application acting for
someone was filtered by that person's role alone. The stream now records
the application it was opened by and the publisher intersects both roles
for object permissions, restricted fields and RLS, exactly as a query
does.
Two smaller fixes fall out:
- `getObjectsPermissionsFromRolePermissionConfig` had a `// Multi-role
union/intersection is not ready — use the first assigned role only`
shortcut and now intersects.
- `computePermissionIntersection` hardcoded empty row-level predicate
arrays, which is why RLS-constrained fields were not exempted from the
field-permission check on insert and could fail spuriously. It now
reports the fields **every** role constrains. Reporting fields
constrained by only one role would be worse than the original bug: the
insert guard waives a field-update deny on them, so one role's row-level
rule would cancel another role's deny.
## Behaviour on the edges
**An application that declares no role adds no bound.** `defaultRoleId`
stays null whenever a manifest omits `defaultRoleUniversalIdentifier`,
which is the common case, so denying would have broken a lot of
installed applications. Behaviour changes only for applications that
actually declared a role.
To stop that being permanent, `defaultRoleUniversalIdentifier` should
become required for new applications. Hard-requiring it needs a backfill
for existing installs, so it is not in this PR.
**An application that cannot be found denies.** That is not the same as
one that declared no role, and treating it as such would have let a
token naming a deleted application fall back to the full permissions of
the user it acts for.
**A role that cannot be resolved denies.** `application.defaultRoleId`
is a plain uuid column with no foreign key, and role deletion does not
clear it, so it can dangle. A bound we cannot apply must not let the
remaining roles decide on their own, so the ORM path,
`getObjectsPermissionsFromRolePermissionConfig` and the subscription
publisher all return no permissions in that case rather than falling
back.
## Testing
- Full `twenty-server` unit suite green (896 suites, 7353 tests)
- New spec for `resolveRoleIdsFromAuthContext`: both roles, application
with no declared role, application holding the user's own role, user
with no role, api key, application-only, system
- New spec for multi-role RLS, including the case this fixes (a
restricted role intersected with an unrestricted one keeps the
restriction) and two restricted roles ANDing
- `permissions.service.spec.ts` had **no coverage of the application
branch at all** (`ApplicationEntity` was mocked as `{}`); it now has a
real mock plus user-grants/application-denies, the reverse, both-grant,
the null-role fallback, a shared role, and a missing application
- First coverage of non-empty row-level predicates through
`computePermissionIntersection`, including a field constrained by one
role only
- Subscription publisher: application role denies, both allow,
application role dangling, and both roles reaching the RLS filter
- Updated the two specs that asserted the old behaviour: the middleware
dropping the application, and "use the first role when multiple are
provided"
No schema, cache or GraphQL change: `rolesPermissions` is keyed by role
id alone and the intersection is computed per request from cached
per-role entries.
## Not in this PR
`workflow-execution-context.service.ts` falls back to the **admin** role
when an application has no `defaultRoleId`, and to
`shouldBypassPermissionChecks: true` if admin is not found. That is the
inverse of the rule here and an escalation in its own right, but
workflow execution is sensitive, so it is tracked separately in
twentyhq/core-team-issues#2753.
Three resolvers still carry their own principal precedence and do not
use this seam: `rest-api-base.handler.ts`, `mcp-protocol.service.ts`
(which never builds a user or application context at all), and actor
attribution in `actor-from-auth-context.service.ts`.
Separately, `computeRecordGqlOperationFilter` silently discards
predicates under any parentless group after the first, with no test
coverage. That is a latent bug independent of this work and lives in
`twenty-shared`, shared with the front-end filter system.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01C6nCVbcb5ZZrz67uvqvMWF)_
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23680?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``<img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</a>
|
||
|
|
62c1cf78af |
fix(server): scope personal favorite SSE events to their owner (#23712)
## Problem Fixes #20483. In a multi-user workspace, when a user creates/updates/deletes a **personal favorite** (a `navigationMenuItem` with a non-null `userWorkspaceId`), the metadata SSE event is broadcast workspace-wide. Every other connected user receives it and the favorite pops into their own sidebar in real time. Cross-user data-isolation leak. ## Root cause The delivery filter in `WorkspaceEventBroadcaster` already supports per-user scoping via `recipientUserWorkspaceIds`, but treats an **undefined** list as workspace-wide (delivered to every stream). `MetadataEventPublisher.publish` never set that field, so every favorite event fell into the workspace-wide default. The `agentChatThread` path already sets `recipientUserWorkspaceIds` explicitly and is scoped correctly; favorites simply never opted in. ## Fix In `MetadataEventPublisher`, resolve the owning `userWorkspaceId` for `navigationMenuItem` events (from `properties.after` on create/update, `properties.before` on delete) and set `recipientUserWorkspaceIds: [userWorkspaceId]` when present. Workspace-level items (`userWorkspaceId === null`) leave it unset and keep broadcasting to everyone. This mirrors the existing `agentChatThread` precedent and touches only the producer, not the broadcaster or any consumer. ## Testing Unit test (`metadata-event-publisher.spec.ts`) covers personal create/update/delete (scoped to owner), workspace-level (unscoped), and an unrelated metadata entity carrying a user id (unscoped). Also verified end to end against a local multi-user workspace (Tim and Jane, same workspace): each opened a live SSE stream (`/metadata` `onEventSubscription`) and Tim created favorites. | Case | Before fix | After fix | |------|-----------|-----------| | Personal favorite -> owner (Tim) | receives | receives | | Personal favorite -> other user (Jane) | **receives (leak)** | not received | | Workspace-level favorite -> other user (Jane) | receives | receives | - `nx typecheck twenty-server`: pass - oxlint + oxfmt on changed files: clean ## Scope / follow-up Favorites only. Two related items are intentionally out of scope and worth tracking separately: scoping other user-owned metadata (`view` via its visibility rules, `roleTarget`), and making the broadcaster's "no recipient list = everyone" default explicit rather than fail-open. |
||
|
|
b7724bbbee |
i18n - translations (#23713)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
2389b4f807 |
Add captcha and throttling to the password reset link (#23372)
The public `emailPasswordResetLink` mutation was the only email-taking auth mutation without `CaptchaGuard`, so bots could drive reset email spam against arbitrary addresses. - Adds `CaptchaGuard` and a `captchaToken` argument (no-op when no captcha provider is configured). The frontend sends it like sign-in does, and `/settings/profile` joins the captcha-protected paths so the Change Password button keeps working - Throttles reset emails per address, 3 per 15 minutes, and surfaces a rate limit error once the bucket is empty - Acknowledges the request as soon as the throttle passes and generates the link off the request path, so the response time no longer depends on whether the address is registered - Returns a generic success instead of distinguishing found from not-found, with matching frontend copy - Rotates the reset token in a single transaction, so a failed write can no longer revoke a still valid link This does not close user enumeration on its own: `checkUserExists` exposes `exists` on the same unauthenticated surface, and sign-in returns distinguishable errors. Tracked in #23711. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23372?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
96ea1e1ffc |
Track connected account webhook subscription lifecycle metrics (#23710)
Emits created/renewed/deleted counters and their failure counterparts from the messaging and calendar webhook subscription services. Each counter carries channel_type and provider attributes so the Grafana panels can break them down. Infra side: twentyhq/twenty-infra#841 Co-authored-by: neo773 <huzef@twenty.com> |
||
|
|
4a5c623ece |
Improve the workspace setup kickoff prompt (#23594)
Rewrites the workspace setup chat kickoff prompt for conversion: the goal is a real workspace the team keeps using, with the setup doubling as a tour of what Twenty can do. - Replaces the arbitrary bands (2-4 custom objects, 3-6 fields, 250 words) with admission tests, favoring custom fields on standard objects over custom objects. - Opens by sharing what we already know about the company and the user's role, then lets them steer: propose a model right away, or hear their use case first. - After the data model there is no fixed script. The agent proposes the single next capability worth building (workflow, dashboard, role) based on what the user actually said, and names the ones it did not build before closing so nothing stays hidden. - Introduces each capability in one plain sentence where it comes up, and drops the view-field step that #23585 made redundant. Also passes the workspace member job title into the AI chat user context, so the agent can shape the setup around what the user does. This applies to every chat, not just onboarding. Example: Creating an Apple workspace <img width="2584" height="5022" alt="CleanShot 2026-08-03 at 15 33 48@2x" src="https://github.com/user-attachments/assets/bc12ec95-e29d-42fd-8757-38ab3a8a5705" /> <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23594?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
495bd193a3 |
Messaging archived account fix (#23553)
Fix case where workspace admin wants to reconnect inherited connected account Case: - workspace admin inherits team accounts from other workspace member who left the workspace - admin wants to reconnect inherited channels but it's not possible as there's no path to make archived connected account active Expected outcome: admin, who has credentials to archived connected accounts, can reconnect said accounts <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23553?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: neo773 <huzef@twenty.com> Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com> |
||
|
|
9731921983 |
fix(server): catch application job enqueue throttle instead of failing the batch job (#23684)
## Context Sentry issue [TWENTY-SERVER-GXJ](https://twenty-v7.sentry.io/issues/7495161692?project=4507072499810304) (`Application job enqueue limit reached`, 10k+ events, 33 workspaces) is the application-job enqueue throttle firing as designed, but being reported as an error. ## Root cause `CallDatabaseEventTriggerJobsJob.handle` calls `throttleOrThrow` inside its per-application loop with no `try/catch`. When an application exceeds its enqueue budget, the `ThrottlerException` propagates out of the bullmq job handler, where `shouldCaptureException` captures it (it has no `statusCode < 500`, so it is not filtered) and the whole batch job fails. Two consequences: - Expected throttling shows up in Sentry as an error (noise). A metric (`JobEnqueueApplicationRateLimited`) already tracks it. - The batch job fails and is retried (`retryLimit: 3`), re-running the loop from the top and re-enqueuing logic-function jobs for applications that already succeeded before the throttled one (duplicate triggers); after retries are exhausted the remaining applications' triggers are dropped. The workflow hard-throttle uses the same `ThrottlerException` but does not show up in Sentry because its call site (`checkHardThrottleLimit`) catches it and turns it into a graceful signal. This PR applies the same pattern to the application enqueue path. ## Change - Wrap the `throttleOrThrow` call: on `ThrottlerException`, `continue` to the next application instead of failing the job; rethrow anything else. - Add a unit test covering the skip-throttled-application and rethrow-other-errors behavior. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23684?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
6afc3d33a4 |
fix: provision INDEX view fields for relations created in the same batch as their object (#23665)
## Context Fixes twentyhq/core-team-issues#2749: when an app manifest creates an object and its relation fields in a single sync, the engine-owned INDEX view ended up with no viewField at all for RELATION / MORPH_RELATION fields, not even a hidden one. Adding the same relation to a pre-existing object in a second sync produced a visible viewField. ## Root cause `fieldIndexViewFieldOnCreate` is the sole owner of caller-field view fields on the INDEX view (`objectSystemFieldsAndIndexViewOnCreate` only emits view fields for displayable system fields). Its same-batch branch gated on `isFlatFieldMetadataDisplayableInDefaultView`, which excludes RELATION / MORPH_RELATION, so relations were dropped and nothing else picked them up. The guard's other exclusions (reserved names like `id`/`deletedAt`, system-only types TS_VECTOR / POSITION) are unreachable there: side effect handlers only trigger on caller-authored entities (the engine reads triggers from the pre-expansion matrix), and the flat field validators reject those names/types for caller fields. The guard could only ever drop relations. ## Fix - Remove the displayability guard from `buildViewFieldForObjectCreatedInSameBatch`. - Remove the `displayableOnly` filter from `computeCallerFlatFieldMetadatasForObject`: every caller field now gets a view field, and both handlers keep deriving positions from the same list, so the interleaved layout stays consistent by construction (label identifier, caller fields in input order with relations, then displayable system fields). - Build the view field literal through a single `buildIndexFlatViewFieldToCreate` helper on all handler branches instead of `computeFlatViewFieldsToCreate`, whose internal displayability filter would have dropped relations again. That util keeps its semantics for its remaining callers (system-field view fields, object creation via API, committed upgrade commands). Both identifier derivations are the same deterministic uuid (asserted by an existing twenty-shared spec), so emitted identifiers are unchanged. - `isFlatFieldMetadataDisplayableInDefaultView` itself is untouched: the committed 2-26 upgrade command and the system-field filtering still rely on its current semantics. ## Tests - New manifest-sync integration test (first commit, TDD red then green): a single sync creating two objects and a MANY_TO_ONE / ONE_TO_MANY relation pair asserts each object's INDEX view has a visible view field for its relation, plus a control case adding the same relations to pre-existing objects in a second sync. - Unit spec: the test that locked in the noop now asserts a visible view field at the expected position for RELATION and MORPH_RELATION. - Verified locally: all 62 metadata-side-effect unit tests, the new integration spec, `successful-sync-application-workspace-migration` (4 snapshots), `relabel-onto-new-field-manifest-sync`, `create-one-field-metadata-relation`, plus twenty-server typecheck and lint. |
||
|
|
ad8830ecbf |
fix(server): stop FIND_RECORDS from silently ignoring its filter (#23640)
## Problem
A FIND_RECORDS workflow step with a filter configured could silently
return every record (and thus the first row of the table) instead of
applying the filter. Ways to hit it:
- `recordFilters` set but `recordFilterGroups` omitted (e.g. an
API/agent caller, or any non-UI config).
- A grouped filter (carrying `recordFilterGroupId`) whose
`recordFilterGroups` is missing.
- A filter referencing an unknown `fieldMetadataId` or an unresolvable
relation (`turnRecordFilterIntoRecordGqlOperationFilter` returns
`undefined`, silently dropped).
- `gqlOperationFilter` set, which passed validation but was never read.
In every case the computed filter collapses to `{}`,
`FindRecordsService` returns all records ordered by `id ASC`, and
`records[0]` is the first row. This is fail-open: the step reports
success and returns wrong records rather than erroring.
## Fix
- Compute the filter whenever `recordFilters` is non-empty, defaulting
`recordFilterGroups` to `[]`. `computeRecordGqlOperationFilter` handles
ungrouped filters independently of groups.
- **Fail closed**: if `recordFilters` is non-empty but the computed
`gqlOperationFilter` is empty, throw `INVALID_STEP_INPUT` instead of
running an unfiltered query. This covers grouped-without-groups and
unknown-field/unresolvable-relation cases raised in review. An absent or
empty `recordFilters` still legitimately means "find all".
- Remove the unused `gqlOperationFilter` field from the find-records
input type and settings schema so it is no longer advertised as a filter
option (it has been dead since #16147, when the action moved to
computing the filter at runtime from `recordFilters`).
|
||
|
|
9df893ead1 |
chore: sync AI model catalog from models.dev (#23682)
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. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23682?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com> |
||
|
|
d77b1017aa |
chore: sync AI model catalog from models.dev (#23666)
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. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23666?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com> |
||
|
|
b419805baf |
i18n - translations (#23660)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23660?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
503e40d51b |
i18n - translations (#23659)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
4f8aaeaab0 |
refactor(navigation-menu-item): validate universal properties instead of ids (#23566)
Follow-up to the discussion on #23485 and closes https://github.com/twentyhq/twenty/issues/23484. `FlatNavigationMenuItemValidatorService` receives `UniversalFlatEntityValidationArgs<'navigationMenuItem'>`, so the entity it validates is a `UniversalFlatNavigationMenuItem`: `viewId`, `pageLayoutId` and `targetObjectMetadataId` do not exist at that scope. The validator read the right universal keys but passed them through a bag of booleans named after the ids (`hasViewId`, `hasPageLayoutId`, ...) and then reported the id names in its errors. Nothing tied a message to the property it checked, so fixing one message string leaves the other five wrong. ## Changes - Replace the private `validateNavigationMenuItemType` boolean bag with `validateNavigationMenuItemTypeRequiredProperties({ flatNavigationMenuItem })` under `flat-navigation-menu-item/validators/utils/`, in line with `validateAgentRequiredProperties` and `validateNavigationMenuItemPageLayoutReferenceCrossEntity`. It takes the universal entity, so a message can only name a property that exists at that scope. - The util is an explicit `switch` on `NavigationMenuItemType` closed by `assertUnreachable`, so adding a type fails to compile until its contract is declared. - Each case validates its own properties instead of checking presence generically: - `FOLDER`: non blank `name` - `OBJECT`, `VIEW`, `PAGE_LAYOUT`: `targetObjectMetadataUniversalIdentifier` / `viewUniversalIdentifier` / `pageLayoutUniversalIdentifier` must be valid uuids - `RECORD`: `targetRecordId` and `targetObjectMetadataUniversalIdentifier`, both uuids, reported separately - `LINK`: `link` must pass `isValidUrl` - Both call sites spread the result; the update path passes the merged `{ ...from, ...update }` entity, which removes the redundant `name` re-merge. `targetRecordId` stays an id: it points at workspace record data rather than metadata, so it has no universal counterpart. ## Behaviour - Errors name the universal property (`viewUniversalIdentifier`) instead of the id (`viewId`). - Blank strings are now uniformly treated as missing; creation previously accepted `link: " "`. - `RECORD` reports each missing property separately instead of one merged error. - Values that are present but malformed are now rejected: non uuid identifiers and links that are not urls. Standard application identifiers are all v4 uuids and the create/update inputs already carry `@IsUUID`, so this only tightens the app manifest path. ## Verification - Unit tests for the util cover each type valid and invalid, blank names, non url links and non uuid identifiers (23 tests pass alongside the sibling suite) - `nx typecheck twenty-server` clean - oxlint (type-aware) and oxfmt clean on the changed files <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23566?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
f663cd3c68 |
Move open-record-in to object metadata and member preference (#23614)
Replaces the per-view "Open in" setting with a two-level model, following up on #23422 / #23424 and superseding the closed #23446 and #23457: - `objectMetadata.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` | `USER_CHOICE` (default `USER_CHOICE`) - `workspaceMember.openRecordIn`: `SIDE_PANEL` | `RECORD_PAGE` (default `SIDE_PANEL`), editable in Settings > Experience The rule: records open where the member prefers, unless the object pins them, and never in a panel there is no room for (mobile always resolves to the record page). ## Why Having the setting on views, objects and members at once was heavy, and view-level resolution was fragile: a chip rendered outside a view (notes, front components, kanban cards pointing at another object) had no view to read from, which is the class of bug behind #23422. Resolution is now context-free: it needs only the object, the current member and the viewport, so chips behave identically everywhere by construction. ## Changes **Object level** - New `openRecordIn` enum column on `objectMetadata`, editable through `updateOneObject` and surfaced in Settings > Data model > Object > Layout ("Open records in": Member preference / Side Panel / Record Page) - Standard definitions pin `workflow`, `workflowVersion`, `dashboard` and `messageCampaign` to the record page (matching the previously hardcoded list) and `calendarEvent` to the side panel (it has no curated record page); everything else, including `workflowRun`, follows the member preference - Apps can set it in `defineObject()` via the object manifest **Member level** - New `openRecordIn` standard field on `workspaceMember`, persisted through the existing settings path (same as `colorScheme`) and exposed in Settings > Experience **View level (deprecated)** - `view.openRecordIn` is no longer read or written by the frontend; the "Open in" entry is gone from the view options dropdown - The column, DTO field and inputs are kept for one release for API compatibility: the output field carries a `deprecationReason`, the inputs keep accepting the value with a `Deprecated:` description (NestJS silently drops input fields that have a `deprecationReason`, which would have been a breaking change) **Upgrade (2.27)** - Fast instance command adds the `objectMetadata.openRecordIn` column defaulting to `USER_CHOICE` - Workspace command adds the `workspaceMember.openRecordIn` field - Workspace command seeds the object column from the standard definitions (any non-`USER_CHOICE` value), then lifts deliberate per-view record page choices onto objects the definitions don't pin **Debt removed** - `canOpenObjectInSidePanel` hardcoded object list and its test - `ObjectOptionsDropdownLayoutOpenInContent` and the `layoutOpenIn` dropdown wiring - `DefaultViewOpenRecordIn` - Context-store/view-based resolution in `useResolveOpenRecordIn` (now reads object metadata + member + viewport) - Front components no longer guess from the current view: an explicit side-panel call honours a pinned object and the viewport, nothing else ## Verification - Ran the three upgrade commands against a live database: column created, the pinned standard objects seeded per workspace (record page pins plus calendarEvent to side panel), member field backfilled to `SIDE_PANEL`; seed rerun is a no-op - Seed command verified on a simulated pre-upgrade workspace (index view set to record page on company): pins the standard objects plus company, idempotent on rerun - Both packages typecheck and lint clean; affected unit suites and the application sync, view creation and metadata cache integration specs pass --------- Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com> |
||
|
|
9a7658391b |
Remove noisy actor context injection logs (#23649)
## Context `ActorFromAuthContextService` runs while preparing records for create and update operations. Every actor-field injection logged the complete list of field names found in the object's metadata. It also logged when an object did not contain the actor field, even though skipping injection is an expected control-flow path. These messages are not actionable in normal operation. On a frequently used path, building and emitting them adds avoidable string allocation, serialization, output, and log-ingestion work. ## What changed - Remove the metadata field-list info log. - Remove the info log for the expected missing-field path. - Remove the now-unused NestJS logger instance. ## Safety This only removes routine informational logging. Actor metadata lookup, missing-field handling, record cloning, and `createdBy` / `updatedBy` injection are unchanged. Errors are not suppressed, including the existing error for an unsupported authentication context. ## Expected impact This reduces application log volume and avoids repeated formatting of metadata field-name arrays on record mutations. It is a small hot-path cleanup intended to reduce allocation and logging overhead, not a standalone fix for API tail latency. ## Validation - Oxfmt check on the changed service - Oxlint on the changed service - `ActorFromAuthContextService` Jest suite, 4 tests passing - `git diff --check` |
||
|
|
cb338962f5 |
fix(server): preserve null values in record update util (#23639)
## Problem Record updates through the MCP server silently drop `null` values. Setting a field to `null` (to clear it) returns success but changes nothing. ## Root cause `removeUndefinedFromRecord` is called by `UpdateRecordService.execute` before the DB write. It used `isDefined(value)` to decide what to strip, but `isDefined` returns false for **both** `undefined` and `null`: ```ts export const isDefined = (value) => !isUndefined(value) && !isNull(value); ``` So despite the name (and the comment stating the validation layer expects "a value or null"), the util also discarded `null`. The MCP `update_one` path deliberately keeps nulls (it filters only `!== undefined`), but this util then removed them one layer down. The workflow UPDATE_RECORD action was less affected because it passes an explicit `fieldsToUpdate` list, but the value-level strip sits on both paths. ## Fix Strip only `undefined`; preserve `null` so a field can be explicitly cleared. Added unit tests covering undefined stripping, null preservation, and nested composite fields. ## Test ``` npx jest remove-undefined-from-record # 5 passed ``` <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23639?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
2aa118cb0d |
Add a roles standard skill for the AI chat agent (#23636)
Role management tools shipped in #23613, but no skill was added, so the agent could call them with no guidance. This adds a `roles` standard skill covering the traps that are easy to get wrong: `upsert_object_permissions` replaces the role's entire override list (so omitted objects silently revert to global permissions), granting write without read is rejected, system-managed roles like Admin cannot be modified, and the lockout guard rejects mutations that would strip the acting admin's own access. It also tells the agent to call `list_roles` first, since every workspace already ships with Admin and Member, and reuses the confirmation-gate pattern from `dashboard-building` before any create/update/delete. New workspaces get the skill from the standard application. There is no generic sync for existing ones, so this also adds a `2-27` backfill command that diffs computed standard skills against existing ones and creates the missing ones. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23636?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
0f06fccdee |
Make page layout tab layoutMode diffable and default standalone pages to vertical list (#23596)
Reported on Discord: an app declared a `STANDALONE_PAGE` layout with one `FRONT_COMPONENT` widget and got a small bordered card instead of a full-bleed page, and setting `layoutMode` afterwards changed nothing. Two bugs: - `pageLayoutTab.layoutMode` was `toCompare: false`, so it was written once at create and never diffed again. Changing it in a manifest and redeploying was a silent no-op, and `updatePageLayoutTab(layoutMode:)` was accepted by the API then dropped by the runner's update sanitizer. - A manifest tab that omits `layoutMode` defaulted to `GRID` regardless of page layout type, and a `GRID` tab always renders its widgets as cards on a 12-column grid. Standalone pages now default to `VERTICAL_LIST`, where a lone widget owns the tab. Also fixes the SDK scaffolder (`twenty add page-layout` emitted a tab with no `position`, which does not typecheck) and the docs claim that a single widget is always full-bleed. Worth knowing for review: this does not migrate workspaces holding legacy `CANVAS` tabs. The standard-app sync only runs against a fresh schema, so those rows stay `CANVAS` and keep rendering correctly through the derived presentation. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23596?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
57ecab8571 |
Fix TOTP validation ignoring the configured tolerance window (#23632)
## Problem Entering a 2FA code right after it rotates fails with "Invalid OTP". A tolerance window was configured (`TOTP_DEFAULT_CONFIGURATION.window`) but never applied: `TotpStrategy` validated its options with Zod and then discarded them, and `validate()` called the global otplib `authenticator`, which defaults to `window: 0`. Only the current 30-second code was ever accepted, and clock drift between the device and the server made it feel even stricter. ## Changes - `TotpStrategy` now clones the otplib authenticator with the validated options (window, step, digits, algorithm, encoding, epoch) and uses that instance for both `initiate` and `validate`, so the configured tolerance actually applies. - Default window set to 1: the previous and next codes are accepted alongside the current one, so a code stays valid for up to 30 extra seconds after rotation and forward clock skew is tolerated. This follows the RFC 6238 recommendation of one time step, kept deliberately tight since `getAuthTokensFromOTP` has no rate limiting beyond the optional captcha guard. - Replaced the placeholder strategy tests with deterministic assertions using fixed epochs: previous and next tokens accepted within the window, a token two steps old rejected, and no-window strategies still reject the previous token. ## Testing - All 2FA module unit tests pass (105), including 19 for the strategy. - `lint:diff-with-main` and `typecheck` pass for twenty-server. --- _Generated by [Claude Code](https://claude.ai/code/session_01Va3ESUmkp14Wu65sAXkL7k)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23632?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
fec266a5ae |
test(server): strengthen MCP catalog and gating coverage (#23630)
The three MCP testing improvements discussed in #23613 (now merged; this branch has been rebased onto main). ## What **1. Name-set assertions in `mcp-protocol.service.spec.ts`.** The two "exactly 6 tools" tests only used `expect.objectContaining` subset matches, so the size claim in the titles was never enforced and a new meta-tool would pass silently. They now assert the exact sorted key set of the ToolSet handed to the executor. This immediately caught real drift: the toolset has seven tools, and `get_tool_catalog` was missing from the expected list. **2. Catalog contract integration test** (`test/integration/ai/suites/mcp-tool-catalog.integration-spec.ts`). Calls `get_tool_catalog` over real HTTP with an API-key bearer, then for every advertised category dispatches one read-only tool (`find_/list_/get_/search_` prefixed, up to 3 candidates) through `execute_tool` and asserts a success envelope. Any newly registered provider is covered the moment it appears in the catalog, with no new test code. Categories with no read-only tool are compared exactly against a deliberate exception list, currently empty since every advertised category ships a read-only tool, so drift in either direction fails loudly. **3. Permission gating integration test** (same suite). Creates two API keys: one bound to Admin, one bound to a freshly created role with `canUpdateAllSettings: false` and no settings flags. Asserts the ROLE category (from #23613) is present in the admin catalog and absent from the restricted one, while the restricted key still sees DATABASE_CRUD read tools, proving it is gating rather than a broken catalog. This locks the provider `isAvailable` contract at the real HTTP boundary, which the unit mocks cannot. ## Testing - `npx jest src/engine/api/mcp` — 43 tests pass - `test/integration/ai/suites` — 4 suites, 24 tests pass locally against a reset DB - `npx nx typecheck twenty-server` clean; oxlint and oxfmt clean on the touched files --- _Generated by [Claude Code](https://claude.ai/code/session_0131sLKVsRuaDoaKCxFM8g4Z)_ <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23630?utm_source=github" rel="nofollow noreferrer noopener" target="_blank">``<img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</a> |
||
|
|
f74f71785e |
i18n - translations (#23635)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
5d88cf7f2f |
feat(server): add role management tools for AI chat (#23613)
Adds role management tools to the AI chat so the agent can create and configure roles, including row-level permissions. ## What A `RoleToolProvider` in `tool-provider/providers/`, mirroring `webhook-tool.provider.ts`, registered in `tool-provider.module.ts` and gated behind `PermissionFlagType.ROLES` via `PermissionsService.checkRolesPermissions` (same pattern as the VIEWS/WORKFLOWS gating). Tools: - `list_roles` — global record permissions, settings access, per-object overrides, permission flags, assignability; optionally includes row-level rules - `create_role`, `update_role`, `delete_role` - `assign_role_to_workspace_member` — via `UserRoleService.assignRoleToManyUserWorkspace`, which goes through role-target - `upsert_object_permissions` — per-object overrides, e.g. read-only on a given object - `upsert_row_level_permission_rules` — reuses `RowLevelPermissionPredicateService.upsertRowLevelPermissionPredicates` and the predicate-group service, so the agent can express rules like "members with this role only see records where the owner field matches the current user" (a predicate with `workspaceMemberFieldMetadataId` pointing at the workspaceMember `id` field, resolved to the current user at query time) Everything routes through the existing role services and DTOs (`RoleService`, `ObjectPermissionService`, `UserRoleService`, the row-level predicate services) rather than reimplementing them. A new `ToolCategory.ROLE` is added to `twenty-shared`, along with its label in the exhaustive switch in `build-tool-catalog-section.util.ts`. ## Safeguards - Any mutation on a role with `isEditable: false` is rejected. That covers the Admin role, which is created non-editable, and matches what Settings blocks. - Deleting the role the caller is currently acting under is rejected, since deletion would rebind them to the workspace default role. - Setting `canUpdateAllSettings: false` on the caller's own role is rejected unless that role keeps an explicit ROLES permission flag. - Changing your own role via `assign_role_to_workspace_member` is rejected, checked both by workspace member id and by resolved user workspace id. The tool-layer checks are deliberate pre-checks: the migration validators and services enforce the same rules downstream (`validate-role-is-editable.util.ts`, default-role deletion, last-admin unassignment, write-without-read consistency), but catching them early gives the model a named, actionable message instead of a build failure report. Where the deeper layer does reject, `formatValidationErrors` expands the migration exception so the underlying per-entity errors reach the model rather than a generic summary. Worth flagging for reviewers: the self-lockout protection currently lives only at the tool layer. A human admin can still strip settings access from their own role through Settings/GraphQL. Closing that would mean changing `RoleService`/`UserRoleService` behavior for the human path, which felt like a separate decision than what this change is scoped to. ## Notes `ToolCategory.ROLE` is intentionally left out of `WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES`, so workflow agents don't get these tools; only the chat surface and the MCP/tool-index paths that share the registry do. ## Testing - 24 unit tests in `providers/__tests__/role-tool.provider.spec.ts`, covering permission gating, descriptor exposure, each safeguard, the N+1-free list path, and validation-error surfacing - 333 tests pass across the tool-provider, role, object-permission and ai suites - `npx nx lint:diff-with-main twenty-server` and `npx nx typecheck twenty-server` are clean --- _Generated by [Claude Code](https://claude.ai/code/session_0131sLKVsRuaDoaKCxFM8g4Z)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23613?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
a9084604b4 |
Use the fast model for the onboarding setup chat (#23586)
The workspace setup chat ran on the smart model. The hidden kickoff turn enqueued its job without a `modelId`, and the frontend sends none unless the user picks one, so every turn fell through to `modelId ?? workspace.smartModel` in `chat-execution.service.ts`. Two halves, since the kickoff is server-initiated and the frontend never sends it: - `startHiddenKickoffStream` takes a `modelId` and the setup chat passes `workspace.fastModel`. - `useAgentChatModelId` requests `workspace.fastModel` on the setup page, so user turns follow. Everywhere else it still sends nothing and the server fallback is unchanged. `workspace.fastModel` defaults to the `default-fast-model` sentinel, so the model still resolves through the registry and stays admin-overridable. An explicit pick from the model picker still wins. |
||
|
|
a5680d1732 |
chore: sync AI model catalog from models.dev (#23615)
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> |
||
|
|
510150a016 |
chore: bump version to 2.27.0 (#23604)
## 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 - Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same version ## Checklist - [ ] Verify version constants are correct - [ ] Verify npm package versions match <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23604?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com> |
||
|
|
830404b215 |
fix: Decrypt encrypted front component variables (#23494)
## Summary Fixes #23492 Fixes front-component application variables returning their encrypted at-rest value instead of their configured plaintext value. Non-secret application variables (`isSecret: false`) are now decrypted server-side before being injected into the front-component environment. Secret variables remain excluded and are never decrypted or exposed to the browser. ## Root cause The front-component resolver filtered secret application variables correctly, but forwarded the cached `encryptedValue` directly. As a result, `getApplicationVariable()` returned an `enc:v2:...` envelope rather than the configured value. ## Changes - Decrypt recognized versioned envelopes for non-secret application variables. - Preserve empty and legacy/plain values unchanged for backwards compatibility. - Add `SecretEncryptionModule` to the front-component module. - Add coverage for: - decrypting public variables; - retaining plaintext compatibility; - excluding secret variables without attempting decryption. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23494?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: prastoin <paul@twenty.com> |
||
|
|
a99e62fbca |
Add application job enqueue limits guard on all message queue drivers (#23570)
## Context
Applications trigger logic functions from several paths (cron, database
events, HTTP routes, install/connect hooks). Without a cap, a single app
can flood the `logic-function-queue` and starve it. This adds enqueue
limits scoped to that queue, mirroring the existing per-application API
rate limiting.
## What changed
`JobEnqueueThrottlerGuard` reuses the `ThrottlerService` token bucket
(same primitive as the API rate limiter) with two tiers:
- **Per application installation**
(`enqueue:throttler:application:{applicationId}`) - lower ceiling,
`APPLICATION_JOB_ENQUEUE_RATE_LIMITING_LIMIT` (default 500).
- **Per application registration**
(`enqueue:throttler:application-registration:{applicationRegistrationId}`)
- higher ceiling shared across all workspaces that installed the same
app, `APPLICATION_REGISTRATION_JOB_ENQUEUE_RATE_LIMITING_LIMIT` (default
2000).
Both share `APPLICATION_JOB_ENQUEUE_RATE_LIMITING_TTL_IN_MS` (default
60s). Both buckets are checked before either is debited, so a rejection
on one tier never burns quota on the other.
**Per-queue guarding.** A `ThrottledMessageQueueDriver` decorator wraps
the concrete driver (BullMQ or Sync) in the `QUEUE_DRIVER` provider and
passes the `queueName` to the guard. The guard only acts on queues in
`GUARDED_ENQUEUE_QUEUES` (currently just `logic-function-queue`); every
other queue is untouched.
**Required application context.** The guard reads a dedicated
`applicationJobEnqueueContextStorage` (AsyncLocalStorage) carrying `{
applicationId, applicationRegistrationId }`, and throws if a
guarded-queue enqueue runs without both. Every logic-function-queue
enqueue site wraps its `add`/`bulkAdd` in
`withApplicationJobEnqueueContext`:
- cron trigger
- database-event trigger (groups logic functions by application, one
batch per application)
- server route trigger
- application post-install hook
- connection-provider on-connect hook
When the limit is reached the guard records a
`JobEnqueueApplicationRateLimited` metric and throws
`ThrottlerException` (mapped to 429 by the existing handlers).
## Files
- `message-queue/guards/job-enqueue-throttler.guard.ts` - the guard
(new)
- `message-queue/storage/application-job-enqueue-context.storage.ts` -
dedicated enqueue context (new)
- `message-queue/constants/guarded-enqueue-queues.constant.ts` -
guarded-queue set (new)
- `message-queue/drivers/throttled-message-queue.driver.ts` - decorator
driver wrapping any driver (new)
- `message-queue/message-queue-core.module.ts` - wires the guard into
the driver provider
- `twenty-config/config-variables.ts` - three tunable `RATE_LIMITING`
config variables
- `metrics/types/metrics-keys.type.ts` -
`JobEnqueueApplicationRateLimited` key
- the 5 enqueue sites above - inject the enqueue context
## Notes / trade-offs
- A logic function whose application has no `applicationRegistrationId`
is skipped at the trigger paths (the install hook throws), matching how
the server route trigger already treats "not linked to a registration".
- `addCron` is left ungated (idempotent upsert).
- Default limits are placeholders and tunable per instance.
## Testing
- `JobEnqueueThrottlerGuard` unit tests (7 cases): non-guarded queue
skip, throw on missing/partial context, two-tier throttling with
distinct limits, per-item token consumption on bulk, no partial debit
when either tier is exhausted.
- Updated `connection-provider-oauth-flow.service.spec.ts` for the new
cache key.
- `npx nx typecheck twenty-server` passes; oxlint + oxfmt clean on
changed files.
|
||
|
|
bd65dbd47a |
Cache metadata lookups during ORM result formatting (#23593)
## Context Production profiling identified `formatResult` as a recurring CPU hotspot on read paths, especially for list queries and nested relations. The formatter receives one metadata snapshot for the complete result, but previously rebuilt metadata-derived lookup structures for every record. For each record, including recursively formatted relation records, it rebuilt or rescanned: - field name and join-column maps - composite field property maps - required composite properties - date and date-time field collections This metadata does not change while one result is being formatted, so the repeated work scaled with the number of records without changing the output. It also created short-lived allocations that added GC pressure on busy server pods. ## What changed - Create a private cache for each top-level `formatResult` invocation. - Lazily derive formatter metadata once per object metadata ID. - Reuse it across records in an array and recursively formatted relations. - Precompute required composite property names and date-time field metadata. - Remove the DATE post-processing pass, which assigned each value back to itself. - Keep the exported `formatResult` signature unchanged. The cache is discarded when the formatting call returns. ## Why use a call-scoped cache The derived structures are valid for the metadata maps passed to one `formatResult` call. Keeping the cache local provides reuse for the complete result batch without adding cross-request state, invalidation rules, or another long-lived memory cache. This also preserves existing callers and keeps recursive implementation details private. ## Safety - Formatting behavior and returned shapes are unchanged. - Nested relation formatting still resolves metadata for each target object type. - Composite null and default handling, and DATE_TIME validation, are unchanged. - Metadata is recomputed for every top-level invocation, so a later request cannot reuse data derived from an older metadata snapshot. - No Redis, workspace-cache, database, or public API behavior changes. ## Expected impact Metadata preparation now scales with the number of object types in a result instead of the number of records. The largest benefit is expected for list queries and nested relations, with lower CPU usage and fewer short-lived allocations. This is a targeted result-formatting optimization. It does not address every source of API tail latency or retained cache memory. ## Validation - Added a nested-relation regression test that verifies unchanged output. - The test verifies metadata resolution is bounded per object type within one invocation and recomputed for a separate invocation. - Focused formatter Jest suite. - Existing chart relation-label Jest suite, 10 tests. - Type-aware Oxlint. - Oxfmt. - `yarn nx typecheck twenty-server`. |
||
|
|
bc0ec6b104 |
Maintain INDEX view system side effects on deactivated views (#23590)
# Introduction Follow-up on https://github.com/twentyhq/twenty/pull/23585#discussion_r3683701472. Two INDEX view side-effect handlers bailed out when the view had `isActive: false`. This drops those gates. # Why `isActive: false` on a view has exactly one writer: the delete path, when `isCallerOverridingEntity` is true (`from-delete-view-input-to-flat-view-or-throw.util.ts`, `view.service.ts`). It is not in `FLAT_VIEW_EDITABLE_PROPERTIES`, so nothing else sets it. So the flag means "the workspace deleted an engine-owned view, and since the engine owns the row we deactivate instead of hard-deleting". It is a workspace override of a row we still own, not a signal the row is gone (that is `deletedAt`). Which makes it precisely the state where the engine must keep maintaining its own rows: the row still exists, still belongs to the engine, and is expected to be consistent whenever the override is lifted. Skipping the side effect instead left the view permanently incomplete, with no repair path. The gates were inherited from the candidate-view scan removed in the same commit as these handlers were introduced (`compute-flat-view-fields-from-fields-widgets.util.ts`, #23081). There, `!view.isActive` filtered which of many views were candidates. Transplanted into handlers that resolve *the* one deterministic engine-owned INDEX view identifier, the same predicate stops meaning "is this a candidate" and starts meaning "silently skip the system side effect". # Changes - `fieldIndexViewFieldOnCreate`: create the INDEX view field even when the view is deactivated. - `objectIndexViewLabelIdentifierOnUpdate`: reconcile the label identifier view field even when the view is deactivated. `deletedAt` gates are unchanged in both. The `should noop when the object has no active INDEX view` spec case is inverted accordingly. # Follow-up Both handlers also drop inactive view fields when computing positions, while `FlatViewFieldValidatorService` builds its `otherFlatViewFields` with no `isActive` filter. An inactive view field below all active ones would make a handler emit a label identifier position the validator then rejects. Unreachable today (view fields are only ever soft-deleted, never deactivated), so left out of this PR. |
||
|
|
08891db8be |
Create INDEX view fields visible on field creation (#23585)
# Introduction Follow-up on https://github.com/twentyhq/twenty/pull/23081. Creating a field on an existing object added its column to the object's index view hidden, while creating the same field alongside its object added it visible. Same field, different outcome depending on when it was created. Both now create it visible. Hiding the column stays one click away, and that choice is kept as a user override on top of the engine default. Applies to fields created from now on. Nothing is backfilled: an already hidden column cannot be told apart from one a user hid on purpose. `objectSystemFieldsAndIndexViewOnCreate` and the 2-26 reconcile command are untouched. |
||
|
|
5848c9bd30 |
Display object, field and view links as chips in the AI chat (#23573)
<img width="3840" height="1876" alt="CleanShot 2026-07-30 at 15 37 46@2x" src="https://github.com/user-attachments/assets/9fe178b9-c2fa-4b05-9c9d-0cdc80270b67" /> https://github.com/user-attachments/assets/34c4e486-c462-4300-ae98-da99f614f069 The AI chat already renders record chips from a `[[record:...]]` marker the model writes in its prose, but naming an object, field or view produced plain text. This adds three sibling markers so those render as chips too, as in the [Figma design](https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=104416-116261). - `[[object:<nameSingular>:<label>[[/object]]` links to the record index page. It is name-keyed rather than id-keyed so an object the assistant only *proposes* to create still renders as a chip, just without a link. - `[[field:<id>:<label>[[/field]]` links to the field's settings page, gated on the `DATA_MODEL` permission. - `[[view:<id>:<label>[[/view]]` links to the object index page for that view. Field and view ids must come from a tool, so an unresolvable one falls back to plain text rather than a chip that goes nowhere. The record-only parser becomes one scan over all four kinds. Alternative order is load-bearing: `[[view:<uuid>:` is shaped exactly like the legacy prefix-less record marker, so metadata kinds are tried first and only records keep the legacy `]]` terminator. Server side is prompt-only. The metadata and view tools return bare objects rather than `ToolOutput`, so there is nowhere to hang a structured reference array without wrapping every factory, and the names and ids the markers need are already in those results verbatim. Also fixes a pre-existing issue in `LazyMarkdownRenderer`: its `components` map was rebuilt on every render, and react-markdown uses each entry as the JSX element type, so every node remounted on every streamed chunk. Harmless before, expensive once the model is told to chip every metadata name it writes. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23573?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
3689e89440 |
Optimize upgrade status gauges with count-only queries (#23574)
## Context Upgrade health metrics and the admin upgrade-status query currently share `getInstanceAndAllWorkspacesStatus`. On a cache hit, that method reads the cached behind/failed workspace IDs, then hydrates every workspace name with an individual `CoreEntityCacheService.get` call. This is useful for the admin response, but the gauges only need the number of workspaces in each state. As the number of behind or failed workspaces grows, every gauge refresh therefore creates a fan-out of entity-cache lookups. Those lookups can include Redis validation and response deserialization. Production profiling of slow upgrade-status requests showed `loadWorkspaceNamesById` and `CoreEntityCacheService.get` on the hot path, so this PR removes that unnecessary repeated work. ## What changed - Added a count-only upgrade-status method for metric collection. - Updated upgrade gauges to use cached ID counts without loading workspace names. - Replaced the admin path's per-workspace cache lookups with one repository query selecting only `id` and `displayName`. - Removed the upgrade module's now-unused core-entity-cache dependency. ## Why this improves performance ### Metrics path Before: - Read the cached upgrade-status IDs. - Run one entity-cache lookup per behind/failed workspace. - Discard the hydrated names and only use the array lengths. After: - Read the same cached upgrade-status IDs. - Derive counts directly from those IDs. - Perform no workspace-name lookup. **This changes metric collection from a fixed set of status-cache calls plus `N` entity-cache calls to only the fixed status-cache calls. The amount of ID data still scales with the number of affected workspaces, but the Redis/client round-trip fan-out does not.** ### Admin path The admin response still needs workspace names. It now loads them with one primary-key `IN` query instead of `N` independent entity-cache calls. This reduces round trips and repeated cache validation while preserving the response shape. ## Safety and behavior preservation - Upgrade-status cache keys, TTLs and invalidation behavior are unchanged. - A missing cache marker still triggers the existing full status refresh. - Metrics names and values are unchanged. - The admin GraphQL response is unchanged. - Cached workspace IDs missing from the database still produce a `null` name, matching the previous behavior. - The batched query runs only for callers that request the detailed admin payload, not for metric collection. ## Expected impact - Remove recurring per-workspace cache fan-out from every API process collecting upgrade gauges. - Reduce Redis client work, response deserialization and event-loop pressure during metric collection. - Reduce latency for detailed admin upgrade-status requests. This targets one profiled source of tail latency. It is not expected to eliminate all API p99 outliers, which also have independent causes. ## Validation - 36 focused upgrade-status and gauge tests pass. - `yarn nx typecheck twenty-server` passes. - Oxlint passes with zero warnings and errors. - Oxfmt and `git diff --check` pass. |
||
|
|
ad271ee639 |
Add connected account handle/provider index (#23580)
## Context
Google messaging webhook notifications resolve connected accounts with
an equality lookup on both `handle` and `provider`:
```ts
connectedAccountRepository.find({
where: {
handle: decodedData.emailAddress,
provider: ConnectedAccountProvider.GOOGLE,
},
});
```
This lookup runs for incoming Gmail notifications, but
`connectedAccount` currently has no index matching either predicate. As
the table grows, PostgreSQL has to inspect unrelated connected-account
rows for each notification. Under sustained webhook traffic, that adds
avoidable database work and keeps database connections occupied longer.
## What changed
- Add a composite B-tree index on `connectedAccount(handle, provider)`.
- Register the index in the TypeORM entity metadata.
- Add an idempotent 2.26 fast instance command to create the index for
existing installations and remove it on rollback.
The webhook handler and query behavior remain unchanged.
## Why this index
- Both query predicates are equality conditions, so the composite index
supports a targeted lookup.
- `handle` is first because it is the more selective value and also
makes the index useful for handle-prefixed lookups.
- The index is intentionally non-unique. The same provider handle may
legitimately belong to connected accounts in different workspaces, and
this change must not introduce a new data constraint.
- Connected accounts are read by webhooks much more frequently than
their handle or provider changes, so index maintenance overhead should
remain small.
## Expected impact
Webhook account resolution should use an index lookup instead of
scanning the connected-account table. This reduces cumulative PostgreSQL
work and connection occupancy on the Gmail notification path.
This is a targeted database optimization. It should reduce pressure
generated by this high-frequency query, but it is not expected to
resolve every source of API tail latency by itself.
## Safety and rollout
- The instance command uses `CREATE INDEX IF NOT EXISTS` and `DROP INDEX
IF EXISTS`.
- No uniqueness or application behavior changes are introduced.
- Existing rows require no data backfill.
- The index adds bounded storage and write-maintenance overhead.
## Validation
- `yarn nx typecheck twenty-server`
- Type-aware Oxlint on the changed files
- Oxfmt on the changed files
- `git diff --check`
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23580?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
fc6a95a37f |
Throttle local cache expiration sweeps (#23579)
## Context The workspace cache and core entity cache keep bounded in-process maps. Entries that have not been read for 30 minutes are removed by an expiration sweep. Before this PR, every cache read synchronously walked the entire local cache, including every stored version, before performing the actual lookup. The cost therefore grew with the number of cached entries even when there was nothing to expire. Both caches are used on common server request paths, so these repeated full-map scans add unnecessary CPU work and short-lived allocations, which can contribute to event-loop and garbage-collection pressure under load. ## What changed - Run each cache's expiration sweep at most once per minute. - Keep the existing expiration logic unchanged and use one captured timestamp for the complete sweep. - Cover both cache services with tests proving that repeated reads within the interval trigger one sweep and that sweeping resumes after the interval. Normal cache reads now pay only for a timestamp check and branch. The full `O(cache size)` scan runs at most once per minute per process. ## Safety This does not change cache freshness: - The 100 ms local freshness window and Redis hash validation still run as before. - Explicit cache invalidation is unchanged. - The 30-minute inactivity threshold is unchanged. - LRU eviction still runs when entries are inserted. - Existing local-cache size limits remain unchanged. An unused entry can remain in memory for at most one additional minute before the next sweep. This may marginally increase average retained memory, but it cannot cause unbounded growth or allow stale data to bypass the existing hash validation. ## Expected impact This removes a cache-size-dependent operation from a high-frequency path. The expected benefit is lower CPU and allocation overhead, less garbage-collection pressure, and improved tail latency when local caches are populated. This is intentionally a narrow optimization. It does not claim to address every source of API tail latency. ## Validation - `yarn nx typecheck twenty-server` - Type-aware Oxlint on the changed files - Oxfmt on the changed files - Targeted workspace-cache and core-entity-cache Jest suites, 23 tests passing |
||
|
|
65155fe50c |
feat(apps): add enqueueJob to run a logic function on the workers (#23527)
Closes twentyhq/core-team-issues#2742 A logic function run is capped by its own `timeoutSeconds` (900s max), so anything that can't finish in one run — a full re-sync, a per-record fan-out, a rate-limited third-party API — had no way to continue. This adds a way to hand that work to the workers. ## What it looks like for an app author ```ts import { enqueueJob } from 'twenty-sdk/logic-function'; await enqueueJob({ logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33', payload: { cursor: nextCursor }, retryLimit: 3, priority: 2, delayMs: 60_000, }); ``` The target runs in its own process with its own timeout budget. The classic shape is a function that enqueues *itself* with the next cursor until there is nothing left. ## Changes **twenty-shared** — `EnqueueJobInput` / `EnqueueJobOptions` / `EnqueueJobResult` in `application`. **twenty-server** — new `application-job` module under `core-modules/application`, following the `application-key-value` pattern: - `enqueueJob` mutation on the metadata API, `@AuthApplication`-scoped - the lookup is scoped to `applicationId` + `workspaceId` — that's the authorization boundary, an app can only enqueue its own logic functions, anything else is `LOGIC_FUNCTION_NOT_FOUND` - pushes a `LogicFunctionTriggerJob` onto the existing `logicFunctionQueue`, so the enqueued run goes through the same executor (and the same execution throttling) as every other trigger - the queued run inherits the caller's `userId`/`userWorkspaceId`, so its app access token carries the same permissions as the function that queued it **Job options** are range-checked via `ResolverValidationPipe`, since the values come from application code and an unbounded delay or retry count would let an app pin work in the shared queue: | Option | Default | Range | |--------|---------|-------| | `retryLimit` | `0` | `0`–`10` | | `priority` | queue default | `1`–`10` (lower first) | | `delayMs` | `0` | `0`–7 days | `retryLimit` defaults to `0` rather than inheriting the server-route path's `3`: retries re-run the whole handler, so opting in should be the author's explicit choice. **twenty-sdk** — `enqueueJob` in `twenty-sdk/logic-function`, same shape as `runAgent`/`kv`. **Docs** — new "Background Jobs" page under Extend → Apps → Logic, plus nav and overview entries. **Generated** — regenerated `twenty-front/src/generated-metadata` and `twenty-client-sdk/src/metadata/generated` for the new mutation. ## Tests - `application-job.service.spec.ts` — 5 unit tests: job options mapping, defaults, acting-user propagation, application-scoped lookup, not-found - `enqueue-job.integration-spec.ts` — 5 integration tests: rejects a non-`APPLICATION_ACCESS` token, enqueues a function the app owns, rejects a function owned by another application, rejects an unknown identifier, rejects out-of-range options All green locally, along with `typecheck` for `twenty-server`/`twenty-sdk` and oxlint/oxfmt on the touched files. ## Notes for review - The target is addressed by `universalIdentifier`, matching `runAgent({ agentUniversalIdentifier })` and `ServerRouteDispatchResult.targetLogicFunctionUniversalIdentifier`. Addressing by `name` would be friendlier, but logic function names aren't validated for uniqueness within an app — happy to add it as a convenience if you'd rather. - `enqueueJob` returns as soon as the job is accepted; it can't return the target's result, since the queue driver's `add` returns void. Documented, with a pointer to the KV store for handing results back. --- _Generated by [Claude Code](https://claude.ai/code/session_01QrYvGonS3HMdeuMAVjs5hR)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23527?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
6447b7f935 |
feat(workflow): make the flow atom authoritative for version content, read core behind a flag (#23499)
## What
The frontend half of the workflow-version read switch, plus the small
server query it consumes. Two ideas:
1. **One hook owns where content comes from.**
`useWorkflowVersionContent(workflowVersionId)` returns `{ trigger, steps
}` from the workspace record when `IS_WORKFLOW_VERSION_IN_CORE_ENABLED`
is off (default), and from the new `workflowVersionContent` core query
when on. Switching the source later (core-only, after the column drop)
is a change inside this one hook.
2. **`flowComponentState` becomes authoritative for the builder.** The
canvas, diagram and step output schemas derive from the jotai atom; the
atom is seeded once per version through the hook above; mutations keep
it up to date.
## Why the seeding change is required
Today `WorkflowDiagramEffect` re-seeds the atom from the Apollo record
on **every** `currentVersion` identity change. That has two
consequences:
- Three of the five step/edge hooks (`delete step`, `create edge`,
`delete edge`) never write the atom themselves; they only write the
record and the re-seed papers over it.
- The model breaks the moment content comes from a source mutations do
not write (i.e. core): the stale fetch would be re-applied over every
optimistic edit, and your just-added step would vanish from the canvas.
So the atom is now seeded **once per version**, and
`useUpdateWorkflowVersionCache` applies the mutation's
`stepsDiff`/`triggerDiff` to the atom directly. All five step/edge hooks
get that through their existing call, which closes the three-hook gap in
one move. The step-update, trigger and tidy-up hooks write the atom too.
The record-cache writes are all kept while `trigger`/`steps` still live
on the record (dropped later with the columns).
## The dead wire, now the refresh path
`shouldWorkflowRefetchRequestFamilyState` was set by
`WorkflowSSESubscribeEffect` (reconnect, other-tab create) and
**consumed by nothing**. It is now the external-refresh path: when set,
the builder refetches content and reseeds. Known trade-off: while
connected, another tab's edits no longer live-patch the canvas through
record cache updates (they arrive on reconnect, version switch or
reload). Given concurrent editing of one draft has no conflict handling
anyway, that seemed acceptable; easy to extend the SSE effect to set the
flag on update events if we want live propagation back.
## Untouched by design
- **Run visualizer**: feeds the same atom from the immutable
`workflowRun.state.flow` snapshot; that duality (version content or run
snapshot) is exactly why the atom stays separate from the record store.
- **Version visualizer** (read-only): reseeds on content change, safe
because nothing writes its instance optimistically.
- Peripheral readers of `currentVersion.trigger/steps` (test-workflow
command, headless command enrichment, if-else body, etc.) still read the
record. Correct while dual-writing continues; they move to the content
hook before workspace content writes stop (tracked in the migration
plan).
## Verification
- `nx typecheck` green on both packages; `oxfmt` + `oxlint --type-aware`
green on all 16 changed files
- Front unit tests: 134 suites / 993 tests green (the two hook tests
gained the visualizer instance context their hooks now require)
- New server integration test for `workflowVersionContent`
- **Live click-through pending**: step create/delete/duplicate, edge
create/delete, trigger edit, tidy-up, draft create/discard, activation,
version viewer, run viewer, with the flag off and on. The failure mode
this PR guards against (an edit vanishing from the canvas) does not show
up in typecheck or unit tests.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23499?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
a3beea893d |
Revert the external link confirmation popup for front components (#23567)
Reverts #23270 and #23404. Links in front components navigate natively again, with no confirmation popup and no per-app trusted-origins state in localStorage. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23567?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
e4e1d24731 |
Prevent overlapping workspace cleanup executions (#23522)
## Context The suspended-workspace cleanup is a long-running scheduled job. Under database or cache pressure, BullMQ can consider an execution stalled and start a replacement on another worker while the original execution is still running. Both executions can then enumerate the same suspended workspaces and run destructive cleanup concurrently. This amplifies the initial slowdown: 1. Multiple cleanup transactions target the same workspace data. 2. Transactions wait on each other's locks. 3. Database connections remain occupied while waiting. 4. Other workers and API requests have fewer connections available. There is a second source of unnecessary lock duration in workspace deletion. The deletion transaction currently starts before field metadata is read from the workspace cache. If that lookup is slow, the transaction stays open during an unrelated cache wait. ## What changed ### Prevent overlapping scheduled cleanups - Acquire a non-blocking PostgreSQL advisory lock before listing suspended workspaces. - Skip the execution when another worker already holds the lock. - Keep the lock on one dedicated PostgreSQL session for the full callback. - Release the lock in all normal and error paths. - Discard the database connection if lock acquisition or release has an ambiguous failure, preventing a session that may still own the lock from returning to the pool. - Encapsulate this lifecycle in `PostgresAdvisoryLockService`, exported by `TypeORMModule`, so other coarse-grained jobs can reuse it without handling acquisition and release themselves. ### Shorten the workspace deletion transaction - Read field metadata and build deletion chunks before starting the transaction. - Pass the precomputed chunks into the transactional deletion loop. - Keep the existing deletion order and SQL behavior unchanged. ## Why a PostgreSQL advisory lock The lock needs to coordinate workers running in different pods. A PostgreSQL session advisory lock provides the required behavior: - It is shared across all workers using the same database. - Acquisition is non-blocking, a duplicate execution can exit immediately. - It has no TTL or renewal heartbeat that could expire during the same event-loop stall that caused BullMQ to recover the job. - PostgreSQL automatically releases it when the owning session or process disappears. This is deliberately scoped to `CleanSuspendedWorkspacesJob`. It prevents overlapping scheduled executions, but it is not an exactly-once mechanism or a global mutex around every workspace-deletion entry point. ## Expected impact - Prevent one slow cleanup execution from becoming several concurrent cleanup executions. - Reduce database lock contention and connection-pool pressure during cleanup. - Avoid holding deletion transaction locks while waiting for workspace-cache data. - Reduce cleanup-related API latency bursts without changing normal cleanup semantics. The advisory lock holds one core database connection for the duration of the scheduled cleanup. This is intentional and bounded to the single lock owner. ## Validation - Focused advisory-lock tests cover successful execution, contention, callback failure, and unsafe connection disposal when unlock fails. - Cleanup-job tests cover both the lock-owner and skipped-execution paths. - Workspace-service coverage verifies that field metadata is loaded before the deletion transaction starts. - `yarn nx typecheck twenty-server` - Oxlint, Prettier, and Oxfmt checks on the changed files |
||
|
|
dbd2eac69c |
Let the instance upgrade version reach releases without instance commands (#23552)
Fixes the CI failure on #23520: An upgrade version sequence has to at least contain one instance or one workspace command Workspaces commands do not run for the instance level and aren't triggered automatically Explaining this PR need <img width="1396" height="954" alt="image" src="https://github.com/user-attachments/assets/5455d05b-b286-482a-8914-808f55f3b0bf" /> ``` Upload failed: App requires Twenty server >=2.26.0 but this server is 2.25.0. ``` The server really is 2.26 (`TWENTY_CURRENT_VERSION = '2.26.0'`), but `validateServerCompatibility` resolves the instance version through `UpgradeMigrationService.getInferredVersion()`, which reads the last row in `core.upgradeMigration` with `workspaceId IS NULL AND isInitial = false` and takes the version prefix off its name. Instance commands are the only ones that write a `workspaceId`-null row, and `2-26/` ships none (only three workspace commands), so the highest instance command in the tree is still `2-25-instance-command-fast-1785230296000-add-is-hidden-to-agent-message`. A fully migrated 2.26 server infers 2.25.0, and any app declaring `engines.twenty: ">=2.26.0"` is unpublishable. Two defects, both of which `getWorkspaceCompletedVersion` already avoids: - **Not sequence-aware.** The workspace path walks the registered sequence and only credits a version once the cursor sits on that version's last step. The instance path just reads the cursor's prefix, so a version contributing zero instance commands is unreachable. - **Not status-aware.** `getLastAttemptedInstanceCommand` filters on `attempt = MAX(attempt)` but not on status, so a *failed* 2.25 command still made the server report 2.25.0. ## What changed `UpgradeStatusService` gains `getInstanceCompletedVersion()`, the instance-scope mirror of `getWorkspaceCompletedVersion`. It walks the sequence filtered to instance steps, requires the cursor to sit on the last instance step of its version *and* be `completed`, then advances through any later supported version that declares no instance command at all. The version-skipping rule is the part that unblocks 2.26: a release with no instance-level work has nothing for the cursor to land on, so it is reached as soon as the last version that does have instance commands is done. A version whose instance command exists but has not run still holds the cursor back. - `validateServerCompatibility` calls the new method; `UpgradeMigrationService` is no longer a dependency of `ApplicationVersionValidationService`. - `getInstanceStatus` reports it as `inferredVersion`, so the upgrade gauge metric and `upgrade:status` CLI stop showing 2.25.0 on a 2.26 server. - `getInferredVersion` is deleted. Its one remaining caller passed a command name, which is just `extractVersionFromCommandName`. - Cursor resolution is extracted to `resolve-completed-version-from-cursor.util`, now shared by both scopes; the skip rule lives in `advance-through-versions-without-instance-commands.util`. The asymmetry between the two scopes is intentional and stays: instance commands record a row per workspace as well, so workspace cursors land on both command kinds and never had this gap. ## Testing - `npx nx typecheck twenty-server` clean, `npx nx lint:diff-with-main twenty-server` clean. - 294 unit tests pass across the upgrade and application modules, including 7 new ones for `getInstanceCompletedVersion`. Two pin the boundary: a trailing workspace-only version is reached, a trailing version whose instance command has not run is not. - The fixture in `upgrade-status.service.spec.ts` used `1.21.0`/`1.22.0`/`1.23.0`, which are real entries in `TWENTY_PREVIOUS_VERSIONS`. With the skip rule in place that sequence read as "every version from 2.0 onward has no instance commands" and walked to the end, so the fixture is renumbered to `0.2x.0` to keep those tests on cursor resolution alone. - `failing-app-installation-workspace-version.integration-spec.ts` already carried a comment describing this bug as a hazard it worked around. The workaround still holds, but integration tests were not run here (no DB in this session) — the stale comment is updated. #23520 stays at `>=2.26.0` and unblocks once this lands. --- _Generated by [Claude Code](https://claude.ai/code/session_012SvBG1BB3jTaZs6LA2Wi9R)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23552?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
d5d0726216 |
i18n - translations (#23563)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23563?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
38ad13655c |
Auto-start the workspace setup chat with a data model proposal (#23437)
https://github.com/user-attachments/assets/d447372b-c1b4-4c95-bac9-a8f8efa7d4a1 When the workspace creator lands on `/workspace-setup` after onboarding, the AI chat now starts on its own: an invisible first message, built server-side from the company enrichment collected in #23199, asks the assistant to propose a data model tailored to the business. The proposal streams in; the user never sees the prompt. - New `startWorkspaceSetupChat` mutation: creator only, gated on `IS_ONBOARDING_AI_CHAT_ENABLED`, available models and credits. Idempotent per user and workspace via a `keyValuePair` pointing at the thread, so a reload or a second tab joins the same conversation instead of starting a new one. - The thread holds exactly one hidden `USER` message combining the company context and the setup instructions, which keeps the one-hidden-message-per-thread index from #23199 satisfied. It goes through a dedicated streaming path that never queues, so the prompt cannot resurface as a visible message. - The assistant only proposes. It creates nothing until the user approves, then builds the model with the `metadata-building` skill. Objects and fields get English names with labels in the user's language, and the conversation continues in that language. - With no enrichment (consumer email domain, or the integration disabled) the kickoff still runs, and the assistant asks one short question about the business before proposing. - `findLatestSentUserMessage` no longer filters out hidden messages, so a failed kickoff turn stays retryable, and the no-message chat error surface now offers retry for stream errors. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23437?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
079e9b8e56 |
feat(dashboard): extend number format option to bar, line and pie charts (#23505)
https://discord.com/channels/1130383047699738754/1509604545381142649 Extends the Format option (Short/Full) added for the Number widget in #21521 to bar, line and pie charts. Format controls the numbers printed on the chart face: data labels and the pie center metric. Axis ticks stay abbreviated and tooltips always show the full value. Defaults to Short, so existing charts render unchanged. Server: nullable `numberFormat` on the bar/line/pie configuration DTOs, exposed in the dashboard AI tool schema. No migration, configuration is jsonb. Deferred: - The Format row has no visible effect while data labels are off, since tooltips are always full. - Number widget format defaults differ by field type (CURRENCY defaults to Short, NUMBER to Full). Pre-existing, untouched here. https://github.com/user-attachments/assets/0778f08a-6681-4e7a-8716-fb3026d1e01f <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23505?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
0b335d15b3 |
Refresh billing state after ending trial period (#23534)
Fixes #23530 After adding a credit card in the billing prompt, the credits section and subscription details stayed stale until a full page refresh. The `endSubscriptionTrialPeriod` mutation only returned `status` and `hasPaymentMethod`, and the frontend hook only patched the subscription status into the workspace state. The credits query was never refetched, so granted credits kept showing trial values, and `currentPeriodEnd` (renewal date) and `billingCustomer.hasPaymentMethod` stayed outdated. The backend already syncs everything to the database synchronously before the mutation returns, so fresh data was available, just never fetched. Changes: - `BillingEndTrialPeriodDTO` now includes nullable `currentBillingSubscription` and `billingSubscriptions`, returned by the resolver on success, mirroring the other billing update mutations (`switchSubscriptionInterval`, etc.) - `useEndSubscriptionTrialPeriod` applies the full billing update via `useApplyCurrentWorkspaceBillingUpdate` (falling back to the previous status-only patch), marks the billing customer as having a payment method, and refetches `GetResourceCreditUsage` so the credits section updates for any active observer This covers all entry points that end the trial: the billing page card modal, the trial banner, the AI chat banner, and the return from the Stripe portal. --- _Generated by [Claude Code](https://claude.ai/code/session_01W1J7cW2MhaqXGfdjvHeFoX)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23534?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
8b707c5131 |
i18n - translations (#23547)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23547?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
4ff9cba76d |
fix(server): stop the global catch-all filter from shadowing typed GraphQL exception filters (#23508)
## Context Sentry [TWENTY-SERVER-60Y](https://twenty-v7.sentry.io/issues/6633503406) ("Permission Denied: Entity performing the request does not have permission") is still firing at full rate on `v2.25.0`: ~10.8k events in the last 7 days, 24k total. #23104 tried to fix it by registering `PermissionsGraphqlApiExceptionFilter` globally via `APP_FILTER`. That registration is correct but **inert in production**, and the integration test added alongside it passes for a reason unrelated to prod behaviour. ## Root cause `main.ts` registered a catch-all filter after bootstrap: ```ts app.useGlobalFilters(new UnhandledExceptionFilter()); ``` Nest builds each resolver's filter list as `[...global, ...class, ...method]`, reverses it, and selects **exactly one** matching filter — there is no chaining. `APP_FILTER` providers are collected during module scan; `useGlobalFilters` appends after that, so the catch-all ended up at the head of the list: ``` 1. UnhandledExceptionFilter @Catch() <- matches everything, wins 2. PermissionsGraphqlApiExceptionFilter <- never reached 3. BillingGraphqlApiExceptionFilter <- never reached ``` On a GraphQL host `UnhandledExceptionFilter` then no-ops: `host.switchToHttp().getResponse()` returns the GraphQL args object, `response.header` is undefined, so it hits `return;`. Nest treats a falsy return as unhandled and rethrows the original `PermissionsException`, which reaches the Yoga error hook as a non-`BaseGraphQLError`, is serialized `INTERNAL_SERVER_ERROR`, and is reported by `shouldCaptureException`. The 28 resolvers carrying `@UseFilters(PermissionsGraphqlApiExceptionFilter)` were unaffected — method-level filters are evaluated before globals. Only the resolvers relying on the global registration leaked, which is exactly the set showing up in Sentry (`findOneApplication`, `uploadFilesFieldFileByUniversalIdentifier`, `UpdatePageLayoutWithTabsAndWidgets`, ...). Two other global filters were shadowed the same way and have never run: `BillingGraphqlApiExceptionFilter` and `FlatEntityMapsGraphqlApiExceptionFilter`. ## Why the existing test did not catch it `test/integration/utils/create-app.ts` builds the app from `AppModule` directly and never executes `main.ts`, so `useGlobalFilters` does not exist in the test process. It registered `MockedUnhandledExceptionFilter` as an `APP_FILTER` on the root testing module, which is collected *first* and therefore evaluated *last* — the exact inverse of production precedence. The `findOneApplication` denial test passed while the same query kept reporting to Sentry. ## Fix Register `UnhandledExceptionFilter` through `APP_FILTER` on `AppModule`. Root-module providers are scanned first, so it is collected first and evaluated last. The filter stays global, stays catch-all, and keeps its CORS-header role for HTTP; it simply no longer cuts in front of the typed filters. Un-shadowing the other two global filters means they now actually run, so `FileStorageExceptionFilter` and `FlatEntityMapsGraphqlApiExceptionFilter` get the `host.getType() !== 'graphql'` rethrow that `Billing` and `Permissions` already had. Without it they would start throwing GraphQL error objects into the REST pipeline. `MockedUnhandledExceptionFilter` is removed: `AppModule` now supplies the real filter in the same position, so the mock was dead weight. ## Test Verified against a real server (not the integration harness), calling the exact document from Sentry event `8d19eb7c` as a member with no permission flags: ``` query ($v1:UUID){findOneApplication(id:$v1){applicationVariables{key,value}}} ``` | | response code | exceptions captured | |---|---|---| | before | `INTERNAL_SERVER_ERROR` | 1 | | after | `FORBIDDEN` | 0 | Capture count measured through the console exception-handler driver, i.e. the same `captureExceptions` call site that is the Sentry driver in production. New unit spec `src/filters/__tests__/unhandled-exception.filter.spec.ts` boots a Nest + Yoga app both ways: it asserts `FORBIDDEN` with the `APP_FILTER` registration, and pins the shadowing behaviour of `app.useGlobalFilters` so the pattern cannot come back unnoticed. `granular-settings-permissions.integration-spec.ts` passes (10/10). Note it also passes *without* this fix — the harness cannot observe bootstrap-only configuration, which is the underlying reason #23104 shipped green. Closing that gap properly means sharing the post-`create` bootstrap between `main.ts` and `create-app.ts`; left as a follow-up. `file-storage-exception-filter.spec.ts` extended with a non-GraphQL host case. ## CI follow-up `failing-file-by-id-download.integration-spec.ts` snapshots were updated. That REST endpoint's 403 body changed in tests from `{}` to `{"statusCode":403,"error":"Forbidden","message":"Forbidden resource"}`. The old `{}` was an artifact of the mock: `MockedUnhandledExceptionFilter` rethrew, the exception escaped Nest's handler into Express's default error handler, and supertest saw an empty body. Production has always run the real `UnhandledExceptionFilter`, which writes `response.status(status).json(exception.response)` — the new snapshot. Production HTTP behaviour is unchanged by this PR: no other global filter matches an `HttpException` (the typed ones rethrow outside GraphQL), so the same filter handles it whether it is evaluated first or last. |
||
|
|
a276f3277f |
feat(workflow): pin concrete model on AI agent node creation and exclude interactive tools from workflow runs (#23447)
## Context The AI Agent workflow node's model dropdown could show a model that was not the one used at run time (e.g. the node displayed "Claude Haiku 4.5" while the run log showed `openai/gpt-5.6-sol`). Root cause: workflow agents were created with `modelId: AUTO_SELECT_SMART_MODEL_ID`. The builder's model `Select` cannot represent that value — auto-select ids are filtered out of the options (`useWorkspaceAiModelAvailability`) and the pinned "default" option remaps its value to the resolved concrete model id (`useAiModelOptions`) — so `Select` silently fell back to `options[0]`, the alphabetically first enabled model. Meanwhile the runtime correctly resolved auto-select to the instance's default smart model. ## What this PR does ### 1. New workflow agents store a concrete model id `WorkflowVersionStepOperationsWorkspaceService` now reads the workspace's `fastModel` setting, expands it through `AiModelRegistryService.getEffectiveModelConfig`, validates it with `validateModelAvailability`, and stores the concrete model id — so the dropdown displays the model that will actually run, and workflow agents default to the cheaper fast tier instead of the smart one. Falls back to `AUTO_SELECT_FAST_MODEL_ID` if the lookup or validation fails (workspace missing, no AI provider configured, model disabled), so node creation never breaks. ### 2. Exclude `search_help_center` and `navigate_app` from workflow agent runs `ActionToolProvider` adds both tools unconditionally, but they only make sense in an interactive chat session (navigation targets the user's browser; help-center search is a support tool). They are now excluded via `WORKFLOW_AGENT_EXCLUDED_TOOL_NAMES` in `AgentAsyncExecutorService`, alongside the existing output-navigation exclusions. Chat agents are unaffected. ## Test coverage - Existing specs for `WorkflowVersionStepOperationsWorkspaceService` and `AgentAsyncExecutorService` updated/passing (new constructor deps mocked). - `nx typecheck twenty-server` and `lint:diff-with-main` pass. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23447?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
c4a79c50c3 |
Install pre-installed apps in a dedicated job after the workspace upgrade cursor is written (#23517)
## Problem Application registrations flagged `isPreInstalled: true` were not installed on newly created workspaces. The call was wired in, but it ran too early. `activateWorkspace` invoked `preInstalledAppsService.installOnWorkspace` from inside `prefillCreatedWorkspaceRecords`, which runs **before** `activateAndInitializeUpgradeState`. The install path validates app/workspace version compatibility: - `ApplicationInstallService.runInstall` reads `engines.twenty` from the app's `package.json` and calls `validateWorkspaceCompatibility` - `ApplicationVersionValidationService.validateWorkspaceCompatibility` resolves the workspace version through `UpgradeStatusService.getWorkspaceCompletedVersion` - that reads the workspace's upgrade-migration cursor, which is only written by `markAsWorkspaceInitial` inside `activateAndInitializeUpgradeState` During creation the workspace has no cursor row yet, so `getWorkspaceCompletedVersion` returns `null`, the install throws `INVALID_WORKSPACE_VERSION`, and the failure is swallowed twice over: `PreInstalledAppsService` logs per-app failures without rethrowing, and `activateWorkspace` wraps the whole call in non-critical error handling. The workspace comes up silently missing its apps. This affects most real apps, since they pin `engines.twenty`: `fireflies`, `last-contact`, `people-data-labs`, `call-recorder`, `postcard`, `self-hosting`, `twenty-partners` (`>=2.23.0`) and `exa`, `real-estate` (`>=2.19.0`). Only apps with no `engines.twenty` installed successfully. The same interaction is already documented in `2-23-workspace-command-1784565137000-upgrade-people-data-labs-application.command.ts`, which works around it with `skipWorkspaceCompatibilityCheck: true`. ## Changes - Added `InstallPreInstalledAppsJob` on the workspace queue, mirroring the existing `InstallOnboardingAppsJob`. - `activateWorkspace` now enqueues that job instead of installing synchronously, so workspace creation no longer blocks on package fetching and manifest application. - The enqueue happens after `activateAndInitializeUpgradeState` writes the upgrade cursor, so the compatibility check has a workspace version to resolve by the time the worker picks the job up. ## Notes Workspaces created before this fix can be repaired with the existing `install-pre-installed-apps` backfill command, which is idempotent. |
||
|
|
e5c6cbcf80 |
Resolve route-trigger workspace from bearer token on bare hosts (#23490)
When a request reaches the `/s` route on a host that names no workspace
(bare `SERVER_URL` on a multiworkspace instance), resolve the workspace
from the bearer token — the same source `/graphql` uses — instead of
failing with `WORKSPACE_NOT_FOUND`. Hosts that do name a workspace keep
host resolution unchanged, and requests without a token are unaffected.
This makes the client SDK's same-site `${apiBase}/s` fallback work on
multiworkspace instances without a configured public domain: app logic
functions calling their own HTTP routes (e.g. call-recorder artifact
import) currently 404 there, because `TWENTY_FUNCTIONS_URL` is injected
empty and the bare server host carries no workspace identity. Cloud
(workspace public origin injected) and single-workspace self-host (host
resolves the default workspace) never hit this path.
Note: this also allows public routes to be reached through a bare host
when a valid token identifies the workspace. It does not change route
authorization; the token is used only for workspace resolution.
|