45cbdef9308f8adfcc7e1c5c53971e4d00a5b59c
5620 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
45cbdef930 |
i18n - translations (#23806)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23806?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> |
||
|
|
29e68a7f87 |
Refactor outbound email content compilation (#23782)
## Integration status This is now the final landing PR for the reviewed editor/email architecture stack. | Order | Pull request | Scope | Status | | --- | --- | --- | --- | | 0 | #23657 | Advanced text editor capability presets | Merged into `main` | | 1 | **This PR** | Outbound email content compilation | Ready to land into `main` | | 2 | #23783 | Clean editor surface seam | Reviewed and merged into this branch | | 3 | #23790 | Shared editor block catalog | Reviewed and merged through #23783 | | 4 | #23791 | Canonical TipTap document persistence | Reviewed and merged through #23790 | The current branch tree contains the complete stack. Merging this PR lands all four follow-up layers. ## Architecture The stack establishes four reusable boundaries: 1. **Outbound compilation** — campaign, workflow, and one-to-one/tool email share one compiler, sanitizer policy, renderer, and plain-text derivation path. 2. **Editor surface profiles** — the generic editor owns rendering mechanics while each consuming surface declares chrome, extensions, and explicit compatibility readers. 3. **Shared block primitives** — sections, columns, HTML, images, buttons, and related commands live in the neutral advanced-editor catalog; email behavior is supplied by email schemas/rendering, not by relocating reusable blocks into an email editor. 4. **Canonical persistence** — Twenty-owned authoring persists complete, versioned TipTap JSON documents. HTML, Markdown, plain text, and BlockNote are projections or explicitly owned legacy boundaries. ## Compatibility boundaries Compatibility remains only where shipped data requires it: - workflow Send Email: versionless TipTap JSON, HTML, and plain text - inline email: HTML - AI instructions: Markdown - record rich text: BlockNote arrays and older Markdown/plain text Campaign is unshipped, so its editor, stored rows, sendability validation, and send-time compilation require the current canonical schema version. AI chat drafts are canonical-only local state; old or malformed drafts are rejected at hydration, and plain-text preprompts are converted at their entry point. ## Outbound compiler details The shared compiler owns: - strict structured email-document parsing - React-email rendering - one cached DOMPurify/JSDOM policy for structured and legacy HTML - plain-text derivation from sanitized HTML - single-pass structured-document binding resolution across text, variable tags, links, images, buttons, and raw HTML Resolved workflow values remain inert, legacy workflow and one-to-one HTML remain supported, and Campaign HTML/plain text come from the same compiled result. ## Verification - all automated standard/security reviews passed on the three merged upper PRs with no unresolved threads - shared TipTap/email codec tests: 20 passing - editor, AI draft, and workflow compatibility tests: 14 passing - campaign validation and compilation tests: 31 passing - full shared suite during development: 223 suites / 1,738 tests passing - twenty-front, twenty-shared, and twenty-server typechecks - changed-file type-aware lint and formatting checks |
||
|
|
76bf3651bb |
Fix stray bracket after AI chat chips (#23798)
Chips in the AI chat sometimes rendered with a leftover `]` after them. The reference marker is bracket-asymmetric: it opens with `[[` and closes with `[[/kind]]`, so a complete reference holds four `[` and only two `]`. The model balances that by writing `…[[/object]]]`, and the parser ended the match exactly at the close tag, leaving the extra bracket as prose next to the chip. The parser now absorbs up to as many surplus `]` as the reference opened with, and accepts an opener with extra `[` so an over-wrapped marker doesn't leak one either. The system prompt also tells the model the marker is complete as written. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23798?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. --> |
||
|
|
59672b71b8 |
Add a book-a-call onboarding step for qualified leads (#23521)
https://github.com/user-attachments/assets/76d5a14e-53bd-4195-963b-bf9bb265c8c1 Large-company signups either self-serve a small plan or drop off at the paywall without sales ever seeing them. This adds an embedded Cal.com booking step to onboarding, shown only to leads worth a call. The step sits between Invite Team and the plan step: the lead has built out a workspace by then, and sales gets a chance before checkout. It is always skippable, and a successful booking advances automatically. Qualification reuses the employee count from the People Data Labs enrichment added in #23199. `ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT` sets the bar; leaving it unset means the step never appears. `CALENDAR_BOOKING_PAGE_ID` must also be configured, so the step can never strand someone on an empty embed. Enrichment is no longer gated on `IS_ONBOARDING_AI_CHAT_ENABLED`, since the book-a-call step is now a second consumer of it. `PEOPLE_DATA_LABS_API_KEY` remains the instance-level switch. The existing `/book-call` page is reused: it moves into the onboarding shell and its footer switches between Skip (as a step) and the back link (when reached from the plan page). <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23521?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. --> |
||
|
|
1d755983ff |
Feat/advanced text editor capability presets (#23657)
# Email editor for Compose campaigns
## Short version
Campaign bodies are currently plain rich text. This PR turns the
composer into an email editor: a centered email canvas with section,
column, button, divider, image and raw-HTML blocks, each editable
through a settings side panel, rendered to email-safe HTML per recipient
at send time. Modelled on Resend's Broadcast editor.
**Product**
- Email canvas with page/body styling (background, width, padding,
corner radius, border, text colour, alignment)
- Blocks: section, 2/3 columns, button, divider, raw HTML, images —
insertable from a floating left rail or the slash menu
- A **section is a container whose typography cascades to its
contents**, so one part of an email can have its own look
- Block settings panel focuses whatever you select and shows its
effective values
- Per-recipient variables (`{{firstName}}`, `{{lastName}}`,
`{{fullName}}`, `{{email}}`, `{{personId}}`) usable in text,
button/link/image URLs, image labels and raw HTML
- Image upload by drag-drop, paste or file picker
**Technical**
- Presets now declare **capabilities** instead of surfaces forking the
editor; the UI derives itself from loaded extensions
- Editor behavior lives in `twenty-front`; the versioned email-document
schema and structural traversal live in `twenty-shared`; rendering lives
in `twenty-emails` — HTML is produced server-side per recipient
- Section typography cascade is **resolved at render time**, not left to
CSS: react-email hardcodes `fontSize`/`lineHeight` on every paragraph
and Outlook ignores `inherit`
- Logic vendored from Resend (MIT); all controls rebuilt on `twenty-ui`
+ Linaria
**Also fixes:** the unsubscribe footer was being appended *after*
`</html>`, outside the document, where Gmail strips it — legally
significant since unsubscribe is required.
---
## Detailed version
### Product requirements
**Problem.** The Compose campaign body was a single rich-text field.
Marketing email needs layout — banded sections, columns, call-to-action
buttons, images with links — and it needs that layout to survive
Outlook, which means table-based HTML rather than the divs a text editor
produces. It also needs per-recipient personalisation.
**Reference.** Resend's Broadcast editor, chosen because it solves the
same problem (TipTap authoring → react-email output) and is MIT
licensed.
#### What a user can now do
| Area | Capability |
|---|---|
| Canvas | Email renders as a centered page with its own background,
width, padding, corner radius and border |
| Blocks | Section, 2/3 columns, button, divider, raw HTML, image |
| Insertion | Floating left rail (pointer-first) or the `/` slash menu
(keyboard-first) |
| Sections | Own text colour, font size, line height, letter spacing and
alignment, cascading to everything inside |
| Images | Upload by drag-drop, paste or picker; link URL, alt text,
width, spacing, border |
| Raw HTML | Edited as source in the panel, previewed on the canvas with
scripts neutralised |
| Variables | `{{firstName}}`, `{{lastName}}`, `{{fullName}}`,
`{{email}}`, `{{personId}}` in text, button URLs, link hrefs and raw
HTML |
| Settings panel | Follows selection; shows effective values; opens
automatically when a block is clicked |
#### Deliberate product decisions
- **Variables display as literal placeholders**, not prose labels, so
the syntax is copyable into HTML blocks and button URLs by hand.
- **Sections inherit until they override.** The panel shows what
actually renders rather than blank fields, but writes nothing until you
edit — so changing the body text colour still flows into sections.
- **Headings keep their own scale** inside a styled section; only
colour, family and spacing cascade, otherwise every heading would
collapse to body size.
- **Clicking a block opens its settings**, but only on whole-node
selections, so typing inside a section does not reopen a panel you just
closed.
### Technical strategy
#### 1. Capability presets (the foundation)
Per-surface variation previously worked by **forking**: three separate
`useEditor` call sites with hardcoded extension arrays. Inside the
shared tree there was no variation at all — all five surfaces received a
byte-identical extension list, and presets controlled only sizing,
chrome and serialization format. Adding email blocks that way meant
either leaking section/column nodes into the record rich-text field and
workflow email body, or writing a fourth fork.
Now:
- a preset declares a **capability list** (`basicMarks`, `headings`,
`lists`, `links`, `images`, `campaignVariables`, `slashCommand`,
`blocks`, `mentions`)
- capabilities resolve to extensions through a factory registry
- the UI derives itself from the loaded extensions via
`hasEditorExtension` — no capability list is prop-drilled into a menu,
because the `Editor` already knows what it can do
The acceptance test was collapsing the AI chat fork into an `aiChat`
preset with no visible change to that composer. `campaignBody` is the
only preset opting into the shared `EMAIL_DOCUMENT_CAPABILITIES` today.
Workflow email keeps its current field UI, but can opt into the same
canvas, block settings and image uploader later without adding another
schema or renderer.
#### 2. Schema / renderer split
The hard constraint: **our HTML is produced server-side, per recipient,
at send time**, because variables substitute into nodes rather than into
a serialized string. That rules out Resend's
`renderToReactEmail`-on-the-extension pattern.
```
twenty-front TipTap extensions + node views + shared email settings UI
twenty-shared versioned email-document schema + structural traversal
twenty-emails react-email renderers (imported by twenty-server)
twenty-server surface-specific variable resolution, validation, send
```
Logic was **vendored, not depended on** — Resend's TipTap is 3.17
against our 3.4, and their UI is Radix. We copied the schema/serializer
approach and rebuilt every control on `twenty-ui` + Linaria.
#### 3. Section typography cascade
The subtle part, and the one that would have silently shipped broken.
Section typography *looks* like it should cascade via CSS. It does not:
```js
// react-email's Text
style: { fontSize: "14px", lineHeight: "24px", ...style, ...margins }
```
Every paragraph re-declares `fontSize` and `lineHeight`, overriding any
enclosing section. `inherit` is not a fix either — Outlook's Word engine
ignores it.
So the cascade is **resolved in the renderer**: the tree walk threads
the enclosing section's typography down and writes computed values
explicitly onto each text node. Nested sections refine what they
inherit.
Verified against real rendered output:
| | rendered |
|---|---|
| paragraph inside section | `font-size:22px; color:rgb(255,0,0);
letter-spacing:2px` |
| h1 inside section | `font-size:32px` (own scale) + section colour and
spacing |
| paragraph outside | `font-size:14px`, no colour — untouched |
#### 4. Storage
`bodyTemplate` stays serialized TipTap JSON in a `TEXT` column. Block
attributes are ProseMirror node attrs, so richer blocks add keys to JSON
already being serialized — no migration, and it flows into the existing
500 ms debounced draft save unchanged.
Since the feature has not shipped, the legacy HTML-string body path was
removed rather than maintained. That is a tightening, not just a
deletion: `bodyTemplate` is writable through the record API, and the old
fallback would interpolate an arbitrary string and email it as markup. A
body that is neither empty nor a valid TipTap document is now rejected
at the send gate.
#### 5. Image hosting
Inline assets use an `EmailImage` file folder with
`ignoreExpirationToken: true` and immutable cache headers, because
recipients' mail clients never authenticate and may open an email years
later. The shared uploader returns `{ fileId, url }`; the image node
keeps both the durable file identity and its delivery URL so
ownership/lifecycle or URL resolution can evolve later without a
document migration. The server verifies the uploaded bytes and only
accepts GIF, JPEG, PNG and WebP.
This is intentionally separate from workflow/email **attachments**.
Attachments remain private files that the server reads and embeds as
MIME parts at send time; inline images need a durable recipient-facing
URL. A future workflow canvas should reuse `useUploadEmailImage` for
inline content while keeping its existing attachment control unchanged.
Adding the folder requires three registrations — the folder config, the
route guard's `SUPPORTED_FILE_FOLDERS`, and `DIRECT_UPLOAD_FILE_FOLDERS`
in the upload service.
### Bugs fixed along the way
- **Unsubscribe footer was appended after `</html>`**, outside the
document, where Gmail strips it. Legally significant, since an
unsubscribe link is required. Now inserted before `</body>`.
- **Body text colour never reached the email.**
- **`onImageUpload` was declared but never passed** by any production
call site, so drag-drop and paste image upload were inert everywhere
outside Storybook.
- **Message lists were not user-facing**, so members could not be added
from the list page.
- **Image resize wrote an undeclared `width` attribute** that TipTap
silently dropped.
- **The text bubble menu appeared over selected atom blocks** with
nothing to format.
### Review notes / known limitations
**Security posture to check.** Anything in `EmailImage` is readable by
anyone holding the URL, forever. The server now enforces an image-only
MIME allowlist from sniffed bytes, but it cannot determine whether the
image itself is confidential. This remains a deliberate trade-off for
recipient-visible inline assets.
**Test gap.** The section typography cascade has no regression test:
react-email's `render()` hangs under Jest (tried 60s), and
`twenty-emails` has no test target at all. Verified by rendering through
the built package instead. Adding a test target there is worthwhile
follow-up.
**Sending needs configuration.** `EMAILING_DOMAIN_DRIVER` defaults to
`LOG`, which fakes a messageId, reports any domain as verified, and only
logs — a campaign reaches "sent" with nothing delivered. Real sending
needs `AWS_SES`.
**Unrelated platform bug found.** The pinned "Create new record" command
throws on viewless objects like `messageListMember`, because
`recordIndexId` derives from the current view.
**Not done.** Panel chrome from the reference: breadcrumb (`Page style /
Section`), collapsible groups, per-side spacing grid, and a
variable-insert button inside link fields. All presentation over the
same data.
**Deferred.** Drag-to-reorder blocks.
`@tiptap/extension-drag-handle-react@3.4.2` matches our pinned versions
exactly, so no upgrade is needed, but its behaviour around atom node
views (HTML block, image) is unverified and belongs in its own change.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23657?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: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
|
||
|
|
13a2e3ebe8 |
fix(twenty-server): compose email from the caller's own connected account (#23793)
### The bug
`draft_email` and `send_email` take an optional `connectedAccountId`.
When an agent omits it — which it does whenever it has no way to know
the id — `EmailComposerService` resolved the account like this:
```ts
const allAccounts = await this.connectedAccountRepository.find({
where: { workspaceId, archivedAt: IsNull() },
});
return allAccounts[0].id;
```
The first connected account **in the workspace**, ignoring the
`userWorkspaceId` that `ToolExecutionContext` already carries — with no
`ORDER BY`, so "first" is whatever the planner returns.
We hit this on our own workspace: an agent chat drafted a customer email
on behalf of one user, and the draft landed in a different user's
mailbox. The tool reported `success: true` with a `connectedAccountId`
belonging to someone who was not in the conversation, so nothing
surfaced the mistake. `send_email` shares this composer, so the same
fallback sends mail from another person's address.
### The fix
- **No id supplied** → the caller's own account
(`context.userWorkspaceId`), else an account whose `visibility` is
`workspace`, else throw `CONNECTED_ACCOUNT_NOT_FOUND`. Never a
colleague's private mailbox by accident.
- **Id supplied** → used as given, whoever owns it. Blocking a member
from composing through another member's account is a product decision
this PR does not make; the mix-up above happens when no id is passed at
all.
- **No `userWorkspaceId`** (workflow run) → unchanged.
Ordering is `createdAt ASC, id ASC` so the no-caller path is
deterministic when rows share a `createdAt` — which the seed data does.
### Verified against a real workspace
Run locally against the seeded `test` database — 7 connected accounts in
one workspace, owned by four different members, **all sharing one
`createdAt`**. Same spec, composer swapped:
| Scenario | on `main` | with this PR |
|---|---|---|
| Phil's agent composes, no id | **tim@apple.dev's account** | phil's
own |
| explicit id (jony's), caller is phil | jony's | jony's |
| workflow run (no caller), explicit id | jony's | jony's |
| **workflow run (no caller), no id** | **first account, unordered** |
**first account, `createdAt`/`id` ordered** |
| caller with no account | silently resolved a colleague's | throws |
### What this does not fix
A workflow run carries no caller: `ToolBackedWorkflowAction` executes
the tool with `{ workspaceId }` and no `userWorkspaceId`. So when an
email step's sender resolves to nothing — `postprocessInput` guards for
it — the composer still falls back to the workspace's first account,
because there is no identity to attribute the mail to. The pick is at
least deterministic now. Giving workflow runs an owner is a separate
change.
Normal workflow steps are unaffected:
`EmailWorkflowActionBase.resolveSenderConnectedAccountId` resolves the
configured sender (a connected-account id, or a workspace member id from
a resolved variable) and passes it explicitly.
### Behaviour change to expect
A caller with no connected account of their own, in a workspace with no
shared account, now gets an error where the call previously "succeeded"
from a colleague's mailbox.
### Tests
Resolution is exercised by
`test/integration/email-tool/suites/email-composer-connected-account.integration-spec.ts`
against a real workspace — eight cases: supplied id honoured, supplied
id with no caller, invalid id, unknown id, caller's own account,
workspace-shared fallback (flips `visibility` in Postgres and restores
it), no usable account, and first-account-when-no-caller.
The service's unit spec is deleted: mocking the DI graph asserted the
mock rather than the resolution, and every case it covered now runs
against the database. The pure selection logic keeps unit specs —
`select-connected-account-id-for-caller.util.spec.ts` and
`is-connected-account-usable-by-caller.util.spec.ts`.
Not covered here: the workflow chain itself (`postprocessInput` →
`resolveSenderConnectedAccountId` → `DraftEmailWorkflowAction`), which
this PR does not change.
`npx nx typecheck twenty-server`, the email-tool and connected-account
suites, and the integration spec all pass; oxlint type-aware clean.
|
||
|
|
61c72942ac |
feat(workflow): dispatch automated triggers from core behind a flag (#23775)
## Context
Part of the workflow → core migration. Before we can stop writing
workspace `trigger`/`steps`, automated-trigger dispatch must read from
core. Dispatch currently reads the workspace `workflowAutomatedTrigger`
table (populated from the workspace trigger), so it would go blank once
those writes stop. This flips the dispatch reads behind a flag,
mirroring the version-content read switch.
## What this does
New flag `IS_WORKFLOW_DISPATCH_FROM_CORE_ENABLED` (per-workspace,
default off). At each dispatch read site, flag-on reads the core-derived
trigger map and flag-off keeps the current workspace query.
- **DB-event listener** (`workflow-database-event-trigger.listener.ts`):
extracted `getDatabaseEventListeners(workspaceId, eventName)`. Flag-on
filters the core map (`getOrRecompute → byWorkflowId`, `type ===
DATABASE_EVENT && settings.eventName === name`); flag-off keeps the repo
`find`. The evaluation type is broadened to the structural `{
workflowId, settings }` that both the entity and the map entry satisfy;
the enqueue loop and `shouldTriggerJob` are unchanged.
- **CRON job** (`workflow-cron-trigger-cron.job.ts`): extracted
`getWorkspaceCronTriggers(workspaceId)`. Flag-on filters the core map
for `type === CRON` → `{ workflowId, pattern }`; flag-off keeps the raw
SQL. The redis cron cache, dedup and dispatch loop are unchanged; only
the rebuild source swaps.
## Why it's safe
- The core map is keyed by the workspace `workflowId`, and both sites
enqueue `workflowId` only. Nothing consumes the map's core
`workflowVersionId`, so `workflow-trigger.job.ts` still re-derives the
version from workspace `lastPublishedVersionId` (no id translation).
- Flag defaults off, per-workspace rollout. The drift cron's
`checkAutomatedTriggerSync` already compares the core map against the
workspace table, so it's the soak signal for flipping the flag.
- The CRON source is only re-read on a cron-cache rebuild (cache miss),
so a flag flip takes effect on the next rebuild: bounded by the cache
TTL, or immediately on activation/deactivation, which invalidates the
cache. Both sources emit identical `{ workflowId, pattern }` for a
synced workspace, so the switch is a no-op in output.
## Prerequisite
- The orphan-ACTIVE core-version cleanup (#23739) must land first: the
core map is built from core ACTIVE versions, so a phantom orphan would
become a live phantom trigger the moment this flag flips.
## Verification
- Server unit specs cover both sites with the flag off (existing
behavior) and on (reads the core map).
- Live-verified on a dev instance: DB-event and CRON dispatch both fire
from the core map with the flag on, and from the workspace entity with
it off.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23775?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. -->
|
||
|
|
196a1945ff |
i18n - translations (#23794)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23794?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> |
||
|
|
ded3f1efb3 |
Add messages support to runAgent for multi-turn bot conversations (#23395)
- Extend `runAgent` so callers can pass either a one-shot prompt or a multi-turn messages array (user / assistant text), matching AI SDK’s XOR shape — for Slack/Discord/Teams bots that need thread history. - Enforce exactly one of prompt | messages in AgentRunService; map messages 1:1 to AI SDK ModelMessages in AgentAsyncExecutorService - Update shared types, GraphQL/SDK inputs, docs (skills-and-agents), and regenerate metadata clients; existing prompt-only callers stay unchanged <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23395?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. --> |
||
|
|
0b817e3bc0 |
Restrict which workspace fields can be updated before activation (#23781)
## What `validateWorkspaceUpdatePermissions` returned early with no checks at all when the workspace was in `PENDING_CREATION`, so `updateWorkspace` accepted any field during that window. It now allows only the fields needed to set the workspace up (`displayName`, `subdomain`, `logo`) and rejects everything else until the workspace is activated. Note that `updateWorkspace`'s resolver guard is `CustomPermissionGuard`, which always returns true and only documents that the check lives in the resolver/service, so this service method is the actual enforcement point. ## Why A workspace stays in `PENDING_CREATION` from signup until onboarding completes, and the JWT strategy issues an authenticated context for it without resolving member permissions. During that window every field was writable with no permission check, including security relevant ones such as `allowImpersonation`, `isTwoFactorAuthenticationEnforced` and `isPublicInviteLinkEnabled`. In practice the only principal present before activation is the workspace creator, who is granted the Admin role (`canUpdateAllSettings`) the moment activation completes, so there is no privilege escalation over another user today. This is defense in depth: the early return was broader than it needed to be, and it becomes a real gap if the "only the creator exists before activation" assumption ever stops holding, for example a workspace left pending or a future flow that adds members before activation. The bypass exists because a pending workspace has no roles yet, so permissions cannot be resolved for it. Keeping a small explicit allowlist preserves that while removing the blanket skip. ## Scope Only the `updateWorkspace` path. `SettingsPermissionGuard` has a similar bypass for `PENDING_CREATION` / `ONGOING_CREATION`, but it covers 62 resolvers including billing endpoints that onboarding legitimately calls before activation, so narrowing it needs its own analysis and is deliberately left out. ## Tests `workspace-update-before-activation.integration-spec.ts`, run against a real database with the seeded workspace flipped to `PENDING_CREATION`: - a security sensitive field (`allowImpersonation`) is rejected and the stored value is unchanged - mixing a setup field with a security sensitive one rejects the whole update, and `displayName` is not persisted - setup fields (`displayName`) still apply, so the restriction does not break workspace setup Both rejection tests were verified to fail when the old blanket early return is put back, while the positive control keeps passing. The existing `settings-permissions/workspace*` suites still pass (38 tests total), confirming no change for activated workspaces. --- _Generated by [Claude Code](https://claude.ai/code/session_01Qf58T7Lm7PazNbS3UwaZPd)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23781?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. --> |
||
|
|
8bfa9c4adb |
Proxy API routes through the vite dev server to keep local dev same-origin (#23779)
Replaces #23774 (closed), rebased on latest main. ## Problem Since the cookie-session migration (#23642), the front sends every request with `credentials: 'include'` and the server only reflects `Access-Control-Allow-Origin` for the exact origins in the credentialed allowlist (`SERVER_URL`, `FRONTEND_URL`, `AUTH_COOKIE_ALLOWED_ORIGINS`). Any other origin gets the `*` wildcard, which browsers reject for credentialed requests. Local dev is split-origin by default (front on `localhost:3001`, API on `localhost:3000`), and with `IS_MULTIWORKSPACE_ENABLED` every workspace subdomain (`apple.localhost:3001`, ...) is yet another origin. Each locally created workspace would need a manual `AUTH_COOKIE_ALLOWED_ORIGINS` entry. ## Solution Make local dev same-origin instead of widening the CORS policy: the vite dev server now proxies all top-level API route prefixes to the backend, and the front calls its own origin. - `vite.config.ts` adds a `server.proxy` covering the backend's top-level prefixes (`/graphql`, `/metadata`, `/admin-panel`, `/auth`, `/rest`, `/file`, `/client-config`, ...), defined in `src/config/apiProxyPrefixes.ts`. Keys are anchored regexes (`^/auth($|[/?])`) so SPA routes sharing a prefix (`/authorize`, `/settings`) are not swallowed. The target defaults to `http://localhost:3000` and follows `REACT_APP_SERVER_BASE_URL`. `changeOrigin` stays off so the backend sees the browser's Host: same-origin checks (CSRF, cookie issuance) and workspace resolution by subdomain work unchanged through the proxy. - `config/index.ts` collapses to `window._env_?.REACT_APP_SERVER_BASE_URL || window.location.origin`. Every supported production path injects `window._env_` (docker entrypoint fails hard without `REACT_APP_SERVER_BASE_URL`; a server-served front gets it from `generateFrontConfig()`), and in dev the current origin is correct on `localhost:3001` and every `*.localhost:3001` workspace subdomain thanks to the proxy. The removed `http://<hostname>:3000` fallback only served an un-injected production bundle browsed on localhost, a setup whose credentialed auth the cookie-session migration had already broken. The credentialed allowlist itself is unchanged and stays strict; since dev traffic is same-origin, the per-subdomain cookie-allowlist problem disappears without loosening any production CORS/CSRF policy. ## Tests - `src/config/__tests__/apiProxyPrefixes.test.ts` guards the proxy boundary in both directions: representative backend path shapes (including `/metadata?query=...` and `/auth/...`) must match, every SPA route from the `AppPath` enum and vite's own dev paths must not — so a future route collision fails unit tests instead of breaking dev. - Verified against running dev servers: API paths proxy to the backend from both `localhost:3001` and `apple.localhost:3001`, while SPA routes `/settings` and `/authorize` still serve the vite app; a same-origin POST from `apple.localhost:3001` goes through with no CORS involvement. - `lint:diff-with-main` and `typecheck` pass for twenty-front. --------- Co-authored-by: Félix Malfait <felix@twenty.com> |
||
|
|
0f35b5895a |
fix(workflow): reject if-else branches with a dangling filterGroupId (#23758)
## Summary An If/Else workflow step branch whose `filterGroupId` doesn't resolve to any entry in `stepFilterGroups` was matching unconditionally — before any of the step's real conditions were evaluated — instead of being rejected. This let a single stale or mistyped `filterGroupId` silently hijack the routing of an entire If/Else step. Fixes #23754 ## Problem Reproduced with a standalone unit test against `findMatchingBranch`: ```ts const branches = [ { id: 'branch-A', filterGroupId: 'group-id-that-does-not-exist', nextStepIds: ['wrong-step'] }, { id: 'branch-B', filterGroupId: 'real-group', nextStepIds: ['correct-step'] }, ]; const stepFilterGroups = [{ id: 'real-group', logicalOperator: 'AND' }]; const resolvedFilters = [{ /* branch-B's real filter, evaluates to false */ }]; findMatchingBranch({ branches, stepFilterGroups, resolvedFilters }).id; // => 'branch-A' (its condition was never evaluated at all) ``` `branch-A` wins even though its `filterGroupId` doesn't exist and `branch-B`'s actual (non-matching) filter was correctly evaluated to `false`. ## Root cause `find-matching-branch.util.ts` builds `branchFilterGroups` via `collectAllDescendantGroups(branch.filterGroupId, stepFilterGroups)`, which silently returns an empty `Set` when the root id isn't found. The resulting empty `branchFilterGroups`/`branchFilters` are passed to `evaluateFilterConditions`, which treats "both empty" as vacuously `true` — a rule that's correct for the real trailing else-branch (no `filterGroupId` at all, by design) but indistinguishable, at this call site, from "the referenced group doesn't exist." Since `Array.prototype.find` returns the first match, this branch wins over any later branch whose condition was actually evaluated. There was also no validation path that would catch this before execution: `validateBranchingStep` (`validate-workflow-graph.util.ts`) already checks If/Else branch count and `nextStepIds` connectivity, but had no check for `filterGroupId` referential integrity. ## Fix 1. `find-matching-branch.util.ts` — throw `WorkflowStepExecutorException` (`INVALID_STEP_INPUT`) when a branch's `filterGroupId` doesn't resolve to any group, instead of silently falling through to `evaluateFilterConditions({filterGroups: [], filters: []})`. This mirrors the sibling guard clauses already in this action for other malformed-input cases. 2. `validate-workflow-graph.util.ts` — extended the existing `IF_ELSE` branch checks in `validateBranchingStep` with the same check, surfaced as a new `IF_ELSE_BRANCH_FILTER_GROUP_NOT_FOUND` issue code, so `validate_workflow` catches this before a workflow ever runs. **Alternative considered:** fixing only at validation time. Rejected — validation can be skipped (e.g. the AI workflow-editing tool's `validate: false` option) or bypassed entirely by a direct API write, so the execution-time guard is the actual fix; the validation check is defense in depth, not a substitute. **Alternative considered:** silently skipping the malformed branch instead of throwing. Rejected — throwing immediately gives a specific, actionable error pointing at the exact misconfiguration, matching this file's existing error granularity (distinct messages for "not an if-else step", "no branches", "missing filter groups/filters", "no matching branch"). ## Tests - `find-matching-branch.util.spec.ts` (new) — real-condition match, else-branch fallback match, throws on a dangling `filterGroupId` (fails on `main`, passes here), throws when no branch matches and there's no else branch. - `validate-workflow-graph.util.test.ts` (+2) — flags `IF_ELSE_BRANCH_FILTER_GROUP_NOT_FOUND` for a dangling reference; does not false-positive on a correctly-configured branch. - Full module suites: `npx nx test twenty-server` scoped to `src/modules/workflow` → 68 suites / 626 tests passed. `npx jest packages/twenty-shared/src/workflow` → 30 suites / 248 tests passed. - `npx nx lint twenty-server twenty-shared` and `npx nx typecheck twenty-server twenty-shared` → clean. ## Compatibility / risk Internal-only change to workflow execution and validation logic — no GraphQL schema change, no public API signature change, no migration. A workflow that today relies (accidentally) on the silent "dangling group = always match" behavior would start throwing at execution time, but that was never intentional or documented behavior. ## Out of scope - Branch **ordering** invariants (e.g. asserting the group-less else branch is always last) — not needed for this fix; the defect reproduces purely from a dangling `filterGroupId`, independent of order. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23758?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: Thomas Trompette <thomas.trompette@sfr.fr> |
||
|
|
29aa6e85d6 |
fix(front): only show Discard Draft when workflow has a published version (#23756)
## Problem Draft workflows expose a **Discard Draft** action, but it fails when the draft is the *only* version of the workflow. The backend refuses the delete with `The initial version of a workflow can not be deleted` (guard in `validateWorkflowVersionForDeleteOne`), yet the action was still shown. The display condition and the delete-guard disagreed: - Display condition: `every(selectedRecords, "versions.length")` -> truthy when there is **at least one** version. - Delete guard: forbids deletion unless **another** non-deleted version exists. So a workflow whose only version is a draft showed the button, and clicking it hit a `FORBIDDEN` error. ## Fix Show the action only when the workflow has a published version to fall back to, which mirrors the backend guard: ``` every(selectedRecords, "lastPublishedVersionId") and everyEquals(selectedRecords, "currentVersion.status", "DRAFT") and noneDefined(selectedRecords, "deletedAt") ``` `lastPublishedVersionId` is a plain scalar already on the workflow. `every` (truthiness) is used rather than `everyDefined` because the field is an empty string for never-published workflows, and `everyDefined` would treat `""` as present. The command-menu evaluator reads records straight from the store, and the index/table view only fetches visible columns, so the enrichment provider now also backfills `lastPublishedVersionId` (already fetched by `useWorkflowsWithCurrentVersions`) to keep the condition reliable outside the record show page. ## Existing workspaces The standard-application full sync only runs at workspace creation, so editing the constant alone would fix new workspaces but leave existing ones showing the broken button. A `2.27.0` workspace upgrade command re-syncs the `discardDraftWorkflow` availability expression for existing workspaces, updating it only when it still equals the legacy `versions.length` value (so custom expressions are left untouched). Mirrors the existing 2-23 command-menu-item sync pattern. ## Notes - The `>`-style "more than one version" comparison is not expressible in the current `conditionalAvailabilityExpression` grammar (comparison operators only reach top-level scalars like `numberOfSelectedRecords`, not per-record paths). Gating on `lastPublishedVersionId` achieves the same intent without adding a parser helper. ## Test - Fresh workflow (single draft) -> Discard Draft hidden. - Publish, then edit to create a new draft -> Discard Draft shown and works. - Unit test on the sync-operations builder: updates the legacy expression, no-ops when already synced / custom / missing. |
||
|
|
3833015626 |
Ignore expired invitations in invitation lookups (#23749)
## What Invitation lookups did not filter on `expiresAt`, so expired invitations were still treated as active. This aligns them with the sibling `findInvitationsByEmail`, which already applied that filter. - `WorkspaceInvitationService.getOneWorkspaceInvitation` - added `deletedAt IS NULL` and `expiresAt > now` (also converted to a typed `findOne` so the column references are checked). - `AuthService.findInvitationForSignInUp` - added `expiresAt > now` (it already filtered `deletedAt`). - `throwIfOnboardingInvitationLimitReached` - expired tokens no longer count toward the onboarding invitation limit. - `createWorkspaceInvitation` - deletes the expired token for that email before issuing a replacement, so re-invites don't accumulate stale rows. ## Why Without the filter, an expired pending invitation behaved as if it were still active: - On sign-up with a personal invite token, an expired invitation still granted access to the workspace. - Re-inviting an email whose invitation had lapsed reported `INVITATION_ALREADY_EXIST` instead of sending a fresh invite. - Expired onboarding invitations still consumed quota, so the limit could be hit by invitations nobody could use. Once expired tokens are ignored on read, a re-invite would leave the old row behind, so `createWorkspaceInvitation` now removes it. The delete is scoped to the same workspace, invitation token types, that exact email, and `expiresAt <= now`, so it can only remove tokens that are already unusable. Closes twentyhq/private-issues#503 ## Tests Integration suites added, run against a real database: - `auth/sign-up/failing-sign-up-with-expired-invitation` - expired personal invitation is rejected (snapshot asserts the specific `FORBIDDEN` error). - `auth/sign-up/successful-sign-up-with-valid-invitation` - positive control: a valid invitation still grants access, so the rejection above cannot pass for an unrelated reason. - `expired-workspace-invitation` - re-invite over an expired invitation succeeds and leaves exactly one (fresh) token; a valid invitation is still reported as already existing. Each assertion was verified to fail when its corresponding filter is removed. Unit tests (`workspace-invitation.service.spec.ts`, `auth.service.spec.ts`), typecheck, and lint all pass. Not included: invitations that expire and are never re-invited still linger, since no cron reaps invitation tokens today. |
||
|
|
0379b537dd |
feat(workflow): repair orphan core workflow versions via upgrade command (#23739)
## Context Part of the workflow + workflowVersion in core migration. Before enabling `IS_WORKFLOW_VERSION_IN_CORE_ENABLED` on real workspaces, legacy drift between the workspace source-of-truth and the core mirror must be repaired. The dangerous category is orphan **ACTIVE** core `workflowVersion` rows: once dispatch reads core (later step), they become phantom triggers. ## The problem Some workspaces carry orphan `core."workflowVersion"` rows: core versions that no workspace version references via `coreWorkflowVersionId`. On one production workspace this was 57 rows, 5 of them ACTIVE `DATABASE_EVENT`. Root cause is pre-2.25 residue: - The v2.22 `backfill-workflow-version-core-links` command minted a core row per active workspace version, copying `status` verbatim. - The workflow delete/destroy cascade did not clean core until #23356 (first in v2.25.0): there was no `deleteCoreVersionsByWorkflowIds` and the deactivate-on-delete status flip was not mirrored. - Workflows deleted then destroyed in that window left their core rows behind, still ACTIVE, with every workspace referrer gone. Current code (>= v2.25) cleans core transactionally on delete/destroy, so this cannot recur. This command clears the historical residue. ## What this does A `@RegisteredWorkspaceCommand('2.28.0')` that, per provisioned workspace: - Deletes `core."workflowVersion"` rows with no workspace referrer, then `invalidateAndRecompute`s the automated trigger map. - `NOT EXISTS` does not filter `deletedAt`, so a soft-deleted (restorable) workspace version still protects its core row. - Scoped to `applicationId = workspaceCustomApplicationId OR NULL`, so future app-owned core versions are never touched. - Supports `--dry-run` and is idempotent (re-run is a no-op). ## Testing Run through the real upgrade harness on a dev instance: - Injected 2 synthetic orphans (1 ACTIVE `DATABASE_EVENT`, 1 ARCHIVED). `--dry-run` reported `Would delete 2 (1 ACTIVE)`; real run reported `Deleted 2 (1 ACTIVE)`. - The 5 legit linked core versions were untouched (referrer guard verified). Orphans remaining: 0. - Re-run logged `No orphan core workflowVersion rows` (idempotent). - typecheck, oxlint, oxfmt all clean. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23739?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. --> |
||
|
|
1ebcbdda42 |
feat(server): export event-loop delay and workspace-cache recompute metrics (#23751)
## What
Exports three sets of metrics to Prometheus to diagnose the recurring
"Slow DB Query" Sentry issues on `POST /graphql` (e.g. the
`fieldMetadata` select):
- `twenty_nodejs_eventloop_delay_seconds` (mean/p50/p99/max) +
`twenty_nodejs_eventloop_utilization`
- `twenty_workspace_cache_recompute_duration_seconds{cache_key}` — wall
time per provider `computeForCache`
- `twenty_workspace_cache_redis_write_duration_seconds` — serialize +
Redis write time for recomputed entries
## Why
Investigation of these issues showed:
- The flagged query executes in ~1ms (prod EXPLAIN), so it is not a
query/index problem.
- Event counts do **not** correlate with connection-pool acquire latency
(Pearson ~0 against both p99 and the direct count of >1s acquires), so
it is not pool contention.
- Event counts **do** correlate with pod CPU (Pearson +0.40).
The leading explanation is that the slow `db` span is inflated by
event-loop saturation during the workspace metadata cache recompute:
`Promise.all` parallelizes the I/O, but the synchronous work it cannot
parallelize (TypeORM entity hydration of JSONB-heavy result sets, then
`JSON.stringify` of the flat-map payloads into Redis) blocks the single
event-loop thread, so an awaiting query resolves ~1.3s late.
Node event-loop delay was only being collected by Sentry's
`nodeRuntimeMetricsIntegration`, never exported to Prometheus, so it
could not be graphed or correlated in Grafana. These metrics confirm (or
refute) the mechanism and give a before/after baseline for the fix.
Grafana panels land in a companion twenty-infra PR.
## Notes
- Metric-only change; no behavioral change to the cache.
- Uses the same OTel `MetricsService` / meter as the existing DB pool
metrics.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23751?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. -->
|
||
|
|
55707868cd |
Add IS_FEATURE_FLAG_MANAGEMENT_ENABLED to unlock feature flag toggling outside cloud (#23750)
The admin panel has a per-workspace Feature Flags tab that can toggle
any key in `FeatureFlagKey`, but it was hidden unless `NODE_ENV` was
`development` or billing was enabled:
```ts
canManageFeatureFlags:
this.twentyConfigService.get('NODE_ENV') === NodeEnvironment.DEVELOPMENT || isBillingEnabled,
```
Preview apps run `NODE_ENV=production` with billing off, so the tab
disappears and there is no way to flip a flag on a `trycloudflare.com`
app short of editing `core.featureFlag` by hand.
This is purely a client-side gate. `updateWorkspaceFeatureFlag` is
guarded server-side by `AdminPanelGuard` (`canAccessFullAdminPanel`) and
has no billing or cloud check, so unhiding the tab does not widen what
the server accepts. The gate has to be coarse because `/client-config`
is a public unauthenticated endpoint and cannot carry per-user state,
which is presumably why it ended up keyed on `NODE_ENV`/billing.
## Changes
- New `IS_FEATURE_FLAG_MANAGEMENT_ENABLED` config variable
(`ADVANCED_SETTINGS`), defaulting to `false`.
- `canManageFeatureFlags` now also honours it. Development mode and
billing-enabled instances behave exactly as before.
## Notes
- Deliberately not added to `docker-compose.yml` or `.env.example`, and
not documented in `feature-flags.mdx`. Anyone who needs it can set the
env var directly.
- `twentyhq/ci-public#3` sets it for preview apps by patching the
variable into the server service and the generated `.env`, so it does
not depend on the compose file carrying the entry.
- The seeded dev users already have `canAccessFullAdminPanel: true`, and
on a non-seeded instance the first user to sign up is granted it, so the
tab is reachable once the variable is on.
## Test
`client-config.service.spec.ts` covers the new variable unlocking
management in production with billing off, alongside the existing cases
for development mode, billing enabled, and both off.
---
_Generated by [Claude
Code](https://claude.ai/code/session_015ovxLQNTHxvBbhfrKq2aZt)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23750?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. -->
|
||
|
|
453f3479ab |
Accept a singleton or filter in the GraphQL filter walker (#23738)
`RecordGqlOperationFilter` types `or` as `RecordGqlOperationFilter[] |
RecordGqlOperationFilter`, so both `{ or: [{ name: { ilike: '%acme%' }
}] }` and `{ or: { name: { ilike: '%acme%' } } }` are valid.
`applyLogicalGroup` went straight to `filters.forEach(...)`, so the
non-array form threw `TypeError: filters.forEach is not a function`
instead of returning records. Only `or` is affected: `and` is always an
array and `not` is always a single object.
This is not a new bug. The same assumption existed before the walker was
extracted, when `parseKeyFilter` did the `value.forEach` inline. Sentry
surfaced it on #23369, and it was left out of that PR to keep it scoped.
The fix mirrors `renderLogicalGroup` in the RLS SQL renderer, which
already normalizes a singleton to an array on its first line, so the two
walkers over this filter format now accept the same shapes.
|
||
|
|
0408816781 |
i18n - translations (#23746)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23746?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> |
||
|
|
8e5bdcc781 |
webhook subscriptions error handling (#23707)
A `subscriptionRemoved` lifecycle notification was routed to `renewSubscription`, which PATCHes a subscription Microsoft has already deleted and always 404s ([TWENTY-SERVER-J1N](https://twenty-v7.sentry.io/issues/7604034376/), 884 events). Every sampled webhook event on that issue was `subscriptionRemoved`. Each lifecycle event now gets its own path: `subscriptionRemoved` recreates and resyncs the gap, `reauthorizationRequired` renews in place, `missed` resyncs, unrecognised events are logged and ignored. Provider errors are parsed into driver exception codes following the message-import drivers. Max retry for the renewal cron is deliberately left out and will follow separately. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23707?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> |
||
|
|
997b2c38de |
Add cookie-session integration test suite (#23715)
Stacked on #23642. Integration suite for the cookie-session surface, organized as one successful/failing spec pair per stage of the session lifecycle. 14 spec files, ~36 tests, all over real HTTP against the booted app. ## Coverage by stage **1. Session creation on auth exchanges** (`successful-`/`failing-session-creation`) Flag gating (default off: tokens, no cookie, no row); httpOnly cookie snapshot with 180d expiry window; SHA-256 hash-at-rest with the row bound to the apple seed workspace; scripted sign-ins without an Origin header still get the cookie; login-CSRF refuses the cookie for disallowed origins while returning the token pair; sign-in over an existing session revokes it as `SUPERSEDED`; a failed credentials exchange mints nothing. **2. Cookie delivery** (`successful-session-cookie-delivery`, `secure-deployment-session-cookie`) The runtime side door (`AUTH_COOKIE_SAME_SITE=none` forces the secure path) pins the `__Host-`/`Secure`/`SameSite=None` variant in the default CI run. The exact production combination (`__Host-`, `Secure`, `SameSite=Lax`) is covered by a dedicated spec that requires the app to boot with an https `SERVER_URL`: the secure branch is decided by config, never the transport, so no TLS is needed. It skips itself on plain-http boots; CI runs it as an extra step on one shard with `SERVER_URL=https://localhost:3000`, including the `__Host-` round-trip and the plain-cookie-name downgrade refusal. **3. Per-request authentication and the CSRF read gate** (`successful-`/`failing-session-cookie-authentication`) A cookie-only request resolves the seeded user; a `sess_` token presented as Bearer is rejected; cookie-authenticated unsafe requests with a disallowed or missing Origin get 403 `CSRF_ORIGIN_MISMATCH`; an unknown session token is unauthenticated and its dead cookie is cleared. **3b. Workspace binding** (`successful-session-workspace-binding`) Tim signs into both seeded workspaces (apple and yc); each session row is bound to the workspace its exchange selected (`workspaceId` and `userWorkspaceId` pinned to the seed ids), and each cookie resolves to its own workspace context, with no request-side input able to pivot a session across workspaces. **3c. Credentialed CORS** (`cors-credentialed-origins`) Allowlisted origins get the reflected `Access-Control-Allow-Origin` plus `Access-Control-Allow-Credentials: true` and `Vary: Origin`, preflight included; other origins keep the public wildcard. See tooling notes: this surface was previously untestable. **4. Sessions API** (`successful-`/`failing-user-sessions-api`) `currentUserSessions` marks exactly the presented session as current; `revokeUserSession` revokes by id (`USER_REVOKED`) and drops it from the listing; `revokeAllOtherUserSessions` spares the presented session; cross-user revocation and unauthenticated listing are refused. **5. Exits** (`successful-sign-out`, `failing-session-expiration`) `signOut` revokes with `USER_SIGN_OUT`, clears the cookie, and reuse fails immediately (cache invalidated, not TTL-bound); a cookie-less sign-out clears nothing, so a cross-site POST cannot log a visitor out; absolute-lifetime and idle-timeout expiry both reject and clear the cookie. **7. Cleanup cron** (`user-session-cleanup-cron`) Both halves run in-process against fixtures spanning the 30d retention boundary. Sessions: expired/revoked-beyond-retention deleted; active, recently-expired, and idle-expired rows survive (the idle case pins the known predicate gap). Refresh tokens: old-expired and old-revoked deleted, fresh kept, and a long-expired token of another type survives, pinning the `type` filter that keeps the shared `appToken` table safe from the hard-delete. Not covered here by design: the impersonation park/restore sub-funnel (stage 6, follow-up) and the client-side funnel (stage 8, front-end scope). Password-change revocation and the renewal bridge are also left to follow-ups. ## How the flag is flipped `AUTH_COOKIE_SESSIONS_ENABLED` (and `AUTH_COOKIE_SAME_SITE` for the secure side door) are toggled at runtime through the admin panel config API, reusing the `twenty-config` test utils: `DatabaseConfigDriver.set` updates its cache synchronously and `TwentyConfigService` consults the DB driver before the env driver. No `.env.test` change, no app reboot, runs in the default CI environment without the `ci:auth-cookie-sessions` label. `SERVER_URL` is env-only, hence the dedicated CI step for the production secure-deployment spec. ## Shared tooling changes - **`applyCredentialedCors` extraction (src change)**: the integration harness booted with Nest's wildcard `cors: true`, not the credentialed-allowlist setup living in `main.ts`, so the CORS surface was untestable by construction. The setup moved into `applyCredentialedCors`, now called by both the production bootstrap and `createApp`, making the harness's CORS behavior the deployed one. Behavior-neutral for production. - `makeMetadataAPIRequest` accepts an explicit `null` token for unauthenticated requests. Passing `undefined` silently fell back to the default admin token (parameter defaults apply to `undefined`), which made supposedly public requests Bearer-authenticated, bypassing both the cookie auth path and the CSRF middleware. Existing call sites are unaffected. - The `GetLoginTokenFromCredentials` / `GetAuthTokensFromLoginToken` documents moved into shared query factories; the workspace-origin builder is extracted and generalized to any seeded subdomain (`buildWorkspaceOriginForSubdomain`, reused by `getAccessTokenForCredentials`). - Suite-local helpers: `signInWithCookieCapture` (full credentials exchange returning the raw supertest response, with a `workspaceSubdomain` option), `postMetadataOperationWithHeaders` (Origin/Cookie header control), cookie extraction for both cookie names, clearing-cookie detection, snapshot normalization (token and expiry redacted), and shared `ALLOWED_ORIGIN`/`DISALLOWED_ORIGIN` constants derived from `FRONTEND_URL`. Verified locally: full suite green in CI mode on both plain-http and https-`SERVER_URL` boots; oxlint and tsc clean. --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> |
||
|
|
2db730b65f |
i18n - translations (#23740)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
3ab6bb7915 |
chore: bump version to 2.28.0 (#23730)
## 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/23730?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> |
||
|
|
a26b507361 |
Enforce row-level permissions on joined relations (#23369)
Row-level permission predicates were only ever applied to a query's main
alias, so any SQL join leaked rows the caller is not allowed to see. The
visible symptom: dashboard charts grouped by a relation field (e.g.
Opportunities by Company) read the group dimension off an unfiltered
joined table, surfacing hidden companies as chart labels.
`WorkspaceSelectQueryBuilder` now applies the joined object's predicate
to every relation join's `ON` condition. Using `ON` rather than `WHERE`
keeps left-join semantics correct: a visible record linked to a hidden
related row is still counted, it just falls into the null group instead
of being attributed to the hidden row.
This closes the same class of leak in relation filters and
order-by-on-relation, plus the three paths that serialize a builder via
`.getQuery()` and never reach the execution overrides (group-by with
records, per-parent relation limiting, mutation id subqueries). The
per-parent fix also stops hidden rows from consuming `LIMIT` slots
before being filtered out.
```mermaid
flowchart TD
A["WorkspaceSelectQueryBuilder<br/>SELECT FROM person LEFT JOIN company"]
A --> B["getMany / getOne / getCount / execute<br/>(execution overrides)"]
A --> C["getQuery() serialization:<br/>group-by with records,<br/>per-parent relation limit,<br/>mutation id subquery"]
B --> D["validatePermissions()"]
D --> E["applyRowLevelPermissionPredicates<br/>ToMainAliasAndJoinedRelations()"]
C --> E
E --> F["main alias:<br/>WHERE person predicate"]
E --> G["every relation join:<br/>ON person.companyId = company.id<br/>AND company predicate"]
F --> H["hidden companies never surface as group dimensions,<br/>relation-filter matches or sort keys;<br/>a hidden link sorts as NULL and the row is still counted"]
G --> H
```
The last two commits remove the duplication this fix would otherwise
have introduced: one shared `and`/`or`/`not` filter walker (the GraphQL
filter parser and the RLS util were verbatim forks), one RLS
record-filter resolver used by all three call sites, and one shared set
of RLS integration-test fixtures. Behaviour-preserving, with new
characterization tests pinning the emitted condition tree.
Reviewer notes:
- Results change where a join is involved: relation filters no longer
match hidden related records, and order-by-on-relation sorts
hidden-linked rows as null, which can shift pagination.
- Joins on subqueries/custom tables are skipped, and objects with no
predicates for the role are a no-op, so admins and system contexts are
unaffected.
- Timeline messaging inner joins are filtered too, so thread counts can
change for restricted roles.
- Predicates that need the current workspace member (Me) are still
skipped for API key and application contexts, on joins as on the main
alias.
- The join renderer skips the field-level read-permission check the
main-alias parser performs: predicates on read-restricted fields still
filter joins, and the field values are never selected.
- One user-facing change beyond the leak fix: the empty-array filter
error no longer echoes the submitted value back (`Invalid filter value:
"<value>"` -> `Invalid filter value`), on every filter path rather than
just RLS. Catalogs are not regenerated here, so it falls back to English
until the next i18n sync.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23369?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. -->
|
||
|
|
5bafaa0994 |
Hide onboarding credits when billing is disabled (#23717)
Onboarding advertises free credits (the header pill and the green "Earn +N free credits" tags) even when `IS_BILLING_ENABLED` is false, promising a reward that can never be granted: `creditWorkspaceBalance` already no-ops when billing is off. The server now omits the `onboarding` credit-rewards block from the client config when billing is disabled, which hides every reward tag on its own since they all render behind a defined-config guard. The header pill gets an explicit gate. Also stops treating onboarding invites as reward-eligible when billing is off, so they are minted as plain invitation tokens and the 10-invite `ONBOARDING_INVITE_TEAM_MAX_INVITES` cap no longer applies to self-hosted instances. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23717?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. --> |
||
|
|
66e0f620bc |
Default workspaceMember openRecordIn when the workspace is not upgraded yet (#23723)
`WorkspaceMemberDTO.openRecordIn` is `@Field(() => OpenRecordIn, {
nullable: false })`, and the transpiler passed the entity value straight
through. The field is created per workspace by the 2-27 workspace
command `upgrade:2-27:add-workspace-member-open-record-in`, which runs
*after* the code is already serving traffic — the deploy job only runs
instance commands. Until a workspace's turn comes, `openRecordIn` is
`undefined`, GraphQL raises `Cannot return null for non-nullable field
WorkspaceMember.openRecordIn`, `GetCurrentUser` fails outright, and
nobody in that workspace can load the app.
This is not theoretical. On main it broke all 68 live workspaces and
stayed broken for three days: the instance command ran on Jul 31 with
#23614, and `core."upgradeMigration"` had no row for any 2-27 workspace
command until the sequence was run manually today. On prod the window is
however long `upgrade` takes to walk every workspace sequentially.
`SIDE_PANEL` is already the declared `defaultValue` of the standard
field, so behaviour is unchanged once a workspace is upgraded. The same
function already guards `userEmail` this way.
The other write path, `user-workspace.service.ts` inserting
`openRecordIn` on workspace member creation, does not need a guard: the
workspace entity metadata is built per workspace from its own field
metadata, so TypeORM's insert builder omits a property that has no
column rather than failing.
`OpenRecordIn` moves from a type-only to a value import since it is now
referenced at runtime.
## Test
Two cases in a new spec: the value is preserved when present, and falls
back to `SIDE_PANEL` when the workspace has not been upgraded. The
second fails on `main`.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23723?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. -->
|
||
|
|
a042b350f7 |
i18n - translations (#23728)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
3a646ffcb0 |
feat(connections): add an onDisconnect lifecycle hook to connection providers (#23538)
Platform half of the follow-up to https://github.com/twentyhq/twenty/pull/22984#discussion_r3673946334. The Slack app claims a `team_id` on connect and had no way to release it, because connection providers only had an on-connect hook. Nothing here is Slack-specific, so it targets `main`. The app side is #23540, on top of `feat/slack-bot`, and waits on this plus an SDK release. ## What changes `defineConnectionProvider` accepts `onDisconnectLogicFunction` alongside `onConnectLogicFunction`. It is stored on `connectionProvider.onDisconnectLogicFunctionUniversalIdentifier` (fast instance command `2.26.0_...1785350000000`) and enqueued right after the `ConnectedAccount` row is deleted, in the disconnecting workspace, with the same payload as on-connect: ```ts type OnDisconnectPayload = { connectionProviderId: string; connectionProviderName: string; connectedAccountId: string; }; ``` The `ConnectedAccount` is gone by the time the hook runs, so `getConnection` no longer resolves. Anything the cleanup needs has to be in the key-value store, written at connect time and keyed by `connectedAccountId`. The docs section spells that out, along with the fact that uninstalling an app drops its connections through a cascade that never reaches this hook, where `uninstallLogicFunction` is the right tool instead. Both dispatches moved into a new `ConnectionProviderLifecycleHookService`, so `ConnectionProviderOAuthFlowService` no longer owns hook plumbing and `ConnectedAccountMetadataService.delete` can reuse it. On-connect behaviour is unchanged: best effort, never blocks the caller, failures go to Sentry. ## Tests - `connection-provider-lifecycle-hook.service.spec.ts`: the on-connect cases moved over, plus on-disconnect dispatch, no-hook, and missing-provider cases - `connection-provider-oauth-flow.service.spec.ts`: now asserts delegation to the lifecycle hook service - SDK validation, manifest duplicate-identifier, and manifest to flat converter specs extended Server unit tests and typecheck for shared, sdk and server pass locally. |
||
|
|
116c04d8b2 |
Record and enforce per-user OAuth application authorizations (#23678)
Sits on `main` now that #23642 has merged. 18 files changed. ## Why Application tokens are stateless JWTs. When a user completes an OAuth `authorization_code` exchange, the server issues an access/refresh pair carrying `userId` as a claim and stores nothing. So today: - there is no record that a person ever authorized an app, hence nothing to list on a settings screen - there is no way for that person to take an app's access away. The only revocation that exists is uninstalling the app, which is workspace-wide and admin-only - `/oauth/revoke` accepted a refresh token, logged it and did nothing, because there was no state to change `client_credentials` is unaffected: no user is involved and it returns an access token with no refresh token. ## What **`core."applicationAuthorization"`**, one row per (user, application), unique on that pair so re-authorizing updates in place. Written at the `authorization_code` exchange, before the token pair is issued, so a refresh token is never handed out without the grant that makes it redeemable. A dedicated table rather than a new `AppTokenType`: this is a grant keyed on identity, not a token keyed on a secret, and `appToken` is already overloaded. FKs to user, workspace, application and userWorkspace all cascade, which covers hard deletes. Membership removal soft-deletes the `userWorkspace` row, so that cascade does not fire and the grant outlives the membership. The refresh path therefore rechecks membership on every renewal rather than trusting the row's existence. **Enforcement.** `refresh_token` checks the row when the token carries a user, and returns `invalid_grant` if it is revoked. Revoking does not kill live access tokens, so access ends within one access-token window (`APPLICATION_ACCESS_TOKEN_EXPIRES_IN`, 30 minutes) rather than instantly. The alternative is a DB read on every API request, which is not worth it for a 30 minute tail; the UI should say so. **RFC 7009 revocation now revokes.** Revoking a refresh token revokes the authorization behind it. It also now checks the token was issued to the client asking, which it never did before. That check did not matter while revocation was a no-op; it does now. **Introspection** reports a refresh token inactive once its authorization is revoked. Access tokens keep reporting active until they expire, because they genuinely still work. **API:** `currentUserApplicationAuthorizations` and `revokeApplicationAuthorization`, both behind `UserAuthGuard`. The mutation scopes by `userId` inside the `UPDATE` rather than read-then-write, so one user cannot revoke another's authorization by guessing an id. ## Backwards compatibility Refresh tokens already in the wild have no row. Rejecting them would sign every live integration out on deploy, so the first refresh backfills the grant that was always implied. A revoked authorization keeps its row, so this never resurrects access someone turned off, and the backfill is insert-only so it cannot overwrite a real consent. If the user has since left the workspace, the refresh fails instead. Those tokens carry no scope claim and no record of when consent was given, so `scopes` and `lastAuthorizedAt` are nullable and left null on a backfilled row. Null means "the original consent is not on record" rather than a guess assembled from what the application declares today; a real re-authorization fills both in. Revoking such a token lays the row down before marking it, so the revocation sticks instead of being undone by the next refresh. ## Not in this PR The settings UI, following how #23643 shipped the sessions API and #23645 the devices screen. Introspection still reports a refresh token active once the membership is gone. That matches access tokens, which genuinely keep working in that case, so closing it belongs with the wider question of validating membership on every application-token request. ## Testing - 29 unit tests across the authorization service and the three OAuth grant paths - 9 integration tests on `/oauth/token`, `/oauth/revoke` and the GraphQL API: scopes as granted are recorded, revoking blocks the next refresh, re-authorizing reinstates, a pre-record token backfills without inventing a consent, a revoked pre-record token stays revoked, the authorization is listed to the user who granted it, revoking from that list stops the refresh token being redeemed, a repeated revocation reports no-op, and another user can neither see nor revoke it - the cross-user isolation and revoke-from-list tests are mutation-checked: dropping the `userId` scoping from `revokeAuthorizationById` fails only the isolation test, and disabling the `revokedAt` check in `oauth.service.ts` fails the revoke-from-list test plus two pre-existing ones - full `twenty-server` suite green - instance command applied against a fresh `database:reset`, table/index/FK shape verified against `information_schema` Closes part of https://github.com/twentyhq/core-team-issues/issues/2747 --------- Co-authored-by: prastoin <45004772+prastoin@users.noreply.github.com> |
||
|
|
267ecb12db | Migrate web auth from localStorage token pairs to httpOnly cookie sessions (#23642) | ||
|
|
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>
|
||
|
|
9e2c870574 |
fix(workflow): clear nextStepIds when converting a step to If/Else (#23714)
## Context Fixes #22947. A workflow with If/Else branches could fail at runtime with `Step not found` (and no detail in the Runs panel) because of dangling `nextStepIds` in the workflow graph — references to steps that no longer exist. ## Root cause An If/Else routes only through `settings.input.branches[].nextStepIds`; its top-level `nextStepIds` is never read by the executor and must stay empty. But converting an existing step into an If/Else copied the previous step's `nextStepIds` onto the new If/Else, leaving a stray top-level reference. That reference is invisible to the executor, and when steps around it are later deleted it becomes dangling and propagates into a normal step's `nextStepIds`, which the executor then tries to follow — `Step not found`. ## Fix When a step's type is changed to If/Else, don't carry over the previous step's `nextStepIds`. One change in `workflow-version-step-update.workspace-service.ts`. ## Verification Reproduced on a local instance via the editor's GraphQL mutations (build `trigger → P → X → D`, convert X to If/Else, delete D then X): - Before: converting X produced a stray `nextStepIds: [D]`, and after the deletes P was left with a dangling reference. - After: converting X yields `nextStepIds: []`, and P stays clean — no dangling reference. |
||
|
|
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> |
||
|
|
e81fdbcc7a |
feat(workflow): variable pickers for Search Records limit, offset and date filters (#23696)
## Summary <img width="491" height="390" alt="Capture d’écran 2026-08-03 à 11 30 40" src="https://github.com/user-attachments/assets/6db59b8a-3e8e-41b8-80b3-c736e56b7f7f" /> Adds workflow variable pickers to the **Search Records** action for fields that previously only accepted static values: - **Limit** and **Offset** number inputs now expose the `WorkflowVariablePicker`, so they can be bound to a variable from a previous step. The stored value can be a standalone variable string; the backend coerces the resolved value back to a number. - **Date filters** using the `Is before` (`IS_BEFORE`) and `Is after or equal` (`IS_AFTER`) operands now expose the variable picker in the advanced filter side panel (previously disabled for all date filters). The backend already resolves these inputs via `resolveInput`; the only backend change is a small numeric coercion of the resolved limit/offset. ## Changes - `WorkflowEditActionFindRecords.tsx` — pass `WorkflowVariablePicker` to the Limit/Offset inputs; make `onChange` and form state variable-aware (`number | string`). - `AdvancedFilterSidePanelValueFormInput.tsx` — enable the date `VariablePicker` only for `IS_BEFORE` / `IS_AFTER`. - `useGetRecordFilterDisplayValue.ts` — return the raw variable for a standalone `{{variable}}` value so date filters don't crash `Temporal.*.from`. - `find-records-action-settings-schema.ts` — allow a string (variable) for `limit` / `offset`. - `find-records.workflow-action.ts` — coerce resolved `limit` / `offset` to numbers before querying. ## Testing Built a workflow locally (Manual trigger → Code step returning `{ limit: 2, offset: 1, sinceDate }` → Search Records) with all three fields bound to those variables. The run completed successfully; the Search Records step returned exactly 2 records (limit applied) filtered by `createdAt >= sinceDate`, confirming the backend resolves each variable. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23696?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. --> |
||
|
|
9e25121616 |
Fix ReconcileIndexViewUniversalIdentifier failing on occupied derived identifiers (#23647)
## Context
The 2.26 upgrade failed on 93 workspaces at
`ReconcileIndexViewUniversalIdentifier` with `duplicate key value
violates unique constraint "IDX_552aa6908966e980099b3e5ebf"`. The
colliding identifiers decode to standard objects' INDEX views (task in
91 of 93 cases, plus person, company, opportunity, messageThread).
## Root cause (verified against production data)
A fleet-wide scan of live INDEX views whose `applicationId` differs from
their object's `applicationId` returned one row per failing workspace,
matching the failure list one-to-one including every anomaly. The shape
is always the same: a **legacy caller-created view with `key: INDEX`**
(predating the flat view validator rejecting caller-provided INDEX
keys), **attributed to the workspace-custom application but sitting on a
standard object** (usually task), with a random v4 `universalIdentifier`
and `isSystemSideEffect: false` — coexisting with the object's real
INDEX view, which already holds the derived identifier. These were
minted by the old UI default-view bootstrap: views are attributed to the
caller's application context (workspace-custom) regardless of the object
they sit on. Both producer paths are closed by 2.26 itself (validator
rejects caller INDEX keys, side-effect engine provisions INDEX views at
object creation), so this cleans a closed wound.
The command selected candidates by the view's application (engine-owned)
but derived the identifier from the object's application, so it tried to
UPDATE the legacy view onto the exact identifier the real INDEX view
already holds, violating the unique index on `("workspaceId",
"universalIdentifier")`.
## Fix
The command stays updates-only (no inserts, no deletes), with one
decision per candidate view:
- **View's application differs from its object's application** → demote:
`key: null`, plus `isSystemSideEffect: false` if it was stamped as
system-owned. An INDEX view belongs to the application of its object;
the demoted row becomes a plain caller-owned view (same id, name,
fields, filters). This resolves the 93 failures.
- **View already holds its derived identifier** → at most flip
`isSystemSideEffect` to `true`.
- **Derived identifier free** → claim it. **Held by any other row** —
active or soft-deleted (the unique index is not partial on `deletedAt`,
flat maps are loaded `withDeleted`) — or claimed earlier in the same run
→ skip with a warning instead of crashing the workspace upgrade. Same
resolution for view fields.
## Deliberate non-goals
- **Demotion does not touch `universalIdentifier`**: releasing a held
derived identifier would let the backfill command insert a bare
duplicate INDEX view next to the user's customized one; the identifier
stays put and drifted holders are repaired at the data level instead.
- **A skipped view keeps its view fields untouched**: derived view-field
identifiers encode "field F on view D", so a view that cannot claim D
must not stamp its fields with D-derived identifiers (they belong to the
actual holder's field space). Fields converge only when their parent
view converges.
- **No tombstone deletion**: no production failure traced to a
soft-deleted holder; an occupied identifier is skipped, not
destructively freed.
## Release sequencing
The 2 workspaces failing at `DemoteAndBackfillApplicationIndexView`
(Sales, Synergentic) carry the same cross-attribution on objects of
installed applications, but their July reconcile run committed and
stamped the drifted views with the derived identifiers. **Before
re-running the upgrade**: repair their 5 drifted views by re-pointing
`view."applicationId"` at the object's application (SQL in the internal
runbook), then flush the workspace metadata cache (`cache:flush`) so the
commands don't read the stale attribution. After that, both commands are
no-ops there, and the 93 converge on the re-run.
## Test plan
- 7 new unit tests: cross-application demotion (external-app object,
system-flag reset on a stamped drifted view, and the production shape of
a workspace-custom view next to the standard INDEX view), skip on active
and on soft-deleted holders, first-claim-wins on same-object duplicates.
- All 14 pre-existing tests pass unchanged; lint and typecheck pass.
|
||
|
|
00ad1544d8 |
Classify OAuth refresh errors by reason instead of status code (#23705)
Both provider parsers treated unrecognised failures as permanent, so a single transient error marked a working account as needing reconnection and it never recovered on its own. Permanence is now decided by the provider's OAuth error code, everything else is temporary and retries. Checked against prod: 15 connected accounts currently flagged auth-failed still return a valid token when refreshed, and 12 of those were flagged in bursts across unrelated workspaces (five within 90 seconds on 2026-01-13), which points at a transient blip rather than users revoking access. --------- Co-authored-by: neo773 <huzef@twenty.com> |
||
|
|
7c797ff0c9 |
Filter connected account webhook renewal to exclude accounts with failed authentication errors (#23694)
/closes TWENTY-SERVER-J1F <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23694?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> |
||
|
|
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. --> |