Commit Graph
544 Commits
Author SHA1 Message Date
Thomas des Francs 984781d2e5 [3/4] Desktop Recorder: add the Twenty desktop app (#25616)
Add the complete Twenty desktop application for recording calls locally.

- View upcoming meetings and start recordings; retry failed joins and
capture attempts.
- Keep workspace state consistent if connection setup fails.
- Show five recent recordings; open full history and search in Twenty.
- Reuse the CLI OAuth flow from `twenty remote:add`; offer backend
installation after sign-in if needed.
- Package as a Yarn workspace, with native packaging isolated and safe
Electron restarts during development.

**Scope:** production implementation under `twenty-companion`, its
workspace/lockfile entries, and desktop CI. The focused test suite
follows in #25900 so both reviews remain bounded.

**Latest validation:** the combined stack passes 115 desktop main/shared
tests, desktop typecheck, and changed-file lint. Live browser sign-in
remains to be verified.

**Before release:** signed distribution and an end-to-end install on a
current server.

**Merge order:** #25632#25633#25616#25900.

<details>
<summary>Screenshots — desktop preview with sample data</summary>

**Home: upcoming meetings and recent recordings**

![Desktop
home](https://github.com/user-attachments/assets/7aa55c3a-8126-47f4-b0a2-66a9d3ed3aac)

**General settings and meeting preferences**

![Desktop
settings](https://github.com/user-attachments/assets/e190c9fb-baaf-4916-be56-2250f9ee372e)

</details>
2026-09-16 11:37:23 +00:00
martmull 330cf9c39d Publish Twenty app skills as a portable Agent Skills collection (skills.sh) (#25913)
Closes #22892.

Publishes the Twenty app skills as a harness-agnostic Agent Skills
collection installable with the open `skills` CLI.

```bash
npx skills add twentyhq/twenty/packages/twenty-agent-skills --list
npx skills add twentyhq/twenty/packages/twenty-agent-skills --skill create-app
```

Works in Claude Code, Codex, Cursor, Pi, and anything else the CLI
supports.

## How it is built

One canonical source, two generated distributions, validated on both
ends:

```
packages/twenty-agent-skills/
  skills/           canonical SKILL.md x5  ─┐
  references/       canonical docs         ─┤
                                            │  validate-source.js   (is the source correct?)
                                            ▼
                                        build.js
                                            │   rewrites ../../references/ links to skill-relative,
                                            │   copies the transitive closure of referenced docs
                                            ▼
  dist/skills/        portable skills      ─┬─ validate-distribution.js  (does dist match the source?)
  dist/.codex-plugin/ Codex plugin         ─┘
                                            │
                                            ▼
                                     verify-install.js
                                     (does what the CLI installed match dist, file for file?)
```

Each installed skill directory is self-contained: no monorepo paths, no
sibling-skill or Codex-wrapper dependencies.

The five skills: `create-app`, `develop-app`, `manage-app`,
`publish-app`, and the optional `use-twenty-mcp`.

## What changed here

**Single source of truth.** `references/concepts/operating-rules.md` is
the only copy of the operating rules. `AGENTS.md` and every SKILL.md
link to it and keep only their own additions;
`assertOperatingRulesSingleSource` fails the build if any of them
restates a rule headline, copies a sentence from a rule body, or drops
the pointer.

**Real install verification.** `verify-install.js` discovers every
installed copy of a skill (`.claude/skills`, `.agents/skills`, symlinks
included) and diffs it against `dist/` in both directions, then re-runs
the self-containment and frontmatter assertions on the installed tree.
CI installs each skill into Codex and Claude Code separately, then the
whole collection, and verifies each one. A `main`-only job exercises the
two remote URL forms, which can only resolve once merged.

**Content regenerated against current `main`.** The previous attempt
(#23011) carried July content (`yarn twenty dev --once`, no
standalone-page layouts, a stale validator expectation).

**Self-hosted is first class.** `references/use-twenty-mcp/setup.md` is
now multi-harness MCP setup with user-supplied workspace URLs, custom
domains and localhost as first-class targets. Validation fails if
`create-app` stops presenting self-hosted as supported, or if MCP setup
starts requiring a `twenty.com` workspace.

**Telling the two skill families apart.** The repo also ships
`syncable-entity-*` skills, which are internal guides for changing
Twenty's own server code. Their descriptions now open with "Contributing
to the Twenty codebase itself (twentyhq/twenty server internals), not
for building apps on top of Twenty", `SKILLS.md` at the repo root maps
every skill family and who it is for, and the README and quick-start
point at the app skills with the package-scoped command.

**Cursor cleanup** (from #25946, approved and merged into this branch,
so both land together): `.cursor` is gone entirely — 16 `.mdc` rules,
the background-agent config, the `CLAUDE.md` pointer, a gitignore entry
and a `.cursorrules` editor association for a file that does not exist.
The six `syncable-entity-*` skills moved to `.claude/skills/` next to
`qa-scout`. That changes the bare-repo listing (`.claude/skills` is
default-scanned, `.cursor/skills` was not), which is what the
description prefix above is for. The package-scoped listing is
unaffected and still returns exactly the five app skills, asserted by
`agent-skills-validate`.

**One package, not two** (#25970): `twenty-codex-plugin` used to hold a
hand-synced copy; it was folded into this package so both distributions
build from one source.

**Review follow-ups:** `verify:install` is an nx target rather than a
raw node invocation; `CANONICAL_SKILL_NAMES` has a single definition;
the `scripts/` tree is ESM.

## Verified

- `validate-source` → `build` → `validate-distribution` pass, plus 72
unit tests.
- Real `skills` CLI: all five skills install into Claude Code and Codex,
individually and together, each installed tree matching `dist/` file for
file in both directions.
- Self-hosted scaffold-and-develop smoke test against a local server:
scaffolded an app, added an object with `yarn twenty dev:add`, synced
with `yarn twenty apply`, confirmed `objectMetadata`,
`navigationMenuItem` and the physical workspace table landed. Recorded
in `SMOKE-TEST.md`.

## Known, not addressed here

`create-twenty-app` still calls `yarn twenty dev --once` internally
while the skills direct agents to `yarn twenty apply`. The skill content
is consistent; the scaffolder's internals lag. Worth a separate issue.
2026-09-16 09:13:22 +00:00
Raphaël Bosi a3ba7be23d Add live previews to Twenty UI documentation (#25889)
Add embedded Storybook previews to all 17 Twenty UI component
documentation pages, with light and dark themes and interactive
examples.

On `main` and SDK or UI release tags, `CD Storybook UI` builds the
public Storybook, checks that the documentation's embedded story IDs
exist, and uploads the static site as a GitHub Actions artifact.

After the build, it dispatches `deploy-storybook-ui.yaml` in
`twenty-factory` with the source run ID. Factory validates the
successful source run, downloads its artifact, and publishes the static
files to Cloudflare Pages without executing the downloaded site.
Cloudflare credentials stay in factory's `storybook-ui` environment.

`main` updates the production site; release tags publish version aliases
such as `v2-42-0.twenty-ui-storybook.pages.dev`.

Merge [twenty-factory
#120](https://github.com/twentyhq/twenty-factory/pull/120) first and
complete the Cloudflare project, token, and GitHub environment setup in
its description before merging this PR. That description also covers
validating the first deployment and switching `storybook.twenty.com` to
Pages.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25889?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. -->
2026-09-15 11:24:07 +00:00
Félix Malfait d927876015 fix(create-twenty-app): ship a lockfile so generated apps install under an age gate (#25879)
## The bug

A project scaffolded right after the 2.40.0 release could not install:

```
YN0016: twenty-ui@npm:1.0.0-alpha.2: All versions satisfying "1.0.0-alpha.2" are quarantined
```

## Root cause

Not publication timing, and not the exact version pins — **the template
ships an empty Yarn *v1* lockfile into a Yarn 4 project.** Yarn 4
discards it without a warning, so every install re-resolves all three
first-party packages from the registry.

A minimum release age (Yarn's `npmMinimalAgeGate`, pnpm's
`minimumReleaseAge`, npm's `min-release-age`) is a **resolution-time**
control. Reduced from the Yarn 4.13.0 bundle:

```js
candidates = versions matching range
approved   = candidates.filter(isPackageApproved)
if (candidates.length > 0 && approved.length === 0)
  throw new ReportError(16, `All versions satisfying "${range}" are quarantined`)
```

An exact pin makes the candidate set a singleton, so the gate has no
older candidate to fall back to — it can only throw. Re-resolution is
the only thing that exposes a project to it.

## Why the obvious fixes don't work

**Publish earlier.** Already done. Per npm's own publish times, 2.40.0
gave `twenty-ui` a **52 min 47 s** head start (`15:47:40Z` →
`16:40:27Z`). A gate is measured in days; the consumer picks the delay
*after* we publish, so no schedule can guarantee anything.

**Loosen to ranges.** Worse. Same 7-day gate, real `twenty-sdk`
versions:

| Range | Outcome |
|---|---|
| `2.40.0`, `~2.40.0`, `^2.40.0`, `^2.39.0` | quarantined — identical
failure |
| `^2.35.0`, `*`, `latest` | resolves to **2.38.0**, three minors behind
the template shipping with it |

Loud failure beats silent version skew against templates and docs
written for the new SDK.

**A custom dist-tag** installs cleanly under a gate — because Yarn never
age-checks non-`latest` tags. Undocumented, closable in any patch, and
it defeats the user's policy without anyone noticing.
**`npmMinimalAgeGate: 0`** in the generated `.yarnrc.yml` voids
inherited org-wide config from a file the user never wrote. Both
rejected.

## The fix

Generate a real Yarn 4 lockfile at release time and ship it in the
template. A resolved lockfile entry is never re-resolved, so the gate
never applies — and unlike the alternatives it changes no configuration:
the user's gate stays in force for every dependency they add afterwards,
and what they get instead is an explicit, integrity-hashed, reviewable
set of versions in their first commit.

**The trade this makes, stated plainly:** a shipped lockfile is
installed without re-resolution, which is exactly what lets it satisfy a
consumer's gate and equally what stops that gate from ever inspecting
these versions. Generation is therefore the only point at which an age
gate can apply to the tree a scaffolded project receives — so the
generator holds the monorepo's own `3d` gate and waives it for nothing
beyond the exact first-party versions being published.

## Changes

- **`create-twenty-app:generate-template-lock`** — new nx target
resolving the template against a given registry, under a `3d` age gate,
waiving only the exact `name@version` of the three packages this release
publishes. Refuses to emit a lockfile missing a package, lacking an
integrity checksum, or carrying `__archiveUrl` registry pins. Asserts
the template's `packageManager` matches the monorepo's pinned Yarn,
since a mismatch produces lockfiles the user's CI rejects with YN0028.
- **Dropped the inert v1 stub.** A build without a generated lockfile
still scaffolds working projects; the publish workflow refuses to
release in that state.
- **`install()` now fails instead of warning.** It previously caught a
failed `yarn install`, `console.warn`ed, then went on to git-init,
authenticate, sync and print "Application created successfully" over a
project with no `node_modules`.
- **Actionable YN0016 message** naming the packages Yarn actually
quarantined — parsed from its own output, not assumed to be the
first-party three — and offering a *package-scoped* waiver, never an
instruction to lower the gate. A quarantined dist-tag is waived by bare
name, since `npmPreapprovedPackages` matches with `semver.validRange`
and `pkg@latest` parses to `null`.
- **`yarn install --no-immutable`** during scaffolding. Yarn installs
immutably when `CI` is set, but this install is what rewrites the
lockfile's workspace root entry to the project's real name; immutable
mode rejects that with YN0028.
- **`twenty-ui` added to the monorepo's own `npmPreapprovedPackages`** —
it listed the two SDKs but not `twenty-ui`, part of why dogfooding never
surfaced this.
- **`RELEASE.md`** documenting the ordering constraint.
- **E2E restructured** to publish libraries → generate lockfile →
publish scaffolder, then scaffold and install **under a 30-day gate**.
The old E2E published seconds-old packages with no gate configured, so
it was structurally blind to this and passed while the release was
broken.

## Verification

Eleven experiments on the repo's own Yarn 4.13.0 against the live
registry, plus full runs against a local registry carrying packages
**published seconds earlier**:

| Scenario | Result |
|---|---|
| Reproduce with exact pin + 7d gate, no lockfile | `YN0016` — verbatim
production error |
| Empty v1 stub + 7d gate | `YN0016` — lockfile silently ignored |
| Control: scaffold with no lockfile, 30d gate | `YN0016` |
| **Real CLI scaffold + install, 30d gate** | **passes in 3 s**, all
three packages at the pinned version |
| Generated project's own `yarn install --immutable`, 30d gate |
**passes** |
| Lockfile committed by the scaffolder's initial commit | yes, 1256
lines |
| Generator gate is live: waived `twenty-ui@2.41.0`, seconds old |
resolves |
| Generator gate is live: unwaived `twenty-ui@2.41.1`, seconds old |
**quarantined** |

`tsgo --noEmit` clean · `oxlint` 0 errors · 20/20 jest tests (8 new).

Pairs with twentyhq/twenty-infra#944, which builds the lockfile between
publishing the libraries and publishing the scaffolder, adds
post-publication verification, and makes a partially-completed release
resumable.

## Docs

No app-docs change needed under `developers/extend/apps/`, verified by
hand twice — once for the original diff and again after `SETUP.md`
changed. The `Empty yarn.lock` entry in
`getting-started/troubleshooting.mdx` covers the SDK's
`validateYarnLock`, which triggers on a **zero-byte** file; the stub
removed here was two comment lines, so that entry was never about it.
`operations/publishing.mdx`'s `yarn install --immutable` line stays
accurate — and now actually succeeds on a generated project's first
push. `scaffolding.mdx` documents `twenty generate`, not this CLI.

The generated project's own `SETUP.md` does change: it previously
offered "delete `yarn.lock`" as the way to let your own age gate choose
versions, which walks the reader straight into the failure this PR
fixes. It now says deletion re-resolves pins published alongside the
scaffolder, that the install stops at `YN0016` until they age past the
gate, and points at the scoped waiver as the alternative to waiting.

## What this does not fix

`npx create-twenty-app@X` declares an exact runtime dependency on
`twenty-sdk@X`, which declares one on `twenty-client-sdk@X` — three
same-day packages resolved by npm with no lockfile in the picture. A
user with an npm or pnpm cooldown cannot scaffold on release day; that
is arguably their policy working as intended. Bundling the SDK is not a
way out (`esbuild` and `sharp` ship platform binaries); the real path,
if it becomes pressing, is deferring the CLI's SDK usage to the
generated project's local `twenty` binary.

Separately: `twenty-ui`'s `latest` dist-tag on npm still points at
`1.0.0-alpha.0` from June, published under AGPL-3.0 while the package is
now MIT. The next stable release fixes this by publishing under
`latest`.
2026-09-14 13:41:33 +02:00
Raphaël Bosi a44c8bdf92 Add Twenty UI documentation and generated API references (#25791)
Add a UI Library documentation section so developers can get started
with Twenty UI, customize its theme, and use its components.

The section has guides for setup, theming, design tokens, dark mode,
server rendering, and accessibility, plus a page per component (17 so
far) with usage examples and a generated props reference.

How the references are generated and kept in sync:
- `nx generate:ui twenty-docs` extracts prop types and JSDoc from the
twenty-ui components with react-docgen-typescript and the design tokens
from the token pipeline, writes them to `packages/twenty-ui/generated`,
and renders MDX snippets under
`packages/twenty-docs/snippets/ui/generated`.
- `nx check:ui twenty-docs` runs in CI Docs and fails when the data or
snippets are stale, when a page and the generated snippets disagree, or
when a guide example does not compile against the public entry points
and peer dependencies.
- Twenty UI's own props now carry JSDoc descriptions, and generation
fails on an undocumented prop.

Old `/twenty-ui/*` URLs redirect to the new pages. The pages use the
existing Crowdin translation workflow; the generated snippets are shared
across locales.
2026-09-14 08:03:21 +00:00
Félix Malfait dfa3a39efe Cover every translation pipeline with the normalizer (#25848)
The website pipeline had no normalize step at all, though it carries the
same Lingui PO catalogs, through the same machine translator, as the
app. It is clean today only because it holds eight times fewer strings
(22,620 pairs against 173,789).

**Website gets a normalize step.** The largest uncovered surface, closed
with four lines of YAML.

**Rules declare their catalog formats and the CLI selects with
`--format`.** A new rule now protects every project holding that format,
instead of only the workflow someone remembered to add it to. The ICU
rules stay off MDX, where braces are JSX and would false-positive, so
docs runs exactly the rule set and the targeted scan it ran before.
`--rules` survives for narrowing a run by hand.

**All three pulls normalize before downloading**, as the docs pull
already did, so a repair lands in Crowdin before anything is fetched
rather than a cycle later. Each normalize step now carries the same
`force_pull` condition as the download it precedes, so a `workflow_call`
asking for no download no longer mutates Crowdin.

**`escaped-unicode` only decodes escapes the source does not itself
carry**, and stands down entirely when the source is unknown, matching
its three peers. A source that writes `\uXXXX` itself, such as a code
sample, now survives translation.

**`Record(s) selected` becomes `Records selected`.** The `(s)` invited a
plural that five locales invented on a call site that passes no `count`;
it is a mode label, not a count.

This is the repo-side net. The matching Crowdin changes are already
live: the app project's AI QA category was switched off entirely, so an
ICU structural QA prompt now runs there and on the website project, and
the shared pre-translation prompt no longer instructs the model to add
plural forms to sources that have none.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25848?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture>``&lt;source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;&lt;source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"&gt;&lt;img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</picture></a>
<!-- End of auto-generated description by cubic. -->
2026-09-13 13:52:04 +02:00
Jan Amann 3274fd9771 Add eloqnt lint and fix ICU placeholders in source strings (#25754)
Fixes source strings whose ICU placeholders were single-quoted, so the value rendered literally, and rewrites plural messages to use # instead of positional placeholders. Adds @eloqnt/cli and runs it as a non-blocking report in the i18n pull workflow.
2026-09-13 12:44:58 +02:00
Félix Malfait 40040983de Repair machine-translation damage in Crowdin automatically (#25842)
Fixes translations corrupted by the Crowdin AI step — leaked response
envelopes, invented markup, dangling ICU arguments — in Crowdin itself,
so the fixes survive the sync. Details in the commit messages.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25842?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. -->
2026-09-13 11:45:54 +02:00
Félix Malfait 211303ad8f feat(ai): generate every deployment catalog from the model catalog and a spec (#25831)
First of two. This one adds the generator and puts self-host on it; the
second gives twenty-infra its private specs.

## Why

The daily sync keeps one record of what every model is: identity and
pricing from models.dev, intelligence, speed and cost per task from
Artificial Analysis, effort levels carried over by hand. That is the
truth about models.

A deployment serves a subset of those models through its own routes, and
until now it restated them. Each cloud environment carried a second
catalog with its own copy of prices, labels, context windows and
modalities, written by hand and kept in step by nothing. Diffing those
files against the catalog showed the result: almost every model was a
verbatim copy, and where they differed it was a stale copy rather than a
deliberate change. prod-eu lists the OpenAI models with `modalities:
["image"]` against the catalog's `["image", "pdf"]`, so it is quietly
refusing PDFs.

The fields never copied at all are the ones that mattered. A deployment
catalog carries no `efforts`, so no effort variant is registered and
every tier chain entry pinned at an effort is unreachable. It carries no
`benchmark`, so the product has nothing to compare models with.

## The shape

`ai-providers.json` used to be two things at once: the truth about
models, and one deployment's view of it. That is why only cloud could
get a spec. Splitting the two makes self-host and cloud the same
mechanism:

| File | What it is |
| --- | --- |
| `ai-models.json` | What every model is. No routes, no credentials.
Synced daily. |
| `ai-self-host-spec.json` | What a self-hosted deployment serves: the
five direct routes and their key templates. |
| `ai-providers.json` | What the server bundles. Generated from the two
above. |

Cloud is the same projection over a private spec. There is one generator
and two callers.

## What a spec says

Only what belongs to the deployment: which routes exist, their
credentials, and which catalog models each route serves.

```json
{
  "providers": [
    {
      "name": "azure-foundry",
      "npm": "@ai-sdk/azure",
      "apiKey": "{{AZURE_FOUNDRY_API_KEY}}",
      "dataResidency": "eu",
      "labelSuffix": " (Azure)",
      "models": ["gpt-5.6-luna", { "model": "gpt-5.6-sol", "cachedInputCostPerMillionTokens": 0.5 }]
    },
    { "name": "openai", "npm": "@ai-sdk/openai", "models": { "vendor": "openai" } }
  ]
}
```

A route may rename a model it deploys under another id, and override the
prices it negotiated, the label and the deprecation flag. It cannot
restate what a model is: context window, modalities, efforts, benchmark
and per-effort benchmarks always come from the catalog. That is the
property that makes the drift above impossible rather than merely fixed.
Naming a model the catalog does not carry fails the run instead of
publishing a route to nothing, which is the failure that put every tier
on one model.

The second form is how self-host works: a route serving a whole vendor
picks up new models with the daily sync instead of waiting for an edit.
The provider labels and key templates that were hardcoded in
`build-catalog.util.ts` now live in the spec.

## Nothing changes for self-hosters

`ai-providers.json` is unmodified in this PR: the projection of
`ai-self-host-spec.json` over `ai-models.json` reproduces the committed
file exactly, key order included, so the diff is zero. A new test fails
if it ever stops matching, which is what stops the shipped catalog being
hand-edited back into a second copy.

## Runnable from a sparse checkout

`project.ts` resolves everything by source path, node builtins and
`packages/twenty-shared/src/ai` for the two guards. A repository holding
a private spec runs it with `npx tsx` against a sparse checkout, no
monorepo install and no API key. Verified in a bare directory holding
only the script, that shared directory and `ai-models.json`:
twenty-infra's prod-eu spec produces its committed catalog byte for
byte.

## Scope

The daily sync now writes `ai-models.json` and projects self-host from
it. 153 tests pass across the sync scripts and the ai-models module.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01N5ctWwYtKiq5VdfDG7ZvNy)_
2026-09-13 10:12:59 +02:00
Thomas des Francs d168df965e Simplify page layout manifest authoring (#25513)
## Summary

Vertical-list widgets can now omit `position` and use array order, with
an optional top-level `heightBehavior`. For example, `heightBehavior:
'TAB_VIEWPORT'` makes a widget fill the tab without repeating the tab
mode or an index.

Existing Canvas tabs, explicit widget indices, and nested height
settings keep their behavior. SDK validation warns about deprecated
shapes and explains the replacement; it does not rewrite manifests.
Shared validation also checks the new height setting and viewport
ordering before installation.

Existing apps keep their published SDK syntax. This PR updates the local
SDK fixture and current API documentation; app migrations can follow
after the SDK is published.

Stack 3/4, following the CI prerequisite and runtime height support. The
final PR migrates eligible stored Canvas tabs.

Review and merge order:

1. [App CI repairs](https://github.com/twentyhq/twenty/pull/25531)
2. [Widget height
behavior](https://github.com/twentyhq/twenty/pull/25512)
3. [Manifest authoring](https://github.com/twentyhq/twenty/pull/25513)
4. [Existing Canvas data
migration](https://github.com/twentyhq/twenty/pull/25511)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25513?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. -->
2026-09-11 15:32:47 +00:00
Thomas des Francs 3c128b3dae Fix app CI fixtures and registry upload limit (#25531)
The app CI jobs fail before exercising the app behavior: the local
registry rejects the SDK publish payload with HTTP 413, and the Last
Contact fixture tries to create a platform-owned message-channel
association.

Raise the test registry request limit to 50 MB and remove the unused
association from the Last Contact fixture. Production permissions stay
unchanged.

Split out of #25513 so the widget/layout stack can be reviewed
independently. Call Recorder already has its fixture repair on main in
#25412, so that duplicate change is omitted.

Validation: these two fixes are unchanged from the passing CI run on
#25513. The new branch runs CI independently against main.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25531?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. -->
2026-09-11 14:08:31 +00:00
Weiko e8f9a165c7 Let the e2e local registry accept twenty-ui (#25765)
## What

`create-app-e2e-minimal` fails for every pull request that runs it, at
the "Publish packages to local registry" step:

```
➤ YN0041: Invalid authentication (as an unknown user)
➤ YN0000: Failed with errors in 0s 813ms
Error: Process completed with exit code 1
```

The workflow publishes `twenty-client-sdk twenty-ui twenty-sdk
create-twenty-app`, but `.github/verdaccio-config.yaml` grants `publish`
to only three of them. `twenty-ui` falls through to the `'**'` entry,
which sets `access` and a proxy but no `publish`, and verdaccio defaults
publish to `$authenticated`. The job authenticates with a placeholder
`npmAuthToken` and no htpasswd user, so the request is rejected as an
unknown user.

This has been failing since `twenty-ui` joined the publishable set. It
is not intermittent: retries fail identically, because nothing about it
depends on timing.

Main looks unaffected only because the job is gated on changes under
`packages/create-twenty-app/**`, `packages/twenty-sdk/**`,
`packages/twenty-client-sdk/**` and `packages/twenty-shared/**`. Most
pushes to main touch none of those, so the job is skipped and the run is
green. On the runs where main did execute it, it failed with this same
error.

## How

`twenty-ui` gets the same rule the other published packages already
have, so the local registry accepts it without authentication like its
siblings.

Adding the rule rather than dropping `twenty-ui` from
`PUBLISHABLE_PACKAGES`, because its version is aligned with the SDK's,
so the scaffolded app resolves `twenty-ui` at the CI version from this
registry. Removing it from the list would move the failure to the
install step instead of fixing it.

## Verification

- Reproduced and fixed locally, running verdaccio exactly as the job
does. Publishing a package named `twenty-ui` against the current config
fails with `E401 Unable to authenticate`, which is this failure; against
the config in this PR the same publish succeeds with `+
twenty-ui@0.0.0-authtest`.
- The config parses, and every package the workflow publishes now has a
publish rule.
- The failure and its cause were read from the job logs of a run that
executed the step, and confirmed identical on a run from `main`.
- Size is not a concern for this registry's default 10 MB body limit: a
clean `twenty-ui` build is about 11 MB across 872 artifacts before
compression, so the tarball lands well under it.
2026-09-10 21:01:42 +00:00
Raphaël Bosi 196d676bbc Align twenty-ui with SDK package versions (#25758)
`twenty-ui` uses an independent `1.0.0-alpha.2` version while the SDK
packages use `2.40.0`. Align UI with the SDK packages and add the shared
`set-local-version` target.

New apps pin `twenty-ui`, `twenty-sdk`, and `twenty-client-sdk` to the
scaffolder's version. The local-registry E2E workflow publishes UI
alongside the SDK packages and checks all three dependency versions.
Source docs and Codex plugin references describe the same policy, with a
validator regression test that catches independent UI version guidance.

This replaces the separate-alpha version pins from #25738 while
preserving its explicit inclusion of the MIT `LICENSE` in the npm
package. Plugin checklist rows touched: R3 (front component guidance)
and R5 (app concepts reference).

Companion: https://github.com/twentyhq/twenty-infra/pull/934 adds UI to
SDK publishing, automatic version bumps, and release notes. Land both
changes before the next version bump or SDK publication.

Validation: 11 scaffolding tests and 35 plugin tests passed; plugin
validation and direct typechecks for `twenty-ui` and `create-twenty-app`
passed. An npm pack dry run confirmed the MIT `LICENSE` is included. The
full registry/server E2E workflow was not run locally.
2026-09-10 16:54:34 +00:00
Félix Malfait f1d00f033d Add benchmarks, data residency and zero data retention to the AI model catalog (#25675)
The catalog sync now merges [Artificial
Analysis](https://artificialanalysis.ai/) benchmark data into
`ai-providers.json` alongside the models.dev facts it already carried,
and emits a second artifact other repos can join against. It also fixes
the level data residency is modelled at, and adds zero data retention
next to it.

## One publisher, one reading, three figures

models.dev is a catalog: it enumerates every model id a provider exposes
plus what you need to call it. It publishes nothing about how good or
how fast a model is. Artificial Analysis publishes exactly that, and
supplies all three figures here: intelligence index, output tokens per
second, and cost per task.

Deliberately one source. An index is only meaningful against models
scored the same way, so a second publisher would have to *replace* these
numbers rather than fill their gaps — filling gaps means the scale a
model is scored on depends on which publisher happened to rate it, and
no consumer can compare two models without knowing which. The overlay
names its publisher once at the top rather than tagging each model.

Cost per task is the figure worth having for a reasoning model: it
counts the tokens a model actually spends thinking, which a per-token
price does not.

That is the whole set, and the field is `benchmark`, singular: one
publisher's reading of one model, taken on one date.
`ai-model-benchmarks.json` stays plural because it holds one of these
per model.

## Requires an API key

Gated on `ARTIFICIAL_ANALYSIS_API_KEY`. Until that secret exists the
sync writes the catalog with no benchmarks and an empty overlay, which
is exactly what this branch commits. All the plumbing, matching and
reporting works; the first sync run with a key populates it.

A fetch failure degrades to an empty index rather than throwing. A
leaderboard outage must never break the model catalog.

## Matching is the actual work

Joining a catalog entry to a benchmark row is most of this diff, because
the two name models differently:

- Date suffixes are stripped, so `claude-sonnet-4-5-20250929` finds
`claude-sonnet-4-5`.
- Rolling aliases resolve to the dated release they point at, by
fingerprinting price and limits against models.dev siblings.
`mistral-large-latest` finds `mistral-large-2512` and re-resolves on its
own when Mistral repoints it. This matters: `mistral-large-latest` is in
both `DEFAULT_FAST_MODELS` and `DEFAULT_SMART_MODELS` and is never
benchmarked under that name.
- Names normalize across separators, case and Bedrock version suffixes.

## Reading the API correctly

Three behaviours of that endpoint, taken from the reader already in
twenty-factory's `ai-catalog-steward` rather than from the docs:

- `0` means "not measured", so a naive read records a zero
tokens-per-second as a measurement.
- It carries several rows per model, some with no measurements. First
row wins lets an empty row displace a measured one.
- Only the speed figure is top level; the index sits under `evaluations`
and cost per task under `cost_per_task`.

None of these surface until a key is configured, which is why they are
covered by tests rather than left to the first real run.

## The overlay

`ai-model-benchmarks.json` sits next to the catalog, keyed by model,
carrying what the catalog embeds plus the alias set the publisher gives.
It exists so twenty-infra's per-environment catalogs can join the same
numbers without merging two catalogs that answer different questions:
this one is "what a self-hoster can plug in", those are "what we run, on
our contracts, in our regions". A capability score is a property of the
model, not the deployment, so it is the one thing that legitimately
crosses that boundary.

A model earns an entry only by being measured, so every overlay row and
every catalog `benchmark` block carries a real figure. That is what
keeps `BenchmarkMatch` and the overlay entry non-partial, and it removes
the price-only row that produced two rounds of phantom-measurement bugs
during review.

twentyhq/twenty-factory#112 consumes this overlay and drops its own
Artificial Analysis fetch as a result, taking the org down to one key
against a 100/day limit and one matcher.

## Data residency and zero data retention

A separate axis that lands in the same file. `dataResidency` already
existed, sat on the **provider**, and was set in exactly zero catalogs,
so the model hover card has been rendering nothing since it shipped.

Provider level cannot be right. `amazon-bedrock` in dev and staging
carries one region while serving both `eu.anthropic.*` and
`global.anthropic.*` models, which do not route to the same place, and a
wrong answer here is a compliance claim rather than a cosmetic bug.
Models now declare their own, falling back to the provider value so an
operator stamping a single-region custom provider through the admin
panel still works unchanged.

`zeroDataRetention` joins it, optional so `undefined` reads as
*unasserted* rather than *no*. Claiming a route is not zero-retention
when nobody has checked is as wrong as claiming it is.

Neither figure is published anywhere. models.dev and Artificial Analysis
carry no residency, retention or training field, so these are
hand-declared, forever. They stay unset in the shipped catalog, because
where a self-hosted instance processes and retains data depends on its
own provider accounts and not on us; a test holds that line. The values
that matter are twenty-infra's per-environment catalogs, which is data
entry for whoever holds the contracts. What this change buys them is
survival: `aiProvidersConfigSchema.parse` runs over the catalog fetched
from S3 and silently stripped both fields until now.

twentyhq/twenty-factory#112 forbids the steward from ever writing them,
since it has nothing it could honestly cite.

This is the data model, not the toggle. Filtering the picker to EU-only
or ZDR-only belongs at `validateModelAvailability`, the single choke
point both the picker and the execution paths already pass through, and
wants the data populated first.

## Also

- The sync PR body now carries a coverage summary naming every unrated
model, so a matcher regression shows up instead of silently emptying the
overlay.
- `ai-sync-models-dev.ts` becomes `scripts/ai-catalog-sync/`; the name
no longer described what it does.
- `ModelsDevModel.release` / `.updated` were dead and did not match the
API, which returns `release_date` / `last_updated`. Corrected, since
alias resolution needs the release date.
- `ai-providers.json` is deliberately untouched. Regenerating it here
pulled in a day of unrelated models.dev price and context-window drift
that the daily sync brings on its own and that only obscures this diff.

## Verification

Sync runs clean and is idempotent. Lint, format and typecheck clean; 43
tests across the matcher, the API reader and the two committed
artifacts.

## Follow-up, not in this PR

`load-default-model-preferences.util.ts` still hardcodes the fast/smart
chains, with a TODO asking for them to be derived from the catalog. That
is what this data unblocks, and the steward is the natural place to do
it.
2026-09-09 18:05:24 +02:00
Raphaël Bosi 8997a6d864 Run direct consumer CI on twenty-ui changes and record known radio failures (#25670)
Changes confined to `packages/twenty-ui/**` could skip consumer CI,
allowing sandbox regressions to reach main. Add that path to the
renderer, SDK, and website workflows, which directly depend on the
workspace UI package. Frontend and UI CI already cover the path.

The Base UI 1.8 upgrade in #25615 introduced an
`Element.matches(':disabled')` call that the renderer sandbox does not
support ([CI
failure](https://github.com/twentyhq/twenty/actions/runs/34342034359/job/102435795459?pr=25655)).
Temporarily use the existing known-failure gallery checks with separate
expectations for each runtime: React must fail exactly `CardPicker`,
`Radio`, and `RadioGroup`; Preact must fail only `CardPicker`, while its
radio controls render successfully. Unexpected failures and partial or
complete fixes still fail the assertions, prompting an expectation
update when sandbox support lands.

Validation:
- Checked eight workflow filters against UI source, stylesheet, and
package-manifest changes, plus an unrelated documentation change.
Renderer, SDK, and website filters match UI changes; email, server,
create-app, and both example-app workflows keep their existing filters.
The five reverted workflow files match the base branch exactly.
- Actionlint reports no new diagnostics; existing local-action metadata
errors remain. Two workflows also have pre-existing Prettier failures.
The story formatting and `git diff --check` pass.
- Replayed the current React and Preact input story tests in Chromium
against the saved CI Storybook build. The original Preact expectation
reproduces the linked CI failure; both stories pass with the
runtime-specific expectations. Confirmed that Preact renders all three
radio controls and that failure reports remain stable after waiting.
Changed-story lint and formatting pass. The full suite was not rerun
locally.
2026-09-09 12:56:58 +00:00
neo773 e7d15f85db Stream nx task output so a killed task still prints its stack (#25560)
Nx buffers task output into an internal stream by default, so a task
that gets killed takes its buffer with it. The job log ends on whatever
last happened to flush and the crash is never printed — an integration
shard currently exits 1 after 18 passing suites with no summary, no
failing test and no stack trace.

`--outputStyle=stream` is nx's own flag for this: "nx by default logs
output to an internal output stream, enable this option to stream logs
to stdout / stderr".
2026-09-07 16:37:09 +00:00
Raphaël Bosi 2fa7f53553 Generate the twenty-ui theme artifacts from a single design-tokens source (#24375)
The two 1,000-line theme CSS files, the `themeCssVariables` accessor,
`ThemeType` and the 44 files under `src/theme/constants` were all
hand-maintained mirrors of the same ~994 values. This PR makes a
committed TypeScript token source the single source of truth and
generates every one of them from it.

## How it works

Token values live in `packages/twenty-ui/design-tokens/`, one file per
token group. `npx nx generateTokens twenty-ui` rebuilds every artifact
and formats it with oxfmt. A `generate:check` step on the CI lint job
reruns the generator and diffs its declared outputs, so a hand edit to a
generated value cannot merge.

```mermaid
flowchart LR
  A["design-tokens/
TypeScript token source"] --> B["scripts/generateThemeTokens.ts"]
  B --> C["theme-light.css / theme-dark.css"]
  B --> D["themeCssVariables.ts
var() accessor"]
  B --> E["themeTypes.ts
ThemeType"]
  B --> F["ThemeLight.ts / ThemeDark.ts / ThemeCommon.ts
resolved static values"]
  B --> G["MainColorNames.ts
ThemeColor vocabulary"]
  B --> H["Animation.ts / GrayScaleLight.ts / GrayScaleDark.ts
standalone subtree constants"]
  CI["generate:check (CI)"] -. "regenerates and fails on drift" .-> B
```

## Why this is useful

- Drift between the light CSS, the dark CSS, the accessor, the types and
the static theme objects becomes structurally impossible. Changing a
token is one edit in one file instead of five coordinated ones.
- `src/theme/constants` goes from 44 files to 10, seven of them
generated (`ThemeLight`, `ThemeDark`, `ThemeCommon`, `MainColorNames`,
`Animation`, `GrayScaleLight`, `GrayScaleDark`); the 35 that only held
values are deleted.
- Inside the token source, semantic tokens reference the primitives they
come from instead of copying them: `grayScale` is the gray scale, each
main color is that palette's `9` step, and `smRound` / `mdRound` are
`sm` / `md`. The round-corner and palette invariants hold by
construction rather than by test.
- `ANIMATION`, `GRAY_SCALE_LIGHT`, `GRAY_SCALE_DARK` and `THEME_COMMON`
are emitted as standalone literals rather than slices of `THEME_LIGHT`,
so importing one does not retain the whole 994-token object in a
consumer's bundle.
- `ThemeType` is generated and fully explicit, which removes the
hand-kept `NumericOverrides` mapping and typechecks faster than the
previous nested mapped types.

## Breaking changes

- The four IllustrationIcon CSS variables are renamed from the
historical double-dash names to `--t-illustration-icon-*`. These ship in
the published `./theme-light.css` and `./theme-dark.css` entrypoints, so
a downstream stylesheet writing `var(--t--illustration-icon-color-blue)`
silently resolves to nothing after upgrading. All in-repo consumers read
them through the accessor, which renames in the same commit.
- 35 exports are removed from `twenty-ui/theme` (`ACCENT_LIGHT`,
`BORDER_COMMON`, `COLOR_DARK`, ...). None had a consumer outside
`src/theme/constants`, and every value stays reachable as a subtree of
`THEME_LIGHT` / `THEME_DARK`. `twenty-front` and `twenty-website` need
no edits.
- `packages/twenty-ui/dist/theme-{light,dark}.css` are no longer tracked
in git. The vite `copy-theme-css` plugin is now their only writer;
twenty-front already required a `twenty-ui` build to resolve
`twenty-ui/style.css`.

## Notes for review

Of the ~6,900 added lines, ~3,350 are generated artifacts and ~2,700 are
machine-extracted token values. The reviewable surface is the ~800 lines
under `design-tokens/pipeline`, `design-tokens/__tests__` and
`scripts/`.

Values were machine-extracted, so the only CSS content diffs are the
header comment, a 2-line radius reorder aligning the CSS with the
accessor's token order, and the IllustrationIcon rename. No visual
change is possible, and Argos is flat. The generators are
byte-idempotent. `THEME_LIGHT.spacing` stays a variadic function, and
`background.noisy` stays a `var()` reference in the static trees rather
than inlining a 100 kB data URL into the bundle.
2026-09-07 11:44:58 +00:00
martmullandFélix Malfait cda35be5b0 Return job handles from enqueueJob(s) and add a getJobs query (#25204)
Closes twentyhq/core-team-issues#2779

`enqueueJob(s)` returned `{ enqueued, logicFunctionUniversalIdentifier
}` and no handle, so an app could never answer "did my job run, is it
still running, did it fail". The 202-then-poll shape was not
expressible. The job id existed at every layer and was thrown away at
the driver boundary.

## Message queue layer

- `MessageQueueDriver.add()` returns the job id (`undefined` when the
existing dedup-prefix check skips the add).
- `bulkAdd()` now takes one item per job (`{ data, jobId? }`) instead of
a flat data array, and returns the ids. Updated at the three existing
call sites (emailing campaigns ×2, database-event trigger).
- New optional `getJobs(queueName, jobIds)` on the driver, implemented
for BullMQ over `queue.getJob()` + `job.getState()` (no bulk `getJob`
upstream, so it fans out over `Promise.all`). It returns
`Partial<Record<jobId, QueueJobDetails>>` — sparse, because an id the
queue no longer holds is simply absent, and the type says so.
- `SyncDriver` now generates a real id instead of `''`.
- `JobState` and its BullMQ mappings moved from `admin-panel/enums/` to
`message-queue/enums/`, so reading job status from `application` no
longer couples two sibling feature modules. The GraphQL enum keeps its
name and members, so schemas are unchanged.

## Apps API

- `enqueueJob` returns `jobId`, `enqueueJobs` returns `jobIds` in the
order of the jobs passed.
- Callers can name their own ids by passing `jobs: [{ payload, jobId
}]`. `payloads` still works and is now deprecated in favour of `jobs`;
exactly one of the two must be set.
- New `getJobs(jobIds: [String!]!): [JobStatus!]!` query on the metadata
API, behind the same `@AuthApplication` guard as the mutations,
returning `state`, `attemptsMade`, `failedReason` and the three
timestamps per job.
- `getJobs` added to `twenty-sdk/logic-function`, with a spec matching
the enqueue helpers.

An id the queue no longer holds is omitted from the result rather than
erroring, so one evicted id cannot fail a whole batch; the caller diffs
what came back against what it asked for. A read is capped at
`MAX_JOBS_PER_STATUS_READ` (200), mirroring `MAX_JOBS_PER_ENQUEUE` on
the write side, so one authenticated request cannot fan out an unbounded
number of Redis lookups.

## Scoping and idempotency

BullMQ job ids are queue-global, so a naive `getJob(id)` would be a
cross-tenant read. Ids are stored prefixed with the workspace id
(`buildQueueJobId`), which makes a cross-workspace read structurally
impossible rather than relying on an ownership check after the fact. The
app never sees the prefix.

The same prefixing makes a caller-supplied id an idempotency key:
re-enqueuing an id the queue still holds is accepted but starts no
second run. Two documented caveats — the guarantee lasts only as long as
the job is retained, and ids are shared across apps within a workspace.

## Introspection of deprecated input fields (three consumers, one root
cause)

GraphQL introspection omits deprecated **input** fields by default
(`inputFields(includeDeprecated: false)`). So deprecating `payloads`
made it *disappear* from introspection rather than be marked deprecated
— a deprecation behaving like a removal, breaking exactly the callers it
exists to keep working. Two consumers are fixed here, because the
requested deprecation is not implementable without them:

- `twenty-client-sdk/scripts/generate-metadata-client.ts` now passes
`inputValueDeprecation: true`, so `payloads` is emitted as
`@deprecated(reason: "Use jobs instead.")` instead of vanishing from the
SDK schema — the types apps actually compile against. Nothing else in
the generated client changed.
- `twenty-utils/graphql-introspection-query.graphql` now passes
`includeDeprecated: true` to `inputFields` and selects the deprecation
metadata, matching what it already does for `fields` and `enumValues`.
Without it `api-breaking-changes` reported *"Input field payloads was
removed from input object type EnqueueJobsInput"* and failed. Verified
with `graphql-inspector` on an isolated schema pair: the change is now
reported as *"Directive deprecated was added to input field payloads"*,
no breaking changes.
- `ci-breaking-changes.yaml` also now introspects both servers with the
*same* query text. Checking out main mid-job was swapping in main's copy
of the query file, so the two schema dumps were taken with different
queries precisely when that file changes.

The third consumer, the front's graphql-codegen, still drops deprecated
input fields: `@graphql-tools/url-loader` exposes no
`inputValueDeprecation` option. Nothing in `twenty-front` references
`EnqueueJobsInput`, and the SDK types keep the field, so this is
cosmetic — noted as a follow-up rather than silently accepted.

## Retention

`QUEUE_RETENTION` is unchanged, so a status is readable for 4h after
completing and 7d after failing, capped at the most recent 1000 jobs per
state. Past that the id is simply absent from `getJobs`. This is called
out in the docs, since "no longer known" is a different answer from "it
never ran". A per-queue override for `logicFunctionQueue` is a follow-up
if the window turns out to be too tight.

Also out of scope, from the issue's adjacent list: per-application
concurrency caps, and failed-job listing/requeue.

## Validation

- `twenty-server` typecheck clean on the merged head; `oxlint
--type-aware` and `oxfmt` clean on all changed files.
- `npx nx test twenty-server`: 7313 passed.
- `enqueue-job.integration-spec.ts` with db reset: 13 passed, including
three new cases (a caller-supplied id reaching `COMPLETED` through the
worker, unknown ids omitted rather than erroring, and `payloads` +
`jobs` together rejected).
- `npx nx test twenty-sdk` passed, including the new `get-jobs.spec.ts`.
- All three codegen configurations (`metadata`, `admin`, `data`) re-run
against a freshly built server; the committed generated files are
current and the enum move produces no schema diff.

<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25204?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-09-02 17:10:15 +00:00
Paul Rastoin b88e49126d Port genql 3.0.5 test suite and run twenty-client-sdk tests in CI (#25092)
Follow-up to twentyhq/core-team-issues#2819 (genql vendor challenge):
give the vendored genql fork a real safety net — upstream's own test
suite, ported and enforced — and make CI actually run this package's
tests.

## Context

`packages/twenty-client-sdk/src/generate/genql` is a vendored copy of
`@genql/cli@3.0.5` (upstream commit
`4a547db46a9a614cc2b5958e28674af351898464`, now pinned in its README).
Diffing it against upstream showed the engine (`render/`, `runtime/`) is
near-verbatim while the orchestration was deliberately rewritten to drop
the vulnerable dependency graph. What was missing: behavioral test
coverage for the engine (the existing snapshot test pins bytes, not
behavior), and any CI running the package's tests at all.

## Ported upstream 3.0.5 test suite

`src/generate/__tests__/upstream-3.0.5/` ports the test suite of
remorses/genql at the vendored version (mapping table in its README):

- **Integration**: upstream's fixture schema (byte-verbatim),
`simple.ts` (query-generation snapshots) and `execution.ts` (end-to-end:
unions, interfaces, union-implements-interface, `__scalar`, batching,
`GenqlError`, sync/async header functions).
- **Unit**: `typeSelection` (the type-level `FieldsSelection` contract),
`typeMap`, `renderTyping`, `comment`, `RenderContext`, `renderSchema`.

This mirrors upstream's own CI model (its `genall` step regenerated the
client from the fixture schema on every run, typechecked it with the
tests, and ran the suite against it) and adds byte-level pinning
upstream did not have.

Adaptations (documented in the dir's README):
- apollo-server v3 (deprecated) is replaced by graphql-js `graphql()`
behind an injected `fetch`/`fetcher` — the identical client code path,
no network, zero new dependencies.
- mocha/sucrase/snap-shot-it/tsd become vitest.
- A generated fixture client is checked in (pinned by a drift test with
an `UPDATE_GENQL_UPSTREAM_FIXTURE=1` refresh mode) so the type-level
assertions compile and are verified by the package typecheck.
- tsd's `expectType` assertions are strengthened to exact: upstream
typechecked its tests with plain tsc, never the tsd CLI, and under plain
tsc `expectType<T>(value)` is only an assignability check — several
assertions were looser than the real types. The exact assertions here
pin what the 3.0.5 engine actually generates (`undefined` for absent
fields, never `null`; literal `__typename` types).
- Upstream's `typeMap` assertions ran in output-logging mode with stale
expected values; they are enabled and corrected to the engine's actual
output.

Not ported: `parse`/`printer` tests (cover code Twenty did not vendor)
and the subscription suite (already skipped upstream).

Also: `runtime-templates.ts` (Twenty's `?raw` bundling mechanism, not
upstream code) moved out of the vendored tree to `src/generate/`, so
`src/generate/genql/` contains only the fork plus its README and LICENSE
— and the file gains oxlint/oxfmt coverage it was previously excluded
from.

## Dedicated CI workflow

The package's tests never ran in CI: `ci-sdk.yaml`'s changed-files gate
does not include `packages/twenty-client-sdk/**`, and its matrix runs
`test:unit` while the package only defines `test`. This adds
`ci-client-sdk.yaml` (modeled on `ci-shared.yaml`): lint / typecheck /
test matrix via `nx affected` on a new `scope:client-sdk` tag, gated on
`twenty-client-sdk` and `twenty-shared` changes. The `test` target now
declares `dependsOn: ["^build"]` so `twenty-shared/dist` is fresh
wherever the suite runs (CI and locally).

## Validation

- `twenty-client-sdk` vitest: 15 files, 111 tests passing.
- `tsgo -p tsconfig.json --noEmit`: clean (this is what arms the
`@ts-expect-error`/`expectTypeOf` assertions).
- `nx lint twenty-client-sdk` (oxlint + oxfmt): clean.
- The `client-sdk-test` (lint / typecheck / test) jobs from the new
workflow run green on this PR.
2026-09-01 07:57:33 +00:00
Abdul Rahman 4423973633 fix(ci): authenticate the app-dev test leg as the seeded admin user (#25028)
## Why

The `dockerhub-latest` app CI leg (the one that runs an app's
integration suite against the published
`twentycrm/twenty-app-dev:latest` image) authenticates with a hardcoded
**API_KEY** token. That broke for every app that executes deployed logic
functions in its tests.

Executing a logic function through `executeOneLogicFunction` now
requires a **user** context: the resolver reads `@AuthUser()` and
`@AuthUserWorkspaceId()`, both of which throw `ForbiddenException` for
an API key. The SDK CLI's api-client turns that `FORBIDDEN` into a
generic `Authentication failed: GraphQL auth error in response`. This
requirement was added in #24493 ("give handlers an execution context,
and a choice of whose access each call uses").

Evidence it's the image server, not app code: the leg passed on every
published image up to `2.32.0` and has failed on every image from
`2.35.0` on (the first images carrying #24493). A docs-only Slack PR
(#24882, a 2-line README change) failed the leg with the identical
error, while its `local` leg passed.

## What it does

Swap the action's `api-key` output from an API_KEY to an **ACCESS token
for Tim, the seeded admin**.

- Executing logic functions needs a **user** (Tim is one), so the
`executeOneLogicFunction` gate passes.
- The app test's global setup also **deploys the app** (`appDevOnce`),
which needs **admin** permissions. The image seeds with `--light`, where
the dev seeder makes **Tim** the admin and Jane a plain member (Jane is
the admin only in the non-light local seed). So the token must be Tim's,
not Jane's.

This mirrors the `local` leg, which already authenticates as its own
seeded admin. The token is signed with the app-dev `APP_SECRET` the same
way the previous API key was: `sha256(APP_SECRET + workspaceId +
'ACCESS')`.

## Scope and safety

- Only file changed:
`.github/actions/spawn-twenty-app-dev-test/action.yml`.
- Its only consumer is `spawn-twenty-server`, so this affects every
app's `dockerhub-latest` leg. That's the intent, they all hit the same
latent break.
- Safe: each app already authenticates as its seeded admin on its
`local` leg and passes, so the same identity on the `dockerhub-latest`
leg can't newly break a passing test; it only clears the user/admin
gates the API key could not.

## Verification

- The signing recipe was confirmed by reproducing the existing API_KEY's
signature exactly, then re-signed with `type=ACCESS`.
- Tim's ids are the dev-seeder constants; `seedCoreSchema` (users +
user-workspaces) and the `--light` admin assignment run for this
workspace.
- Confirmed green: the `slack (dockerhub-latest)` leg passes with this
token on the branch it was validated on.

---
_Generated by [Claude
Code](https://claude.ai/code/session_014rA1NFmPJS6RLjVkZoD4T4)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25028?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. -->
2026-08-30 09:35:18 +00:00
Félix Malfait 73a451d841 Make QA Scout failures quiet on PRs and legible in CI (#25060)
Fixes the useless comment on #24937 ([this
one](https://github.com/twentyhq/twenty/pull/24937#issuecomment-5455448375)),
a regression from #24934, and then fixes the gap that made it hard to
diagnose.

## What went wrong

#24934 told the Scout to checkpoint its verdict before the first
scenario so a killed run would leave its last known state instead of
silence. It overcorrected: on run
[33190923955](https://github.com/twentyhq/twenty/actions/runs/33190923955)
the agent hit the turn cap (`terminal_reason: max_turns`, 151 turns,
$10.12) without ever rewriting those files, so the comment job published
the placeholder verbatim — an INVESTIGATE verdict reading "Run still in
progress" with all four scenarios `pending`. On a merged PR that is
worse than saying nothing: it looks like a finding, and it isn't one.

The stream log shows the agent had done the work — screenshots `01`
through `10` including `05-unpinned-persisted-reload.png` and
`09-cancel-restores-pinned.png` — it just never updated the verdict
between scenarios.

## 1. Only real findings reach the PR

`verdict.json` now carries a `status` (`in-progress` / `final`), and the
comment job decides on evidence rather than on the file merely existing:

| Verdict state | Behavior |
|---|---|
| `final`, FAIL or INVESTIGATE | posts (unchanged) |
| `final`, PASS | quiet, or refreshes a stale comment (unchanged) |
| `in-progress` with a failing scenario, or a top-level FAIL | posts
under a `> [!WARNING]` that the findings are partial |
| `in-progress`, nothing failing | **quiet** — job summary and artifact
only |

The top-level FAIL clause is [Greptile's
catch](https://github.com/twentyhq/twenty/pull/25060#discussion_r3888567423):
"app did not boot" is written before any scenario exists, so gating
purely on failed scenarios would have suppressed the loudest signal the
Scout has. The skill now also states that an early terminal verdict is
`final` the moment it is written, so the workflow gate is a backstop
rather than the only defense. A verdict with no `status` counts as
`final`, so nothing regresses while the skill change rolls out.

## 2. A failed run now says why

Related gap, and more pressing now that a dead run is deliberately
silent on the PR: the summary said `No report produced (agent run failed
or timed out)` and left the reason in an archive nobody downloads. Every
failure so far was diagnosed by hand.

The run step keeps stderr and the exit code next to the stream, and the
summary reads both back — the final result line gives outcome, turns,
duration, cost and error; stderr covers runs that die before producing
one. A warning annotation puts the first stderr line on the run page.
Rendered against the two real failures on record:

```
## QA Scout
The Scout produced no verdict.

**Why it stopped** (exit code `128`)

- outcome: `error_max_turns` · 151 turns · 11 min · $10.12
- error: Reached maximum number of turns (150)
```

```
## QA Scout
The Scout produced no verdict.

**Why it stopped** (exit code `1`)

- the agent produced no result line, so it failed before or during startup

<details><summary>stderr (last 10 lines)</summary>
error: Claude Code returned an error result: You've hit your weekly limit ...
</details>
```

That second one is live right now: the shared account is out of weekly
quota until Aug 31 14:00 UTC, which is why this PR's own `PR Review /
Security` and `Quality` checks are red. Not this diff; see [the comment
above](https://github.com/twentyhq/twenty/pull/25060#issuecomment-5466952932).

## 3. Turn budget

The dead run's permission denials show where its 151 turns went: copying
screenshots the artifact already ships from `browser/`, `curl` boot
checks whose rule was removed in #24907, and `python3`/`node` for JSON
that `jq` handles. The skill now tells the Scout to leave screenshots
where Playwright writes them, names the tools it actually has, and asks
for two to four scenarios finalized by roughly the ten-minute mark.
`--max-turns` stays at 150: with checkpoints that carry real results,
hitting the cap degrades gracefully instead of producing noise.

## Validation

Workflow YAML parsed; all three edited run blocks pass `bash -n`; the
posting gate exercised against eight payload shapes including the exact
JSON that produced the #24937 comment (quiet), an in-progress boot FAIL
with both an empty and a pending scenario list (both post), a legacy
verdict with no `status` (posts), and malformed JSON; the summary
rendered against both real failure modes and against a healthy run
(unchanged); the stderr tee and `PIPESTATUS` exit-code capture verified
against a failing command.

## Cleanup

The stale comment on #24937 cannot be edited from here, but once this
merges and quota resets, dispatching CI E2E Main with `pr_number=24937`
re-runs the Scout and overwrites that comment in place with a real
verdict.
2026-08-30 09:55:40 +02:00
Félix Malfait 94ca5a7c64 Fix QA Scout turn exhaustion found by the first live audit (#24934)
First live audit of the Scout (8 non-superseded push runs since the
direct-CLI fix, 11:27Z to 14:00Z today) found one systemic bug; this PR
fixes it.

## The numbers

| Run | PR | Outcome |
|---|---|---|
| 11:32 | #24918 (i18n) | skipped by design |
| 11:44 | #24916 (revert) | PASS, 92 turns, 6.2 min, $4.54 |
| 12:16 | #24778 | died at turn 121, no verdict |
| 12:30 | #24708 | PASS, 88 turns, 6.8 min, $4.28 |
| 12:51 | #24780 (upgrade commands) | wrote a PASS verdict on turn 121,
then killed before report.md |
| 13:13, 13:47, 14:00 | 3 PRs | died at turn 121, no verdict |

Five of eight runs hit the `--max-turns 120` cap. When they did, the
artifact was empty or verdict-only, so the comment job stayed silent and
the PR got nothing.

## Root cause

The execution logs (captured since #24907) show every Bash command
touching `/tmp/qa-scout` was denied: `wc -l server.log worker.log`, `ls
-la /tmp/qa-scout/`, even `tail -n 2 server.log`. The CLI validates Bash
file arguments against the session's working directories, and
`/tmp/qa-scout` is outside the checkout, so no allowlist prefix rule can
permit it. Agents adapted by paging through megabyte log files with the
Read tool, which is exactly where the turn budget went. The one
modest-PR runs finished under 92 turns; anything meatier blew the cap.

## Fixes

- `--add-dir /tmp/qa-scout`: grants the directory, so one `tail -n +N`
replaces dozens of Read pages. Root-cause fix; turn counts should drop
well below the old baseline.
- `--max-turns 150`: headroom while the fleet re-baselines (the
15-minute step timeout still bounds cost).
- `--strict-mcp-config`: stops loading the repo's `.mcp.json`, whose
postgres server the agent kept reaching for and getting denied.
- `PGPASSWORD` in the step env: bare `psql -h localhost ...` now matches
`Bash(psql *)` instead of dying on the `PGPASSWORD=... psql`
env-assignment prefix.
- Graceful degradation: the comment job and job summary now synthesize a
body from `verdict.json` when `report.md` is missing, so a partial run
surfaces its verdict instead of "nothing to post". The 12:51 run on
#24780 had a complete PASS verdict that nobody saw.
- Skill: write both output files right after scoping and update them in
place, so any death leaves the last known state.

## Validation

Workflow YAML parsed; all three edited run blocks pass `bash -n`; the jq
fallback tested against FAIL-with-scenarios, PASS-minimal, and
missing-scenarios inputs under pipefail; `--add-dir` and
`--strict-mcp-config` verified present in the pinned CLI 2.1.247.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01B1ML7TTsrqPbPnCtox9rBX)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24934?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. -->
2026-08-27 19:51:58 +02:00
Félix Malfait e45ac77219 Audit runner egress on the QA Scout job (#24908)
Follow-up to #24856, first step of the containment hardening discussed
in its security review.

## The gap this works toward closing

The Scout's `--allowedTools` list scopes what the agent does, not where
the runner can reach. The accepted residual in #24856 is that a
prompt-injected agent could exfiltrate the inference token in real time
over the runner's open egress (`psql` alone is arbitrarily powerful in
the disposable env, so pure allowlisting cannot close this). The real
fix is a runner-level egress block with an endpoint allowlist. Writing
that allowlist blind would break the job on the first missing endpoint.

## What this PR does

Adds `step-security/harden-runner` (sha-pinned to v2.21.0) as the first
step of the `e2e-test` job with `egress-policy: audit`:

- records every outbound endpoint the job actually uses (yarn install,
Playwright, the Anthropic API, GitHub) in the StepSecurity insights for
the run,
- blocks nothing and changes no behavior; a failure of the action itself
fails nothing downstream since it only observes.

## What comes after

Once a few weeks of audit data show a stable endpoint set, a follow-up
flips to `egress-policy: block` with that allowlist. At that point
real-time exfiltration from the agent step becomes impossible rather
than merely out of scope, and the same pattern can extend to the other
agent-running workflows (`claude.yml`, and the cross-version Scout of
#24906).

## Supply-chain note

This adds a third-party action to the job. It is pinned by commit sha,
runs in audit (observe-only) mode, and is the de facto standard for
exactly this purpose; calling it out explicitly since the tradeoff (new
dependency vs egress visibility) is a real one for reviewers to weigh.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01B1ML7TTsrqPbPnCtox9rBX)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24908?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. -->
2026-08-27 13:28:18 +02:00
Félix Malfait 6af9183bc8 Fix the post-merge QA Scout (direct CLI invocation) and tune its allowlist (#24907)
Follow-up to #24856. Scope grew after the first live post-merge runs:
**this PR is now the fix for the Scout's flagship mode, which turned out
to be broken on main.**

## The bug (found by the first live runs)

Runs
[33059764654](https://github.com/twentyhq/twenty/actions/runs/33059764654)
(merge of #24856 itself) and
[33060899663](https://github.com/twentyhq/twenty/actions/runs/33060899663)
(merge of #24854) both had the Scout step die in 252ms with:

```
Action failed with error: Unsupported event type: push
```

`claude-code-action` has no `push` case in its event parser, neither at
the pinned v1.0.146 nor at latest (verified in its source: the switch
covers issues, comments, reviews, pull_request, workflow_dispatch,
repository_dispatch, schedule, workflow_run). Every pre-merge test used
`workflow_dispatch`, which is supported, so the one untested path was
the flagship one. Shadow-safety worked exactly as designed: the failure
was swallowed, the comment job skipped gracefully, and main CI was
unaffected.

## The fix

Replace the action with a direct, pinned Claude Code CLI invocation
(`@anthropic-ai/claude-code@2.1.247`):

- No event-type opinion: works on push, label, and dispatch alike.
- Strictly better containment: the action's GitHub bootstrap was the
only reason a GitHub token entered the agent step. The step now holds no
GitHub credential at all, only the inference token.
- `--output-format stream-json` teed to `/tmp/qa-scout/execution.json`
(ahead of the scrub) replaces the earlier capture step on this branch
and is richer: per-turn detail plus permission denials for data-driven
allowlist tuning.

The flags are the same ones the action passed through `claude_args` to
this same CLI, so behavior (allowlist enforcement, auto-deny, turn cap)
is unchanged.

## Allowlist tuning (original scope)

The first PASS run finished in 99 turns with 19 permission denials.
Added read-only text tools the agent predictably reaches for: `jq`,
`echo`, `diff`, `sort`, `uniq`, `cut`, `tr`, plus `TodoWrite`. All
subprocess-free (`find` and `awk` were considered and dropped for
`-exec`/`system()`); `psql` stays the single documented full-power
exception, and `curl` stays excluded per the #24856 exfiltration
reasoning.

## Validation

Workflow YAML parsed; the run block passes `bash -n`; all CLI flags
verified present in 2.1.247 (`--allowedTools`, `--mcp-config`,
`--model`, `--output-format`, `-p`), with `--max-turns` proven by the
action's own verbatim pass-through in the earlier PASS run. Until this
merges, the post-merge Scout stays dead on main; label and dispatch
modes are unaffected.
2026-08-27 13:27:30 +02:00
Félix Malfait 0bcce772d2 Add QA Scout: post-merge browser QA agent on main (#24856)
## What

A post-merge browser QA agent, riding the environment `ci-e2e-main`
already boots on every push to main.

After the deterministic Playwright suite, the QA Scout:

1. Resolves the merged PR for the pushed commit and collects its
metadata, file list, and diff. Skips i18n translation PRs and titles
carrying `[no-qa]` / `[noqa]`, mirroring `mustBeQa` in the eng app.
2. Runs Claude Code (`claude-code-action`, same pin as `claude.yml`)
with the Playwright MCP against the already-running app at
`localhost:3000`, following the new skill at
`.claude/skills/qa-scout/SKILL.md`: derive 2 to 5 user-visible scenarios
from the diff, execute them in the browser, and diff its own window of
the server and worker logs to catch swallowed backend exceptions behind
a green-looking UI (the 2.35 timelineActivity failure mode).
3. Writes `verdict.json` (`PASS | INVESTIGATE | FAIL`) and `report.md`,
uploaded as the `qa-scout-report` artifact and rendered in the job
summary.
4. On `FAIL` / `INVESTIGATE`, a separate job posts (or updates) a single
marked comment on the merged PR, opening with a `[!CAUTION]` admonition.
`PASS` stays quiet.

The skill also works locally: engineers can run `/qa-scout` in Claude
Code against a dev stack.

## Why

Post-merge QA of merged PRs is today a fully manual Build Companion
queue, and the deterministic e2e suite covers 7 flows. The Scout
pre-chews that QA: it picks the risky flows for each specific diff, and
it reads the logs while it clicks, which is exactly where
silently-swallowed regressions hide. `hasQaBeenDoneOnMain` stays
human-owned; the Scout's report lands where the human QA starts.

## Safety and rollout

- **Shadow-safe**: every Scout step is `continue-on-error`, so it can
never redden `ci-e2e-main` or affect `ci-e2e-main-status-check`. The
suite's own behavior is unchanged (server/worker logs are now teed to
files; console output is identical).
- **Privilege separation**: the agent step runs under the workflow's
read-only token and only gets `Read/Grep/Glob/Write`, a short Bash
allowlist, and the Playwright MCP (headless Chrome, isolated profile,
origin-restricted to localhost). `pull-requests: write` exists only in
the `qa-scout-comment` job, which reads the structured artifact and
never the agent's raw context, so untrusted page/log content the agent
reads can never reach a privileged credential.
- **Never on fork or PR events**: the Scout only runs on push to main
and manual `workflow_dispatch`; merge-queue PR runs of this workflow are
untouched.
- **Kill switch**: set repo variable `QA_SCOUT_DISABLE_COMMENTS=true` to
keep verdicts in artifacts/summaries only (shadow mode).
- **Re-run**: Actions, CI E2E Main, Run workflow with `pr_number`
re-runs the Scout for any merged PR.

## Validation

- Workflow YAML parses; both embedded scripts pass `bash -n`; the
heredoc MCP config is valid JSON.
- The `mustBeQa` mirror was table-tested against real title shapes
(`[no-qa]`, `[NOQA]`, `i18n - translations`, mid-title i18n mentions).
- `@playwright/mcp@0.0.79` (current latest) flags used (`--headless`,
`--browser chrome`, `--isolated`, `--output-dir`, `--allowed-origins`)
verified against its CLI help.
- `.gitignore` gains `!.claude/skills/` so the skill can live next to
the committed `.claude/settings.json`.

## Follow-ups (separate PRs)

- Wire the verdict into the eng app: a webhook route (like
`/s/main-ci-failing`) writing the verdict onto the `pullRequest` record
so Build Companion shows it in the QA queue.
- Point the same skill at staging after deploy/upgrade, where aged data
lives.
- Have the nightly/deep variant draft Playwright specs for stable
scenarios so the deterministic suite grows.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01B1ML7TTsrqPbPnCtox9rBX)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24856?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. -->
2026-08-27 11:42:11 +02:00
Charles Bochet 588ed9a413 Remove IS_ORM_V2_READ_PATH_ENABLED and collapse to the ORM v2 path (#24692)
## What

ORM v2 is now the only read/write path for workspace data. This removes
the `IS_ORM_V2_READ_PATH_ENABLED` feature flag and every branch that
gated on it, always taking the v2 path and deleting the now-dead v1
read/write branches.

## Changes

- **Common query runners** (base / create-many / group-by / merge-many)
always use the v2 read/write repositories. The legacy mutation
query-builder, the v1 `GroupByWithRecordsService`, and the v1 merge
transaction path are deleted.
- **Nested-relations processor** always routes through the ORM v2
helper; the v1 `ProcessNestedRelationsV2Helper` (and its spec) is
deleted. The dead v1 `workspaceDataSource` plumbing is removed from the
query-runner context and all its consumers.
- **`GlobalWorkspaceOrmManager`**: `getRepository` and
`runInWorkspaceTransaction` always use v2; the v1 fallbacks and the
`isOrmV2ReadPathEnabled` helper are removed. `loadWorkspaceContext` no
longer builds the TypeORM `EntityMetadata` graph, and the
`ORMEntityMetadatas` cache provider is now a no-op (the cache key stays
because committed upgrade commands still invalidate it).
- **Flag definition**: removed from `FeatureFlagKey`,
`DEFAULT_FEATURE_FLAGS`, and the feature-flag cache-key invalidation
util (inlined). Generated GraphQL enum types regenerated.
- **CI**: the `server-integration-test` `orm-v2` matrix axis collapses
to a single run now that the default run exercises v2. The temporary
`TEST_FORCED_FEATURE_FLAGS` seeding/assertion mechanism is removed.

## Scope / follow-up

The v1 `GlobalWorkspaceDataSource` is intentionally kept: a few services
(`object-record-count`, `mostly-empty-fields`) still use it as a raw
`pg` connection via `dataSource.query(...)`, not the ORM entity layer.
Migrating those onto the v2 pool and deleting the v1
datasource/entity-manager/query-builders is a separate follow-up.

## Notes

- Assumes `IS_ORM_V2_READ_PATH_ENABLED` is already enabled on all
existing workspaces (not just defaulted for new ones).
- `feature_flag.key` is a `text` column, so removing the enum member
needs no DB migration; existing rows are inert.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24692?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. -->
2026-08-24 17:17:00 +02:00
9618231dfc feat(docs): gate MDX docs on Crowdin-safe placeholders (#24540)
Tracked as `DOCS-01` on twentyhq/core-team-issues#2784.

### Problem

Crowdin parses `<foo>` in prose as a tag rather than literal text, so an
angle bracket placeholder is dropped or mangled in every translated
page. Curly braces `{foo}` survive the round trip.

A `no-angle-bracket-placeholders` rule used to guard against this. It
ran as `'error'` on `**/*.mdx` in the root `eslint.config.mjs` from
#15751 (Nov 2025) until #18443 migrated us to oxlint. oxlint has no
`eslint-plugin-mdx` equivalent, so the whole `**/*.mdx` block went with
it and the rule stopped running. The file was carried into
`twenty-oxlint-rules` wired to nothing, and #24535 removed it as dead
code. So the guard has been silently off for about five months, not
missing by design.

This restores it in a form that runs without a JSX AST: a plain text
scan, no new dependencies.

### Change

- `scripts/lint-mdx.ts` scans the 204 English source pages and skips
`l/` (the 2613 generated Crowdin files, not hand-editable). A violation
there is only ever a symptom of one in the source.
- The `lint` target was `{}`, an empty no-op, while the `docs-lint` CI
job invoked `nx lint twenty-docs`. So the job has been passing without
running anything. The target now runs the package's own `.oxlintrc.json`
(nothing was running that either, so `scripts/*.ts` is linted for the
first time) followed by the MDX scan.
- A `test` target plus a vitest config for the package, wired into CI.
14 tests.

### On the backtick handling

The scanner skips angle brackets inside code spans, since they are
legitimate there. The first version did this with a running backtick
parity count over the whole file, which is unsound: one unpaired
backtick in prose flips parity and silently suppresses every finding
after it in the file, while the run still prints clean.
`docker-compose.mdx` was already in that state from line 210.

Fixed in 54dff6e4: backtick runs are now paired within a line, by run
length, which is how inline code actually delimits. Three tests pin it,
all of which fail against the old counter.

### Verification

```
$ npx nx test twenty-docs
Tests  14 passed (14)
```

End to end on the real CI command. Seeding two violations into
`getting-started/introduction.mdx`:

```
$ npx nx lint twenty-docs
getting-started/introduction.mdx:78:5    <workspace-slug> reads as a tag in Crowdin, use {workspace-slug} instead
getting-started/introduction.mdx:78:34   <span> reads as a tag in Crowdin, use {span} instead
2 angle bracket placeholder(s) found in 204 MDX files.
nx lint exit=1
```

Unseeded, the tree is clean: `No angle bracket placeholders in 204 MDX
files.`

To be clear about scope: the gate catches nothing in the tree today. Of
the 83 angle brackets in the English source, 81 sit inside code spans
and 2 are a legitimate `kbd` element. It is a preventive guard, plus the
CI wiring fix above. The live corruption (a backslash before angle
brackets inside inline code in the translated output, 494 across 156
files) is a `crowdin-normalizer` concern, not something a source-side
gate can see, and is handled in a separate PR.

### Not in this PR

`DOCS-01` also covered `mdx-component-newlines` (component tags sharing
a line with prose). Deferring it, but not because the current
occurrences are settled idiom: blame shows 137 of them were authored
while that rule was live at `'error'` and only 68 after it went dark, so
it was never really enforcing. Its opening-tag branch early-returns
whenever the following text is adjacent, so a component wrapping text on
one line never tripped it. Porting it faithfully buys nothing without
redesigning what it checks, which is a separate call that stays on
#2784.


<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24540?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>

---------

Co-authored-by: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com>
Co-authored-by: Abdul Rahman <ar5438376@gmail.com>
2026-08-24 08:52:23 +00:00
Abdul Rahman 4373833b8d fix(ci): stop the i18n formatting ping-pong between Crowdin pull and push workflows (#24590)
## Symptom

Since Aug 20, the i18n workflows open formatting-only translation PRs in
a loop, even with no new translations. On main this shows up as pairs of
exactly inverse commits minutes apart, e.g. #24586 (780 insertions /
1534 deletions) then #24587 (1534 / 780), same for #24571/#24572,
#24575/#24576, #24579/#24580, and for the app #24577/#24578. App i18n PR
volume jumped from 1-6/day to 14 (Aug 20) and 25 (Aug 21).

## The mismatch itself

Crowdin's PO exporter and lingui's PO writer disagree on exactly one
thing: multiline strings. Crowdin exports gettext-style `msgid ""` +
quoted continuation lines; lingui writes the first line inline (`msgid
"first line\n"`). Single-line strings are byte-identical in both. So a
download flips every multiline entry, the resulting merge triggers the
push workflow, its `lingui extract` flips them back, and the two
workflows ping-pong forever.

## Why now: full timeline

The mismatch is old and latent; it needed a Crowdin download to actually
run against catalogs containing multiline strings, and scheduled
downloads had not been running for about a year:

- **Feb 2025** — the original i18n CI gated the scheduled Crowdin
download on `lingui compile --strict` failing: "only download when
translations are missing". Sound at the time, since `--strict` genuinely
failed on missing translations.
- **2025** — English became the fallback locale in the lingui configs.
With a fallback, `--strict` treats missing translations as found and
always succeeds, so the gate never opened and scheduled downloads
silently stopped.
- **Aug 28, 2025 (#14128)** — the symptom was diagnosed ("--strict does
not fail since we've set english as a fallback locale... temporary
hotfix"), but the hotfix un-gated only the *compile* step; the
*download* step kept the dead condition. Scheduled downloads stayed
dead; translations could only land via manual `force_pull` dispatches.
- **May 2026 (#20171)** — the website pull workflow was created
following the app one, dead gate included, so its scheduled downloads
never worked (why "PRODUCT" stayed English on the zh homepage despite
`产品` being approved in Crowdin since July 2).
- **Meanwhile** — multiline strings accumulated in catalogs that had
only ever been written by lingui: the website's competitor pricing pages
(#23588, Jul 30 2026) and the app's custom-application markdown
descriptions.
- **Aug 20, 2026 (#24400)** — the dead gate was removed and downloads
became unconditional on schedule. The first scheduled download in a long
time brought Crowdin's wrapping of those multiline strings into the
repo, and the ping-pong started within hours, in both projects.
(Verified: every app i18n PR from Jul 15 to Aug 19 contained no
downloaded translations — extract output plus a few encoding-fixer edits
— then filled translations and inverse pairs appear from Aug 20.)

## Fix

Two complementary changes:

**1. Normalize downloads (root cause).** In `website-i18n-pull.yaml` and
`i18n-pull.yaml`, run the corresponding `lingui:extract` targets on the
downloaded catalogs before the change-detection diff. Extract merges the
downloaded translations and rewrites the files in lingui's canonical
format, so both sides converge on identical bytes: an unchanged download
produces no diff and no PR, while real translation updates still flow
through. (Validated in production by the loop itself: #24586 is exactly
this command running over Crowdin-formatted catalogs, preserving every
msgstr.)

**2. Skip push runs for translation-only merges (trigger hygiene).**
Exclude the locale catalog directories from the push workflows' `push`
triggers (`paths` negation for the website, `paths-ignore` for the app,
which previously ran a full install + extract on every merge to main). A
translation-only merge can never change extract's input (source code),
and its Crowdin upload step is already gated on extract detecting
changes, so these runs were pure waste.

An alternative considered: a Crowdin-side export transform (Crowdin
Enterprise file processors) could rewrap PO exports into lingui's style
instead, with zero CI steps. Trade-off: it lives in Crowdin's UI where
repo contributors can't see or verify it. If a processor is set up
later, change 1 becomes redundant and can be reverted.

Docs workflows are untouched: docs translations are whole MDX files
copied verbatim, no PO reformatting involved.

Notes for after merge: main currently holds the Crowdin-formatted
catalogs (from #24587), so the first pull run will produce one final
normalization PR per project, then go quiet until there are actual new
translations. Worth triggering both pull workflows manually once via
workflow_dispatch to confirm.
2026-08-22 18:17:48 +00:00
Félix Malfait 741849b20b fix(open-api): make request-body examples deterministic (#24591)
## Context

Follow-up to #24544. While reading that job's failure output we noticed
it listed 93 "changed operations" covering `/duplicates`, `/merge` and
every base collection path, which read as a huge unintended breaking
change. It was not: none of the 93 were breaking, and the same 93 are
reported on **every** run of the breaking-changes job, including runs
where nothing changed.

Two independent problems, both fixed here.

## 1. The examples were random

`generateRandomFieldValue` drew from the shared faker with no seed and
used `v4()` for UUID fields, so the OpenAPI document differed on every
generation.

The breaking-changes job boots a server on the PR branch and one on
main, generates a document from each, and diffs them. Since the examples
are random, every operation with a request body differs between the two
documents. From the #24544 artifact:

```
changedOperations: 93
of which incompatible: 0
changed ops whose ONLY difference is the request-body example: 93 / 93
```

So the noise never failed anything on its own (`incompatible: false`),
which is why it went unnoticed. It only got printed when the job failed
for another reason, and there it buried the real signal.

Examples now come from a private `Faker` instance seeded per field, so a
field's example depends on the field and nothing else: not on generation
order, not on when it ran. `faker.date.soon` is anchored to a fixed
reference date for the same reason. The instance is private so seeding
cannot disturb the shared faker other callers use.

The seed derives from the field **name**, not its id, because ids differ
between two freshly created workspaces and the two documents have to
agree.

The snapshot delta shows the second half of this. The same field used to
get a different example in `ObjectName` and in `ObjectNameForUpdate` and
now gets one value:

```diff
   "ObjectNameForUpdate": {
       "fieldCurrency": {
-        "amountMicros": "253000000",
+        "amountMicros": "773000000",   // same as ObjectName now
-      "fieldNumber": 692.6302930536448,
+      "fieldNumber": 352.6893054576472,
```

## 2. The job's own failure output was unreadable

The printer dumped **every** changed operation whether or not it was
incompatible, and read `.method` where openapi-diff puts `httpMethod` on
changed operations (it is `.method` on `newEndpoints`/`missingEndpoints`
only). So all 93 rendered as `?`:

```
  Changed operations:
    - ? /companies
    - ? /companies/duplicates
    - ? /companies/merge
    ... 90 more
```

Narrowed to `select(.incompatible)` and reading `httpMethod`. Replayed
against the real artifact from #24544, the failure summary goes from 93
unattributed lines to the 66 removed endpoints that actually broke the
job:

```
  Removed endpoints:
    - PATCH /restore/attachments/{id}
    - PATCH /restore/attachments
    ... 64 more
```

The non-breaking-changes log line still counts all changed operations,
since "N operations differ, none breaking" is the useful number there.

## Test plan

- New `generate-random-field-value.util.spec.ts`: 13 tests covering
stability across 11 field types, independence from generation order, and
that distinct fields still get distinct values.
- `npx jest src/engine/core-modules/open-api --config=jest.config.mjs` —
4 suites, 26 tests, 4 snapshots passing.
- Both rewritten jq programs run against the real `rest-api-diff.json`
from #24544: 67 output lines, zero `?` placeholders.
- oxfmt and oxlint clean; typecheck introduces no new errors.


---
_Generated by [Claude
Code](https://claude.ai/code/session_016pz8KcZkNpYoqngfKk5Yzm)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24591?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. -->
2026-08-22 10:35:20 +02:00
neo773 0dce0b5b13 Match participants on the transaction that inserted them (#24525)
Sync inserts participants through the transaction scope, but
`matchParticipants` took its repository from the global ORM manager.
With `IS_ORM_V2_READ_PATH_ENABLED` on, that update runs on a pooled
connection that cannot see the just-inserted rows, so it updates nothing
and leaves `personId` null. The matched event still fires off the
in-memory participants, so the record page Timeline shows the event
while the Calendar and Emails tabs show nothing.

#24182 dropped the `transactionManager` argument `matchParticipants`
used to take and nothing replaced it. This threads the scope back in,
optional because the rematch jobs call it outside a transaction.

Test run with `TEST_FORCED_FEATURE_FLAGS=IS_ORM_V2_READ_PATH_ENABLED`,
on the new spec alone.

<details>
<summary>Before the fix — 2 failed</summary>

```

FAIL test/integration/google/messaging/participant-matching-existing-person.integration-spec.ts (7.359 s)
  Participant matching when the person already exists (integration)
    ✕ links the imported message participant to the person that already owned the handle (41 ms)
    ✕ links the imported calendar event participant to the person that already owned the handle (49 ms)

  ● Participant matching when the person already exists (integration) › links the imported message participant to the person that already owned the handle

    expect(received).toEqual(expected) // deep equality

    - Expected  - 1
    + Received  + 1

      Array [
    -   "66442b51-7c80-49b2-88f9-a4af2385179f",
    +   null,
      ]

      117 |         KNOWN_SENDER,
      118 |       ),
    > 119 |     ).toEqual([senderPersonId]);
          |       ^
      120 |   }, 60000);
      121 |
      122 |   it('links the imported calendar event participant to the person that already owned the handle', async () => {

      at Object.<anonymous> (test/integration/google/messaging/participant-matching-existing-person.integration-spec.ts:119:7)

  ● Participant matching when the person already exists (integration) › links the imported calendar event participant to the person that already owned the handle

    expect(received).toEqual(expected) // deep equality

    - Expected  - 1
    + Received  + 1

      Array [
    -   "c08f8d45-1c3c-4ad5-af65-dd3198d645c8",
    +   null,
      ]

      127 |         KNOWN_ATTENDEE,
      128 |       ),
    > 129 |     ).toEqual([attendeePersonId]);
          |       ^
      130 |   }, 60000);
      131 | });
      132 |

      at Object.<anonymous> (test/integration/google/messaging/participant-matching-existing-person.integration-spec.ts:129:7)

Test Suites: 1 failed, 1 total
Tests:       2 failed, 2 total
Snapshots:   0 total
Time:        7.418 s
Ran all test suites matching /test\/integration\/google\/messaging\/participant-matching-existing-person.integration-spec.ts/i.
```

</details>

<details>
<summary>After the fix — 2 passed</summary>

```

PASS test/integration/google/messaging/participant-matching-existing-person.integration-spec.ts (5.883 s)
  Participant matching when the person already exists (integration)
    ✓ links the imported message participant to the person that already owned the handle (50 ms)
    ✓ links the imported calendar event participant to the person that already owned the handle (36 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        6.111 s, estimated 8 s
Ran all test suites matching /test\/integration\/google\/messaging\/participant-matching-existing-person.integration-spec.ts/i.
```

</details>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24525?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. -->
2026-08-21 15:10:07 +00:00
Abdul Rahman 9ec11bd7aa fix: repair escaped inline-code tags in Crowdin docs translations (#24397)
## Why

Crowdin's `mdx_v2` parser HTML-escapes tags inside inline code
(`\`<path>\`` → `\`&lt;path&gt;\``). `exclude_code_blocks` only protects
fenced blocks, so one escaped span fails the **entire language build**
and every other translated page in that language goes stale.

This PR adds a repair for that, and puts it in a small rule-based
normalizer instead of a third one-off script. The existing
escaped-unicode fixer (`fix-crowdin-translations.ts`, used by the app
pull) is folded into the same engine so the next corruption class is a
~4-line rule, not another script plus workflow step.

## What landed

**New — docs inline-code repair**
- Rule `escaped-inline-code-tags`: detect/unescape `&lt;`/`&gt;`
(including hex/numeric entities) **only inside backtick spans**, so a
literal `<` in prose is left alone.
- Wired into `docs-i18n-pull.yaml` after pre-translate and before
download (`--project=2 --rules=escaped-inline-code-tags`). Non-blocking.

**Also — consolidate the existing unicode fixer**
- Rule `escaped-unicode` replaces `fix-crowdin-translations.ts`
(deleted).
- App `i18n-pull.yaml` now calls the shared CLI (`--project=1
--rules=escaped-unicode`).

**Shared engine (`packages/twenty-utils/crowdin-normalizer/`)**
- One export per file: rules, Crowdin client helpers, CLI orchestrator.
- Reads the **exported** translation (not stale suggestions).
- Add-before-delete, so a failed re-add cannot leave a string empty;
partial failures exit non-zero.
- `--project` and `--rules` are required. Dry-run unless `--apply`.
- `nx test twenty-utils` plus `ci-utils-test.yaml` on `pull_request`
(`contents: read`), kept separate from `ci-utils.yaml`
(`pull_request_target`).

## Testing

- Unit tests pin: unescape only inside inline-code spans, prose entities
untouched, unicode restoration, idempotent rules, concurrency helper.
- Controlled injection on docs (project 2): injected `&lt;...&gt;` →
dry-run found 1 → `--apply` restored the original → follow-up scan 0.
- Live unicode dry-run on app (project 1): flagged real cases such as
`S\u00f6k` → `Sök`.
2026-08-21 13:26:17 +00:00
Abdul Rahman f415173ada fix: always pull app and website translations on schedule (#24400)
## Problem

The Chinese homepage still shows English **PRODUCT** even though Crowdin
has had `产品` (approved) since July 2. Same for ~99 other website
strings, empty in every locale.

Root cause: `lingui compile --strict` used to fail on empty `msgstr`,
and that failure gated the scheduled Crowdin download. After English was
set as `fallbackLocales.default`, `--strict` succeeds with missing
translations, so the pull is skipped every 2 hours.

Latest website run (`32316813115`) and app run (`32315907131`): compile
`--strict` success → **Pull from Crowdin skipped**. Last merged website
i18n PR was July 27. The website Crowdin project is already at 100%
translated; the strings just never reach the repo.

## Fix

Always download on `schedule` and `workflow_dispatch`. `workflow_call`
still requires `force_pull` (pre-existing; nothing in this repo calls
these workflows). If Crowdin has nothing new, the existing `git diff`
no-ops and no PR is opened.

Dropped the dead `--strict` compile that no longer gates anything.

## Out of scope

Pre-translating leftover untranslated strings (TM/MT/AI) is a separate
decision. It would fill real gaps in the **app** project (95–99%), but
machine output goes straight into the repo and automerge, and
`--translate-untranslated-only` makes it hard to undo. The website is
already 100% in Crowdin, so that sweep would not have fixed Product.
Docs already does this; app/website should get their own explicit yes.

## Test plan

- [ ] After merge, run **Pull website translations from Crowdin** (or
wait for the next schedule).
- [ ] Confirm `packages/twenty-website/src/locales/zh.po` has `msgid
"Product"` → `msgstr "产品"`.
2026-08-20 09:48:18 +00:00
Félix Malfait 8d4a11e498 fix(server): timeline calendar events denied on the ORM v2 read path (#24403)
`getTimelineCalendarEventsFromObjectRecord` returns `PERMISSION_DENIED`
for every user, admins included, on any record that has calendar events,
in workspaces where `IS_ORM_V2_READ_PATH_ENABLED` is on.

```json
{
  "message": "Entity performing the request does not have permission",
  "extensions": { "subCode": "PERMISSION_DENIED", "code": "FORBIDDEN" }
}
```

## Cause

`TimelineCalendarEventService` runs under a system auth context but
built its calendar event repository without a `RolePermissionConfig`:

```ts
getRepository<CalendarEventWorkspaceEntity>(workspaceId, 'calendarEvent')
```

With no config the repository gets `objectRecordsPermissions = {}`
**and** `shouldBypassPermissionChecks = false`. That is not "the
caller's permissions" — a system auth context resolves no role at all,
so there is nothing to check against and every non-system object is
denied. `calendarEvent` and `calendarEventParticipant` are unaffected
because they are `isSystem: true` and get an early return in
`validateOperationIsPermittedOrThrow`. The participant relations are
not: `person` is a regular object and `workspaceMember` is explicitly
excluded from the system bypass.

On the v1 read path this never surfaced. Relation joins added by `find({
relations })` are emitted as bare alias selects, which the join check in
`permissions.utils.ts` cannot match, so those relations were never
validated. On the v2 read path each relation is loaded through a
separate `find()` on the target repository, inheriting the same empty
permissions, and that select is validated.

Only records that actually have calendar events are affected; empty
timelines return before the relation load.

## Fix

Pass `{ shouldBypassPermissionChecks: true }` to the calendar event
repository, which is what `resolveRolePermissionConfig` already returns
for a system auth context, and what the `workspaceMember` lookup in this
same function already does. No-op on the v1 path.

A `TODO` records the better long-term shape — running under the caller's
role instead of bypassing — which needs a redaction path first, since a
role that cannot read `person` would otherwise go from a full timeline
to a hard denial.

## Coverage

The ORM v2 CI leg only runs suites named in `ORM_V2_TEST_PATH_PATTERN`,
and `timeline-from-object-record` was not one of them, which is why this
reached production. The suite already seeds a calendar event with a
person participant, so it catches this once it runs; it is added to the
pattern here.

## Verification

Ran `timeline-from-object-record.integration-spec.ts` against a database
reset with `TEST_FORCED_FEATURE_FLAGS=IS_ORM_V2_READ_PATH_ENABLED`:

- before: 3 failed, 5 passed, each failure carrying the exact
`PERMISSION_DENIED` payload above
- after: 8 passed

The messaging half of the timeline is not affected. Its participant
query joins `person` and `workspaceMember` too, and
`getThreadParticipantsByThreadId` runs on every request regardless of
the GraphQL selection, but those tests pass under the flag both before
and after this change — explicit joins take a different validation path
than find-options relations.

## Follow-ups

twentyhq/core-team-issues#2777 collects what this turned up and
deliberately leaves out of scope: joined aliases going unvalidated on
the v2 path (which the messaging timeline currently depends on),
config-less `getRepository` silently meaning deny-all, and `location` /
`conferenceSolution` / `callRecordings` being returned ungated for
`METADATA`-visibility events on `main` today.
2026-08-20 09:04:40 +02:00
Félix Malfait d2d3e500b8 Make cookie sessions the only web auth path (#24257)
Cookie-based web sessions have been running in production long enough to
become the only web login path, ahead of deprecating JWT for the web
app. JWT stays for OAuth/apps, and as the fallback credential for
clients that predate the session cookie.

- Removed `AUTH_COOKIE_SESSIONS_ENABLED` and every branch behind it:
`UserSessionCookieService` and `UserSessionService` now always issue and
read the session cookie.
- Removed `isCookieSessionEnabled` from client config (entity, service,
generated schema artifacts) and from the front end: the
`isCookieSessionEnabled` atom is gone, `CookieSessionBootEffect` always
probes, and `SettingsProfileDevicesSection` no longer skips its query.
- Dropped the CI env plumbing and the `ci:auth-cookie-sessions` label,
so the server integration suite runs against sessions like production.
- Removed the config overrides and the disabled-path cases from the
session integration suites and the CSRF middleware unit test.

Existing instances with a DB override row for the key keep it in
`core.keyValuePair`, where it is simply never read.

Generated files were edited by hand since codegen needs a running
server; the schema drift check in CI is the verification.
2026-08-17 12:25:47 +02:00
Félix Malfait e713700305 Remove the dedicated media-notes example app workflow (#24216)
Follow-up to #24203, removing
`.github/workflows/ci-example-app-media-notes.yaml` per the review
discussion — the dedicated per-app GitHub Action wasn't wanted.

What still covers the recording pipeline per-PR: the renderer polyfill
unit tests and the real-Chromium sandbox story (both in the renderer
CI), plus the SDK e2e. The app's own Playwright e2e stays in the repo
and runs locally per its README — it just no longer has CI wiring.

Two notes:
- If `ci-example-app-media-notes-status-check` was added to the
branch-protection required checks, it should be removed there too
(settings, not code) so future PRs don't wait on a check that no longer
reports.
- The sibling workflows (`ci-example-app-hello-world.yaml`,
`ci-example-app-postcard.yaml`) follow the same copy-paste pattern —
happy to remove those as well or fold them into one reusable
`workflow_call` if you want the same treatment.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Dk7hVLhyPwMMV5Zj5KHQQZ)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24216?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. -->
2026-08-14 17:53:42 +02:00
Félix Malfait 3ea46b5453 App media recording via standard web API polyfills (compare with #24053) (#24203)
## What this is

The **low-level alternative** to #24053, built from `main` so both PRs
can be compared side by side. Same end-user experience — the media-notes
app looks and behaves identically, the same host recording indicator
names the recording app — but the recording surface for apps is the
**standard web platform**, not a bespoke SDK API.

Apps write:

```tsx
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
recorder.ondataavailable = (event) => chunks.push(event.data);
recorder.onstop = async () => {
  const result = await uploadFile(new Blob(chunks, { type: recorder.mimeType }), { fieldMetadataId });
};
recorder.start();
```

This is code any web developer already knows, and it's what third-party
recording libraries call.

## SDK surface comparison

| | #24053 (capability API) | This PR (polyfills) |
|---|---|---|
| SDK functions | `startAudioRecording`, `startVideoRecording`,
`stopRecording`, `cancelRecording` | `uploadFile` only |
| SDK types | 7 bespoke types | 5 types around `uploadFile` |
| Recording semantics | defined by us, forever | defined by the web
platform |
| Third-party recorder libs | can't work | work if they stick to
getUserMedia + MediaRecorder |
| Pause/resume, timeslice streaming, `requestData`, track mute | not
exposed | standard behavior |
| Upload coupling | stop = upload to a FILES field | app decides what to
do with the Blob |

## How it works

- **Worker (sandbox)**: `polyfills/media/` installs
`navigator.mediaDevices.getUserMedia`, `MediaStream`,
`MediaStreamTrack`, and `MediaRecorder` classes backed by a
`workerMediaBridge` (same pattern as the geometry store).
`MediaRecorder.isTypeSupported` is synchronous, so it answers from a
capability snapshot the host ships in the render context.
- **Host**: a `FrontComponentMediaSessionHost` (renderer package) owns
the real `getUserMedia` streams and `MediaRecorder` objects. Capture
RPCs travel like `hostFetch`/geometry — renderer-owned thread functions,
not the app-facing API map. Recorded chunks come back as `Blob`s through
a new `pushMediaSessionEvents` worker export (Blobs are
structured-cloneable).
- **twenty-front**: `useFrontComponentMediaSession` adds the host policy
— one live capture at a time (a second `getUserMedia` rejects with
`NotReadableError`, exactly what a busy device produces natively) — and
drives the recording indicator.
- **Upload**: the one Twenty-specific piece. `uploadFile(blob, {
fieldMetadataId, fileName? })` validates the target is a FILES field
(anything else would strand the file as an unattachable orphan),
sanitizes the file name, and returns `{ fileId, path, url, size,
mimeType }`.

## Same safeguards as #24053

- **Non-spoofable host indicator** naming the recording application
whenever a device is live, with a stop button.
- The indicator's stop (or a revoked device) reaches the app as
**standard track `ended` events** — this is actually *stronger* than
#24053, where the app only discovered a host stop on its next call. The
e2e test asserts the app auto-cancels with no further interaction.
- Renderer unmount tears down all sessions; nothing can keep capturing
after the component is gone.
- FILES-field validation before any upload.

## Known limits (documented)

- `srcObject` live preview inside the app can't work (a MediaStream
can't cross the sandbox); the host indicator shows the self-view, same
as #24053.
- getUserMedia constraint details (deviceId, resolution) are accepted
but not forwarded; `enumerateDevices` not provided.
- `isTypeSupported` answers for a candidate list of common
container/codec combos.

## Verification

- **Real-browser proof**: a renderer sandbox story runs the full loop in
actual Chromium — worker `getUserMedia` → `MediaRecorder` → host RPCs →
Blob pushed back through the real bridge → standard
`dataavailable`/`stop` events → rendered result. It already paid for
itself: it caught that `crypto.randomUUID` doesn't exist in the
opaque-origin sandbox realm (not a secure context), which unit tests
can't detect.
- 15 new renderer unit tests (bridge routing, polyfill class semantics
incl. native details like "self-initiated `track.stop()` fires no
`ended` event").
- 5 new `useFrontComponentExecutionContext` tests for `uploadFile`
(FILES validation, path-separator stripping, failure mapping); suite
50/50.
- media-notes e2e spec ported with identical test-ids; renderer jest
616/616; typecheck + lint green across twenty-sdk,
twenty-front-component-renderer, twenty-front, and the app.

## Comparing the two PRs

The app diff is the clearest comparison:
`media-notes.front-component.tsx` here vs #24053 — same JSX, same
test-ids, same states; only the engine differs (standard APIs +
`uploadFile` vs the four bespoke SDK functions).

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Dk7hVLhyPwMMV5Zj5KHQQZ)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24203?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. -->
2026-08-14 17:48:18 +02:00
Félix Malfait 655277cc63 Point CI dispatches at twentyhq/twenty-factory (#24196)
`twentyhq/ci-privileged` is being renamed to `twentyhq/twenty-factory`.
Two things here resolve the repo by name and do not follow GitHub's
rename redirect:

- `actions/create-github-app-token` validates the `repositories:` input
against the App installation, so the token mint returns 422 under the
stale name
- `gh workflow run --repo` POSTs to the repo API, where relying on a 301
redirect is not safe

8 mint steps and 8 dispatch targets across 7 workflows, plus comments in
an 8th.

**Merge after the rename, not before.** Until `twentyhq/twenty-factory`
exists this branch mints against a repo that is not there. In the window
between the rename and this merge, PR comments, AI review, preview envs,
website previews, prod-parity e2e and visual regression dispatches all
fail at the mint step, so this wants to go in immediately after the
rename.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24196?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. -->
2026-08-14 13:02:27 +02:00
Charles Bochet bbf584a5f2 feat(server): route create, upsert and merge through the ORM v2 write path (#24145)
Stacked on #24140 (update through v2). Completes the ORM v2 write path:
create, upsert,
merge, transactions, and relation connect/disconnect resolution. v1
stays in place; this
does not retire it.

Behind `IS_ORM_V2_READ_PATH_ENABLED` (the same flag). Flag off, nothing
changes.

## Scope

- **Create**: the non-upsert insert branch of `createMany` / `createOne`
flag-branches to
`WorkspaceRepositoryV2.runInsert`: `formatData` flattens each record, a
multi-row
`INSERT ... VALUES ... RETURNING` (`DEFAULT` for the columns a row
omits, so Postgres
defaults apply) is generated by `buildInsertStatement`, the insert
permission is validated
over the inserted columns, and `CREATED` then `UPSERTED` fire from an
all-columns re-select
  of the inserted ids, matching the v1 insert builder.
- **Upsert** (`createMany` with `upsert: true`): the read-then-split is
unchanged; the
insert half routes through `runInsert`, the update half through
`runBatchUpdate` (each
per-id update its own statement, one batch `UPDATED` + `UPSERTED` over
the collected
before/after images, matching v1's `updateMany`). Conflict-target
derivation is unchanged.
- **Transactions**: `WorkspaceDataSourceV2.transaction(work)` checks a
client out of the
pool, wraps `work` in `BEGIN` / `COMMIT` (rollback + rethrow on error),
and hands back
repositories bound to that client through a `ClientQueryExecutor`, so
every statement and
  event snapshot inside runs on one connection.
- **Merge**: `executeMergeWithinTransactionV2` re-points the losers'
foreign keys to the
survivor across each related object, hard-deletes the losers, and
updates the survivor,
each step a `runMutation` on a transaction-scoped repository, so the
same
  `UPDATED` / `DESTROYED` / `UPSERTED` events fire as v1.
- **Relation connect/disconnect**: `{connect}` / `{disconnect}` input on
create, update and
  upsert is resolved to plain join-column values before the v2 write by
`resolveNestedRelationsForOrmV2` on the base runner: it runs the
unchanged v1
`RelationNestedQueries` (its lookup select stays on v1), then drops the
relation
  field-name keys so only columns reach the v2 statement.
- **Files fields**: synced through the same v1 `FilesFieldSync` the
write query builders
use. The repository computes the file diff against the before-image,
enriches the record,
and re-points the `File` rows after the statement runs. `FilesFieldSync`
needs only the
object name and `coreDataSource`, both already on the v2 repo's
`internalContext`, so no
  TypeORM entity is built.

With files folded in, the write path no longer has any content-based
fallback: the old
`writeDataIsSupportedByOrmV2` check is gone, so with the flag on
**every** create / update /
upsert / delete / merge routes to v2 regardless of the field types it
touches.

- **Merge relation-by-name**: the survivor update now runs
`resolveNestedRelationsForOrmV2`
before writing, the same relation resolution the other write paths use,
and the
`isMergeSupportedByOrmV2` gate is removed. Investigating it showed the
gate was dead: the
merge write path selects columns and join columns
(`buildColumnsToSelect` maps relations
to join columns) and loads relations by name only on the dry-run branch,
so the merged
data never carried a relation-by-name key. The resolution is a no-op on
real merges and a
safety net if that ever changes; the net effect is all merges route to
v2.
- **To-many join for group-by record ordering**:
`WorkspaceSelectQueryBuilderV2.leftJoin`
can now render a to-many join (child FK = parent id + soft-delete
predicate) when the
caller passes `allowToManyJoin`; only group-by "with records"
record-ordering opts in, so
filter traversal and relation loading still refuse to-many joins with
the usual error.
This is latent capability: the GraphQL order-by input excludes to-many
relations
(`generateSimpleRelationFieldOrderByInputType` returns `{}` for
`ONE_TO_MANY`), so no API
query can order by one today. The join is unit-tested against its exact
SQL and activates
  automatically if that input is ever exposed.

## Stays on v1 (deliberately)

- **v1 itself**: the TypeORM repository is still constructed per
request. Retiring it is
  the separate follow-up, not this PR.

## Verification

- Ran locally both flag states: the create/update permission suites
(many + one),
`people-create-many`, `upsert`, `composite-unique-index-upsert`, the
field/object/RLS
upsert suites, `mutation-relation-filter-rls`,
`mutate-by-relation-field`, and
`nested-relation-queries` (all 16 connect/disconnect cases across
create-one, create-many
upsert false/true, update-one, update-many, plus morph
connect/disconnect), green flag-on
  and flag-off.
- Exact-SQL unit tests for the insert/mutation statement builders and
the new to-many join;
full v2 unit suite green. `group-by-with-records-resolver` and
`order-by-with-group-by`
green flag-on (to-one record ordering unaffected by the join-site
change). oxlint / oxfmt
  / typecheck clean. Write suites ride the flagged CI shard via
`object-records-permissions` / `upsert` / `merge-many` /
`nested-relation` / `group-by` /
`files-field` (added to the pattern so the files-sync path is exercised
flag-on in CI,
where object storage is configured; the files-field suites can't run in
a bare local env
  and fail there on v1 too).
2026-08-13 20:36:02 +02:00
Félix Malfaitandgithub-actions 21a0439f75 Install changed apps on the preview environment, document app previews for external repos (#24143)
## What

PRs touching `packages/twenty-apps/**` now get their apps **installed on
the PR's preview environment** — the env built from the PR's own server
and front — instead of a separate preview against a stock image.

> Pivoted mid-review from an earlier design (dedicated stock-image app
preview, see the closed twentyhq/ci-privileged#81): in the monorepo,
apps and platform capabilities land in the same PR, so previewing an app
against *released* Twenty answers the wrong question — an app exercising
an SDK capability from the same PR can only work on the PR's build. The
stock-image flow remains the right shape for dedicated app repositories,
and ships here as a copy-pasteable docs template.

## Changes

**`preview-env-dispatch.yaml`**
- App roots (`examples`, `internal`, `public`) join the trigger paths,
so app-only PRs get a preview env too.
- A new step lists changed apps via the **PR files API** (nothing from
the PR head is checked out under `pull_request_target`; a `gh api`
failure fails loudly, only an empty match is a no-op) and passes them to
ci-public as an `app_paths` input.
- Concurrency now keys on the PR number: under `pull_request_target`,
`github.ref` is the *base* ref, so unrelated PRs shared one group and
cancelled each other's dispatches.

**ci-public** (companion PR twentyhq/ci-public#5): `preview-env.yaml`
installs each app after the env boots, using the PR's own SDK CLI —
correct in this env, because the env *is* the PR's platform build. A
failing app warns instead of killing the env platform reviewers still
need. The env runs the e2e `APP_SECRET` (read from the PR tree at
runtime) instead of a random one so the seeded workspace's well-known
API key works for the CLI — which weakens nothing, since the env's login
is already public knowledge (`SIGN_IN_PREFILLED` with seeded demo
credentials on a public tunnel).

**Docs**: `developers/extend/apps/operations/preview-deployments` — a
standalone, copy-pasteable preview workflow for **dedicated app
repositories** (tunnel → `docker run twentycrm/twenty-app-dev` → `yarn
twenty` publish/install → PR comment → 5h keepalive), plus a section
explaining why the monorepo previews on the PR build instead. Registered
in `navigation/base-structure.json` (the docs.json source of truth) with
regenerated artifacts.

## Merge order

twentyhq/ci-public#5 first, then this PR. Until ci-public#5 lands, the
extra `-f app_paths` input would be rejected by the old
`preview-env.yaml`, breaking server previews — so please merge in order.
twentyhq/ci-privileged#81 is closed (no comment-side change needed; the
existing env comment covers it).

## Test

- Both workflows YAML-validated; docs artifacts regenerated with the
repo's own generator scripts (`docs:generate-navigation-template` /
`docs:generate` / `docs:generate-paths`), so the docs-i18n sync bot
no-ops.
- The seeded API key + e2e `APP_SECRET` pairing is the exact combination
this repo's example-app CI and my local runs used all day for
`app:publish` / `app:install`.
- End-to-end firing needs ci-public#5 on its `main`; after both merge,
any member push to an app PR (e.g. #24053) exercises the full path.

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-08-13 15:55:01 +02:00
Charles Bochet a96e3b46cb ci: serialize the i18n workflows per branch (#24148)
## What

Groups the six i18n workflows by `github.ref` instead of
`github.run_id`, so runs on a branch queue behind each other instead of
running concurrently.

## Why

`Extract and upload translations` went red on `main` this morning
([run](https://github.com/twentyhq/twenty/actions/runs/31696154871/job/94434279330)):

```
To https://github.com/twentyhq/twenty
 ! [rejected]          HEAD -> i18n (fetch first)
error: failed to push some refs
```

The group is

```yaml
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
```

and `github.head_ref` is only set for `pull_request` events. On a push
to `main` it is empty, so the group falls back to `github.run_id` —
unique per run. Every push to `main` starts its own concurrent run, they
all check out the same bot branch, and whichever finishes second is
rejected as non-fast-forward. Two pushes landed a minute apart (11:34
and 11:35) and the second run failed.

Using `github.ref` gives one group per branch. On `main`,
`cancel-in-progress` is already false, so a second run queues rather
than racing; GitHub keeps only the newest queued run, which is the right
outcome here since each run extracts from the current tip.

Applied to all six workflows that push a shared bot branch (`i18n`,
`i18n-docs`, `i18n-website`) — the push-triggered ones raced today, the
scheduled ones carry the same latent bug.

## Note

This does not cover a run racing the Crowdin bot's own force-push of
`i18n`, or an `i18n-pull` run racing `i18n-push` for the same branch
(they have distinct workflow names, so distinct groups). Neither has
shown up in the failure log; worth revisiting if it does.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24148?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. -->
2026-08-13 14:40:05 +02:00
Charles Bochet 9cb06724b0 feat(server): route delete, destroy and restore through the ORM v2 write path (#24131)
Follow-up to the ORM v2 write statement layer (#24118). Routes
`deleteMany`,
`destroyMany` and `restoreMany` (and their `...One` delegates) through
the v2 write path.

Behind `IS_ORM_V2_READ_PATH_ENABLED` (the same flag; there is no
separate write flag).
Flag off, nothing changes.

## Scope

Each runner flag-branches through a shared `runFilteredMutation` on the
base runner:

- **Off**: the existing v1 path (`buildMutationQueryBuilder` + the
TypeORM mutation builder).
- **On**: `buildMutationQueryBuilderV2` builds the filtered v2 select
builder — rewriting a
relation-traversal filter into an `id IN (subquery)` predicate with RLS
inside the
subquery, mirroring the v1 util — then
`WorkspaceRepositoryV2.runMutation` owns the
choreography the v1 mutation builders own: apply the row-level
predicate, validate the
write with the matching operation type (`soft-delete` / `restore` /
`delete`), snapshot
the affected rows before and after through a permission-bypassing
all-columns select,
run the statement, and emit the `DELETED` / `RESTORED` / `DESTROYED`
batch event through
  the shared `formatTwentyOrmEventToDatabaseBatchEvent`.

`getWriteRepository` on the base runner pins the primary (writes never
hit the replica) and
counts a new `orm-v2/write-path-used` metric.

Deliberately matching v1: returned records come from `RETURNING` rather
than a full
re-select (the GraphQL layer reads only the selected fields either way);
a mutation never
adds the `deletedAt IS NULL` predicate, and the snapshot select is
`withDeleted`, so
restore reaches soft-deleted rows; hard delete snapshots its
before-image with `getOne`, so
a `DESTROYED` event carries at most one record — same as the v1 delete
builder, not fixed
here (that would change flag-off too).

Update, insert and upsert stay on v1.

## Verification

- Ran locally both ways: the delete/destroy/restore permission suites
(many + one), plus
`mutate-by-relation-field` and `mutation-relation-filter-rls` (the
join-rewrite path), all
green with the flag on and off. Broader flag-on sweep of
`object-records-permissions`,
  `soft-deleted-relation` and `merge-many`: 17 suites / 67 tests green.
- `mutate-by-relation-field` and `mutation-relation-filter-rls` added to
`ORM_V2_TEST_PATH_PATTERN` (the delete/destroy/restore suites already
ride
`object-records-permissions`). 89 v2 builder unit tests still green.
oxlint / oxfmt /
  typecheck clean.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24131?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. -->
2026-08-13 13:01:05 +02:00
Charles Bochet 2c9f808f7d ci: unbreak CI E2E Main (same-origin front + fix the specs behind the login gate) (#24115)
## What

Unbreaks `CI E2E Main`, which has failed on every push to `main` for
weeks.

The job now serves the front build from the server instead of a separate
static server on port 3001, and the four specs that were failing behind
that gate are fixed.

## Why the suite was red

**The login setup died on "Unable to Reach Back-end"**, so 8 of 9 tests
never ran. Two changes stacked up:

- Since the cookie-session migration (#23642) the front sends
credentialed requests, and only the exact `SERVER_URL` / `FRONTEND_URL`
origins are reflected in `Access-Control-Allow-Origin`. A front on
`:3001` talking to an API on `:3000` was already broken for workspace
subdomains (`apple.localhost:3001` is not in the allowlist).
- #23779 then collapsed the front config to
`window._env_?.REACT_APP_SERVER_BASE_URL || window.location.origin`. The
job serves the production bundle with `npx serve` and never injects
`window._env_`, so the front called `localhost:3001/client-config`, got
the SPA fallback HTML back, and rendered the backend-unreachable screen.

Serving the front from the server makes every request same-origin,
subdomains included, so neither CORS nor the cookie allowlist is
involved. It is also how the `twenty` docker image ships the front
(`COPY ... /app/packages/twenty-server/dist/front`).

## Changes

CI harness:

- Copy `packages/twenty-front/build` into
`packages/twenty-server/dist/front` before starting the server, and drop
the `npx serve` step.
- Start the server with `start:ci` (`nest start`) rather than `start`
(`rimraf dist && nest start --watch`), which would delete the front we
just copied.
- Point Playwright's `FRONTEND_BASE_URL` and the server's `FRONTEND_URL`
at `http://localhost:3000`.
- Health-gate on `/healthz` (the real endpoint; `/health` 404s, and a
bare `curl -s` accepted that 404 as ready).

Specs behind the gate:

- `onboarding`: run it against the workspace-agnostic base domain. The
shared fixture sets `baseURL` to the workspace subdomain the login setup
landed on, where the server correctly refuses to sign a new user up to
an existing workspace. Also allow workspace creation on the e2e server
(`IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=false`, it defaults to
admin-only), and make the sync-email and install-apps stages optional:
both auto-skip when the instance has no connected-account provider and
no vetted marketplace app, which is how the e2e server is configured.
- `create-record`: the side panel button is `Expand record` since
#23965, not `Open …`.
- `create-kanban-view`: drop `isDragDisabled={options.length === 1}` on
select-field options. dnd-kit sets `aria-disabled` on the drag
activator, which is the whole row here since the row registers no drag
handle, so a single option's inputs were announced as disabled to
assistive tech and refused by Playwright. A one-item list cannot be
reordered anyway, so disabling the drag bought nothing.

## Tests

Full suite locally against a server serving the front build the same
way, all 9 green:

```
 setup › login.setup.ts › Login test
 authentication/onboarding.spec.ts › New workspace signup goes through every onboarding stage
 authentication/return-to-path.spec.ts › should redirect to deep link after login
 authentication/return-to-path.spec.ts › should preserve path with query params across login
 authentication/signup_invite_email.spec.ts › Sign up with invite link via email
 create-kanban-view.spec.ts › Create Industry Select Field
 create-kanban-view.spec.ts › Create Kanban View from Industry Select Field
 create-record.spec.ts › Create and update record
 workflow-creation.spec.ts › Create workflow
```

The first CI run of this branch (harness fix only) went from 0 passed /
1 failed / 8 not run to 5 passed, with exactly the three specs above
failing; this push fixes those.
2026-08-13 11:20:17 +02:00
Charles Bochet 371c016b54 feat(server): load mutation nested relations through the ORM v2 read path (#24113)
Follow-up to #24101 (nested relation loading on the ORM v2 read path).
That PR routed
the *read* runners' relation loading through v2 but left the mutation
runners behind:
`createMany`, `updateMany`, `deleteMany`, `destroyMany`, `restoreMany`
and `mergeMany`
all call `processNestedRelations` without passing the read-path options,
so their
post-write relation load stayed on v1 even with the flag on.

Behind `IS_ORM_V2_READ_PATH_ENABLED`. Flag off, nothing changes.

## Scope

Spreads `getNestedRelationsReadPathOptions(queryRunnerContext)` into the
seven
`processNestedRelations` call sites in the six mutation runners.
`useReplica` follows
`isReadOnly`, which is `false` for every mutation runner, so these
relation loads read
from the primary — correct, since they run after the write.

`createMany` loads relations from a private helper that only receives
slices of the
context, so it takes the resolved options as a parameter rather than the
whole context,
matching what the group-by v2 service does.

The writes themselves stay on v1. This PR only moves the relation reads
that already
happen after them.

## Verification

- `object-records-permissions` and `merge-many` added to
`ORM_V2_TEST_PATH_PATTERN`.
  Those are the suites where a mutation response selects relations
  (`permissions-on-relations`, `fields-permissions/read-permissions`,
`companies-merge-many`); the rest of the directory comes along and gives
the write
  permission suites flagged coverage too.
- Ran locally both ways: 16 suites / 64 tests green with the flag on and
off.
  oxlint / oxfmt / typecheck clean.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24113?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. -->
2026-08-13 08:26:08 +00:00
Charles Bochet d82b6b192e feat(server): route groupBy through the ORM v2 read path (#24092)
Follow-up to the findMany / findOne / findDuplicates migrations. Routes
`CommonGroupByQueryRunnerService` through the ORM v2 read path, behind
`IS_ORM_V2_READ_PATH_ENABLED`. Flag off, nothing changes.

groupBy has two paths, handled differently:

**Aggregate group-by** — a transparent `getReadRepository` swap. Adds to
the v2
builder: `groupBy` / `addGroupBy`, `getRawMany`, and `GROUP BY`
emission.

**group-by "with records"** — not a transparent swap. It wraps a builder
subquery
in a table-less `FROM (subquery)` `JSON_AGG` + `ROW_NUMBER() OVER
(PARTITION BY …)`
query, which v2's table-shape-based builder cannot represent. So:
- `GroupByWithRecordsV2Service` builds the inner subquery with the v2
builder
(`select([])` + `addSelect` for record columns / group dimensions /
window rank,
`andWhere` for the group predicates, `applyRowLevelPermissions`) and
composes the
outer `JSON_AGG` query as raw SQL, run through a new
`repository.executeRaw`
(compiles the builder's named params once and executes on the v2 pool).
- The runner flag-branches: v2 service when the flag is on, the existing
v1 service
otherwise. Supporting v2 additions: `setParameter`,
`applyRowLevelPermissions`.

## Verification

- 72 v2 builder unit tests (new: GROUP BY / addGroupBy, getRawMany).
- `group-by` added to `ORM_V2_TEST_PATH_PATTERN`, so the flagged shard
runs all
group-by suites (aggregate, with-records, both RLS variants,
order-by-with-group-by,
  REST) against v2.
- Ran locally both ways: all 7 group-by integration suites green with
the flag on
  (52/52) and off (52/52). oxlint / oxfmt / typecheck clean.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24092?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. -->
2026-08-12 22:59:28 +02:00
Charles Bochet 587ade034a feat(server): route findDuplicates through the ORM v2 read path (#24088)
Follow-up to #24077 (findMany + findOne on the ORM v2 read path). Routes
`findDuplicates` through v2 as well.

Unlike the previous two, findDuplicates uses builder surface v2 didn't
have, so
this PR extends v2 first, then the runner is a pure repository swap (no
v1-logic change):

- `getCount()` — `SELECT COUNT(1)` reusing the same from / join / where
/
  soft-delete / RLS building, ignoring projection, order and pagination.
`COUNT(1)` is exact because v2 only ever joins to-one relations, which
never
  duplicate root rows.
- `take()` / `skip()` — aliases of `limit()` / `offset()` (v1's
take/skip only
  diverge when a join can duplicate rows; v2 refuses to-many joins).
- object-literal `where({ id: In([...]) })` — only `in` and equality are
supported; keys are validated against the table shape (no injection via
keys)
  and values bind as parameters. Anything else throws.

Behind `IS_ORM_V2_READ_PATH_ENABLED`. Flag off, nothing changes.

## Verification

- 32 v2 builder unit tests, incl. new ones asserting the COUNT SQL,
take/skip ->
  LIMIT/OFFSET, and the object-where IN / equality / rejection paths.
- `find-duplicates` added to `ORM_V2_TEST_PATH_PATTERN`, so the flagged
shard
runs `rest-api-core-find-duplicates` (data + ids paths, depth-1
relations,
  error cases) against the v2 SQL generator.
- Ran locally both ways: find-duplicates suite green with the flag on
and off.
  oxlint / oxfmt / typecheck clean.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24088?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. -->
2026-08-12 20:55:21 +02:00
Charles Bochet bcdac3e824 feat(server): route findOne through the ORM v2 read path (#24077)
Follow-up to #23991. Routes the single-record read path through the ORM
v2
read repository, exactly as findMany was routed there. Behind
`IS_ORM_V2_READ_PATH_ENABLED`. Flag off, nothing changes.

`CommonFindOneQueryRunnerService` now selects the read repository from
the
flag and builds its query off it; everything else (filter, soft-delete,
`setFindOptions({ select })`, `getOne`, nested-relation loading on v1)
is
untouched. The v2 builder already covers `getOne`, `leftJoin` on to-one
relations and the soft-delete default, so no v2 changes were needed.

## Scope

- `CommonFindManyQueryRunnerService` and now
`CommonFindOneQueryRunnerService`
read through v2 under the flag. Every other runner still keeps
`repository`.
- Relation loading stays on v1, same as findMany.
- No guard added for to-many-relation filters: findOne has no LIMIT
correctness issue, an always-on guard would reject filters v1 currently
  serves (so "flag off, nothing changes" would break), and findOne is
  filtered by id / unique scalars in practice.

## Verification

- `ORM_V2_TEST_PATH_PATTERN` gains `find-one`, so the flagged
integration
  shard now runs both findOne suites against the v2 SQL generator.
- New GraphQL suite `find-one-record` covers the paths where v2 composes
SQL
  differently from TypeORM: composite reassembly, the to-one join, the
soft-delete default filter and `deletedAt`-filter retrieval. The
existing
  REST findOne suite (composite fields, depth-1 relation, 404, malformed
  uuid) joins the flagged pass too.
- Ran locally both ways: new suite and REST findOne green with the flag
off
  (v1) and on (v2). oxlint/oxfmt clean.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24077?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. -->
2026-08-12 17:20:07 +02:00
Charles Bochet ad0c5d7a59 feat(server): ORM v2, a TypeORM-free workspace read path behind a flag (#23991)
Adds `src/engine/twenty-orm-v2/`: a second ORM that uses no TypeORM,
exposes the same surface as `twenty-orm` so call sites do not change,
and generates parameterised SQL from field metadata. Behind
`IS_ORM_V2_READ_PATH_ENABLED`. Flag off, nothing changes.

Follows the investigation in #23989.

## The idea

Workspace entities have no classes — every one is synthesised per
request from `objectMetadata` / `fieldMetadata`. So TypeORM's
entity-metadata graph describes tables the metadata already describes.
v2 drops that graph entirely.

`WorkspaceTableShape` is the whole model: schema, table, columns,
relations, composite-column mapping. Plain data, derived from the flat
maps, no methods, no back-references, no cycles. **No ORM metadata
anywhere** — where metadata is needed it is field metadata, as asked.

## Prepared statements

The shared filter/order/RLS renderers already emit SQL with
TypeORM-flavoured named parameters (`:name`, `:...list`). v2 keeps that
dialect, so all three are reused unchanged, and `compileNamedParameters`
converts to `$1..$n` at the last moment. It's a scanner rather than a
regex, so `::text` casts, `'literals'` and `"identifiers"` containing
colons are left alone.

Statements then go to `pg` with a `name`, so Postgres parses and plans
each one once per connection. Query text is generated from metadata and
repeats across requests, which is the shape that benefits. Names are
bounded at 1000 distinct SQL shapes; past the cap statements are sent
unnamed and lose only the plan reuse. No new dependency: `pg` is already
direct.

## Deliberately different from v1

**Permissions no longer parse SQL.** v1 recovers entity, operation and
column list from `QueryExpressionMap`, including regex-matching
`"alias"."column"` out of select strings, because the check runs
downstream of a builder it didn't drive. v2 built the query, so it
reports the columns it read per alias and the existing
`validateOperationIsPermittedOrThrow` is called with them.

**`take()` is a plain LIMIT.** v1's `take` triggers the two-phase
`distinctAlias` query as soon as any join is present, stripping ORDER BY
and LIMIT from the inner scan. Only a to-many join can duplicate root
rows, and v2 refuses to join to-many at all, so LIMIT is correct.

## Deliberately identical

- `getMany()` returns records with composites reassembled, via the same
`formatResult`.
- Soft delete is `deletedAt IS NULL` unless `withDeleted()`. A nested
bracket group does not carry its own copy — a test caught that
inheriting it changes OR-group semantics.
- `orderBy` ignores `castToText`/`useLower` on the builder path,
matching v1 exactly.
- RLS predicates use the same resolver and renderer, rendered into the
join `ON` clause for joined aliases so a LEFT JOIN does not become an
inner join.

## Scope

Wired into `CommonFindManyQueryRunnerService` only; every other runner
keeps `repository`. Covered: `createQueryBuilder`, `clone`,
`where`/`andWhere`/`orWhere` (strings and bracket factories),
`setParameters`, `setFindOptions({select})`, `select`/`addSelect`,
`orderBy`/`addOrderBy`, `groupBy`/`addGroupBy`, `leftJoin` to-one,
`withDeleted`, `take`/`skip`/`limit`/`offset`,
`getMany`/`getOne`/`getRawMany`/`getRawOne`/`getCount`,
`getQuery`/`getQueryAndParameters`.

Not covered, and stated plainly in the folder README: writes (still v1,
and that is where `EntityMetadata` is genuinely used, so this shrinks
what the metadata cache is for rather than deleting it), relation
loading, the `find*` surface used by `src/modules`, transactions, DDL,
group-by aggregates. One cast remains where the v2 builder is handed to
the shared parsers, since they are typed against the TypeORM class
rather than an interface — giving them a structural type removes it and
is the next cleanup.

## Verification

Unit: 32 new tests across 3 suites. Builder tests assert exact SQL text,
since the emitted statement is this layer's contract. `npx jest
src/engine/twenty-orm src/engine/api/common` green; `tsc --noEmit`,
oxlint and oxfmt clean.

Integration: the CI matrix gained an `orm-v2` dimension, so
`server-integration-test` runs a second pass with
`TEST_FORCED_FEATURE_FLAGS=IS_ORM_V2_READ_PATH_ENABLED`. Since findMany
is the only runner on v2 today, that pass is scoped to the findMany
surface — record reads plus the paths where v2 composes SQL differently
from TypeORM: relation ordering and filtering, the per-parent limit,
row-level predicates on joins, soft-deleted relations, composites. It is
green on this head.

That flagged pass earned its keep immediately: on its first run it
caught three real defects, all fixed here — the shared filter parser
reads `objectRecordsPermissions` off the query builder rather than the
repository; custom object tables were derived from a property that does
not exist on `FlatObjectMetadata`, so `pet` never became `_pet`; and a
typo in `TEST_FORCED_FEATURE_FLAGS` was swallowed by the seeding
command, which `globalSetup` now asserts against so a mistyped flag
cannot leave the flagged pass green while testing the unflagged path.
2026-08-12 14:10:48 +02:00
Abdul RahmanandFélix Malfait abe3b2d570 [BREAKING CHANGE] refactor(server): migrate the metadata API off nestjs-query (#23925)
## Context

Follow-up to the ongoing nestjs-query removal. The last two modules on
it were object-metadata and field-metadata, which relied on the
auto-generated cursor resolvers (`objects`, `fields` and their
connection subfields). They had to move together: the generated types
they share (`FieldEdge`, `FieldFilter`, `CursorPaging`, `PageInfo`,
`ConnectionCursor`, comparison inputs) would otherwise be defined twice
in the metadata schema.

As discussed, the cursor pagination in the common API is coupled to the
workspace flat-metadata maps, so only its generic primitives are reused
(`decodeCursor`, `encodeCursorData`, `getPaginationInfo`); core metadata
entities get a thin layer on top in `metadata-modules/pagination/`.

## What this does

**Hand-written replacements for the auto-generated resolvers**, on the
existing resolver classes, with the same guards (`WorkspaceAuthGuard` +
workspace scoping) and the same defaults (page size 10, max 1000, sort
disabled with fixed `id DESC`):

- `objects(paging, filter)`, `object(id)`, `Object.fields`,
`Object.indexMetadatas` on `ObjectMetadataResolver`
- `fields(paging, filter)`, `field(id)`, `Field.object` (via the
existing dataloader) on `FieldMetadataResolver`

**Shared pagination layer** (`metadata-modules/pagination/`):

- `findManyWithCursorPagination` — keyset pagination on `id` over a
TypeORM query builder
- `applyMetadataFilterToQueryBuilder` — translates the nestjs-query
filter shape (`and`/`or` + `eq`/`neq`/`in`/`like`/`is`/…) into
query-builder conditions
- Schema types matching the previously generated ones name-for-name:
`ConnectionCursor` scalar, `PageInfo`, `CursorPaging`,
`UUIDFilterComparison`, `BooleanFieldComparison`, plus per-entity
filter/edge/connection types

**Cleanup:**

- `ObjectMetadataService` / `FieldMetadataService` no longer extend
`TypeOrmQueryService`; the one consumer of the inherited API
(`field-metadata-tools.factory`) uses a new plain
`findManyWithinWorkspace` finder
- Leftover nestjs-query decorators stripped from the index-metadata,
index-field-metadata, search-field-metadata and logic-function DTOs
(they produced nothing in the schema)
- `PageInfoType` in the workspace schema builder uses a local
`ConnectionCursor` scalar
- `@ptc-org/nestjs-query-{core,graphql,typeorm}` removed from
twenty-server, along with both yarn patches, the root uuid resolution
and the dependabot ignore entry

## Schema compatibility

Verified byte-for-byte: with the migrated server running,
`graphql:generate --configuration=metadata` produced a **zero diff** on
`generated-metadata/graphql.ts`, and validated every frontend document
against the new schema.

One behavioral note: cursor values changed format (offset-style before,
keyset on `id` now). Cursors are opaque and short-lived, but a client
holding a pre-deploy cursor gets an "Invalid cursor" error on its next
page fetch after deploy.

## Testing

- All 68 object/field-metadata integration suites pass (500 tests, 263
snapshots), plus the REST metadata suites and the fields-permissions
suites that paginate `fields` (93 tests)
- Typecheck and lint clean

---
_Generated by [Claude
Code](https://claude.ai/code/session_01MoxpdyTsjqeG6VX73HPdZk)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23925?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@twenty.com>
2026-08-10 10:39:42 +02:00
Charles Bochet d4c3759c70 ci(pr-review): stop a skipped label dispatch from cancelling the open dispatch (#23856)
## Problem

The standard review silently does not run on PRs that get labelled by a
bot right after opening. https://github.com/twentyhq/twenty/pull/23854
is an example: no `PR Review #23854` run exists in `ci-privileged` at
all.

| time | what |
|---|---|
| 10:08:35 | PR opened |
| 10:08:39 | `twenty-eng-sync[bot]` adds the `-PR: draft` label |
| 10:08:40 | dispatch run for `opened` starts, cancelled during "Set up
job" |
| 10:08:43 | dispatch run for `labeled` is skipped by the job `if` |

Concurrency is evaluated before the job-level `if`, so the `labeled` run
preempts and cancels the in-flight `opened` run and is then skipped
itself (`-PR: draft` does not start with `pr-review-`). The sync bot
labels within ~4 seconds of open, which is faster than the app-token
mint step, so the `opened` dispatch loses this race essentially every
time that label is applied.

`opened` is the only event that resolves to the `standard` check, so
with no later push the PR gets no review at all. Same class of gap as
the one #23708 closed, moved down a layer: the trigger exists now but
gets cancelled.

## Fix

Scope the concurrency group by event action, and only cancel in-progress
runs for `synchronize`. Rapid consecutive pushes still de-duplicate;
`opened`, `ready_for_review` and `labeled` no longer cancel each other.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23856?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. -->
2026-08-06 12:54:20 +02:00
Paul RastoinandFélix Malfait 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>
2026-08-04 10:05:12 +00:00