071980d511d7c24d29fc2548528ddef2791ef7e2
3335 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
30b8663a74 |
chore: remove IS_AI_ENABLED feature flag (#19916)
## Summary - AI is now GA, so the public/lab `IS_AI_ENABLED` flag is removed from `FeatureFlagKey`, the public flag catalog, and the dev seeder. - Drops every backend `@RequireFeatureFlag(IS_AI_ENABLED)` guard (agent, agent chat, chat subscription, role-to-agent assignment, workflow AI step creation) and the now-unused `FeatureFlagModule`/`FeatureFlagGuard` wiring in the AI and workflow modules. - Removes frontend gating from settings nav, role permissions/assignment/applicability, command menu hotkeys, side panel, mobile/drawer nav, and the agent chat provider so AI UI is always on. Tests and generated GraphQL/SDK schemas updated accordingly. ## Test plan - [x] `npx nx typecheck twenty-shared` - [x] `npx nx typecheck twenty-server` - [x] `npx nx typecheck twenty-front` - [x] `npx nx lint:diff-with-main twenty-server` - [x] `npx nx lint:diff-with-main twenty-front` - [x] `npx jest --config=packages/twenty-server/jest.config.mjs feature-flag` - [x] `npx jest --config=packages/twenty-server/jest.config.mjs workspace-entity-manager` - [ ] Manual smoke test: AI features still accessible without any flag row in `featureFlag` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5d438bb70c |
Docs: restructure navigation, add halftone illustrations, clean up hero images (#19728)
## Summary - **New Getting Started section** with quickstart guide and restructured navigation - **Halftone-style illustrations** for User Guide and Developer introduction cards using a Canvas 2D filter script - **Removed hero images** (`image:` frontmatter + `<Frame><img>` blocks) from all user-guide article pages - **Cleaned up translations** (13 languages): removed hero images and updated introduction cards to use halftone style - **Cleaned up twenty-ui pages**: removed outdated hero images from component docs - **Deleted orphaned images**: `table.png`, `kanban.png` - **Developer page**: fixed duplicate icon, switched to 3-column layout ## Test plan - [ ] Verify docs site builds without errors - [ ] Check User Guide introduction page renders halftone card images in both light and dark mode - [ ] Check Developer introduction page renders 3-column layout with distinct icons - [ ] Confirm article pages no longer show hero images at the top - [ ] Spot-check a few translated pages to ensure hero images are removed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
a1de37e424 | Fix Email composer rich text to HTML conversion (#19872) | ||
|
|
a8d4039629 |
chore: sync AI model catalog from models.dev (#19914)
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev). This PR updates pricing, context windows, and model availability based on the latest data. New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically. Deprecated models are detected based on cost-efficiency within the same model family. **Please review before merging** — verify no critical models were incorrectly deprecated. Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com> |
||
|
|
41ee6eac7a |
chore(server): bump current version to 2.0.0 and add 2.1.0 as next (#19907)
## Summary We are releasing Twenty v2.0. This PR sets up the upgrade-version-command machinery for the new release line: - Move `1.23.0` into `TWENTY_PREVIOUS_VERSIONS` (it just shipped) - Set `TWENTY_CURRENT_VERSION` to `2.0.0` (no specific upgrade commands — this is just the major version cut) - Set `TWENTY_NEXT_VERSIONS` to `['2.1.0']` so future PRs that previously would have targeted `1.24.0` now target `2.1.0` - Add empty `V2_0_UpgradeVersionCommandModule` and `V2_1_UpgradeVersionCommandModule` and wire them into `WorkspaceCommandProviderModule` - Refresh the `InstanceCommandGenerationService` snapshots to reflect the new current version (`2.0.0` / `2-0-` slug) The `2-0/` directory is intentionally empty — there are no specific upgrade commands for the v2.0 cut. New upgrade commands authored after this merges should land in `2-1/` (or be generated against `--version 2.1.0`). ## Test plan - [x] `npx jest` on the impacted upgrade test files (`upgrade-sequence-reader`, `upgrade-command-registry`, `instance-command-generation`) passes (41 tests, 8 snapshots) - [x] `prettier --check` and `oxlint` clean on touched files - [ ] Manual: open `nx run twenty-server:command -- upgrade --dry-run` against a local stack with workspaces still on `1.23.0` and confirm the sequence is computed without errors Made with [Cursor](https://cursor.com) |
||
|
|
96fc98e710 |
Fix Apps UI: replace 'Managed' label with actual app name and unify app icons (#19897)
## Summary
- The Data Model table was labeling core Twenty objects (e.g. Person,
Company) as **Managed** even though they are part of the standard
application. This PR teaches the frontend to resolve an `applicationId`
back to its real application name (`Standard`, `Custom`, or any
installed app), and removes the misleading **Managed** label entirely.
- Introduces a single, consistent way to render an "app badge" across
the settings UI:
- new `Avatar` variant `type="app"` (rounded 4px corners + 1px
deterministic border derived from `placeholderColorSeed`)
- new `AppChip` component (icon + name) backed by a new
`useApplicationChipData` hook
- new `useApplicationsByIdMap` hook + `CurrentApplicationContext` so the
chip can render **This app** when shown inside the matching app's detail
page
- Reuses these primitives on:
- the application detail page header (`SettingsApplicationDetailTitle`)
- the Installed / My apps tables (`SettingsApplicationTableRow`)
- the NPM packages list (`SettingsApplicationsDeveloperTab`)
- Backend: exposes a minimal `installedApplications { id name
universalIdentifier }` field on `Workspace` (resolved from the workspace
cache, soft-deleted entries filtered out) so the frontend can resolve
`applicationId` -> name without N+1 fetches.
- Cleanup: deletes `getItemTagInfo` and inlines its tiny
responsibilities into the components that need them, matching the
`RecordChip` pattern.
|
||
|
|
13afef5d1d |
fix(server): scope loadingMessage wrap/strip to AI-chat callers (#19896)
## Summary
MCP tool execution crashed with \`Cannot destructure property
'loadingMessage' of 'parameters' as it is undefined\` whenever
\`execute_tool\` was called without an inner \`arguments\` field. Root
cause: \`loadingMessage\` is an AI-chat UX affordance (lets the LLM
narrate progress so the chat UI can show "Sending email…") but it was
being wrapped into **every** tool schema — including those advertised to
external MCP clients — and \`dispatch\` unconditionally stripped it,
crashing on \`undefined\` args.
The fix scopes the wrap/strip pair to AI-chat callers only:
- Pair wrap and strip inside \`hydrateToolSet\` (they belong together).
- New \`includeLoadingMessage\` option on \`hydrateToolSet\` /
\`getToolsByName\` / \`getToolsByCategories\` (default \`true\` so
AI-chat behavior is unchanged).
- MCP opts out → external clients see clean inputSchemas without a
required \`loadingMessage\` field.
- \`dispatch\` no longer strips; args default to \`{}\` defensively.
- \`execute_tool\` defaults \`arguments\` to \`{}\` at the LLM boundary.
## Test plan
- [x] \`npx nx typecheck twenty-server\` passes
- [x] \`npx oxlint\` clean on changed files
- [x] \`npx jest mcp-protocol mcp-tool-executor\` — 23/23 tests pass
- [ ] Manually: call \`execute_tool\` via MCP with and without inner
\`arguments\` — verify no crash, endpoints execute
- [ ] Manually: inspect MCP \`tools/list\` response — verify
\`search_help_center\` schema no longer contains \`loadingMessage\`
- [ ] Regression: AI chat still streams loading messages as the LLM
calls tools
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
755f1c92d1 |
i18n - translations (#19893)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
6d3ff4c9ce |
Translate standard page layouts (#19890)
## Context Standard page layout tabs, page layout widgets, and view field group titles were hardcoded English in the backend. This PR brings them under the same translation pipeline as views. Notes: Once a standard widget/tab/section title is overriden, the backend returns its value without translation |
||
|
|
9a95cd02ed |
Fix applications query cartesian product causing read timeouts (#19892)
## Summary Same fix pattern as #19511 (`rolesPermissions` cartesian product). The `Settings > Applications` page was hitting query read timeouts in production. The offending SQL came from `ApplicationService.findManyApplications` / `findOneApplication`, which loaded **5 `OneToMany` children** in a single query via TypeORM `relations`: ``` logicFunctions × agents × frontComponents × objects × applicationVariables ``` Postgres returns the Cartesian product of all five — e.g. 20 logic functions × 5 agents × 30 front components × 100 objects × 10 variables = **3M rows for ~165 distinct records**, which trivially exceeds the read timeout. ## Changes - **`findManyApplications`** — dropped all `OneToMany` relations. The frontend `FIND_MANY_APPLICATIONS` query only selects scalar fields and the `applicationRegistration` ManyToOne, so joining the children was pure waste at the list level. - **`findOneApplication`** — kept the cheap `ManyToOne` / `OneToOne` joins (`packageJsonFile`, `yarnLockFile`, `applicationRegistration`) on the main query and fetched the 5 `OneToMany` children in parallel via `Promise.all`, reattaching them on the entity. Same shape as `WorkspaceRolesPermissionsCacheService.computeForCache` after #19511. - **`application.module.ts`** — registered the 5 child entity repositories via `TypeOrmModule.forFeature`. The other internal caller (`front-component.service.ts → findOneApplicationOrThrow`) only reads `application.universalIdentifier`, so the extra parallel single-key lookups remain far cheaper than the previous 8-way join with row explosion. |
||
|
|
117909e10a | Billing - Adapt to new unit (#19886) | ||
|
|
10c49a49c4 |
feat(sdk): support viewSorts in app manifests (#19881)
## Summary
Today the SDK lets apps declare `filters` on a view but not `sorts`, so
any view installed via an app manifest can never have a default
ordering. This PR adds declarative view sorts end-to-end: SDK manifest
type, `defineView` validation, CLI scaffold, and the application
install/sync pipeline that converts the manifest into the universal flat
entity used by workspace migrations. The persistence layer
(`ViewSortEntity`, resolvers, action handlers, builders…) already
existed server-side; the missing piece was the manifest → universal-flat
converter and the relation wiring on `view`.
## Changes
**`twenty-shared`**
- Add `ViewSortDirection` enum (`ASC` | `DESC`) and re-export it from
`twenty-shared/types`.
- Add `ViewSortManifest` type and an optional `sorts?:
ViewSortManifest[]` on `ViewManifest`, exported from
`twenty-shared/application`.
**`twenty-sdk`**
- Validate `sorts` entries in `defineView` (`universalIdentifier`,
`fieldMetadataUniversalIdentifier`, `direction` ∈ `ASC`/`DESC`).
- Add a commented `// sorts: [ ... ]` example to the CLI view scaffold
template + matching snapshot assertion.
**`twenty-server`**
- Re-export `ViewSortDirection` from `twenty-shared/types` in
`view-sort/enums/view-sort-direction.ts` (single source of truth,
backward compatible for existing imports).
- New converter `fromViewSortManifestToUniversalFlatViewSort` (+ unit
tests for `ASC` and `DESC`).
- Wire the converter into
`computeApplicationManifestAllUniversalFlatEntityMaps` so
`viewManifest.sorts` are added to `flatViewSortMaps`, mirroring how
filters are processed.
- Replace the `// @ts-expect-error TODO migrate viewSort to v2 /
viewSorts: null` placeholder in `ALL_ONE_TO_MANY_METADATA_RELATIONS`
with the proper relation (`viewSortIds` /
`viewSortUniversalIdentifiers`).
- Update affected snapshots (`get-metadata-related-metadata-names`,
`all-universal-flat-entity-foreign-key-aggregator-properties`).
## Example usage
\`\`\`ts
defineView({
name: 'All issues',
objectUniversalIdentifier: 'issue',
sorts: [
{
universalIdentifier: 'all-issues__sort-created-at',
fieldMetadataUniversalIdentifier: 'createdAt',
direction: 'DESC',
},
],
});
\`\`\`
|
||
|
|
e68842c268 |
Billing - fixes (#19867)
- Uniformize credit formating : In UI, 1$=1credit. In BE 1 UI credit = 1_000_000 BE "crédits" - Add crédit rollover information + Link to documentation + Documentation update <img width="291" height="317" alt="Screenshot 2026-04-17 at 18 22 59" src="https://github.com/user-attachments/assets/2519fb9f-159d-4c85-95f4-a6e005a8a1a3" /> <img width="848" height="763" alt="Screenshot 2026-04-17 at 14 12 20" src="https://github.com/user-attachments/assets/a3cc0874-f275-49ea-819f-305ec314bdfe" /> <img width="797" height="757" alt="Screenshot 2026-04-17 at 14 12 13" src="https://github.com/user-attachments/assets/9048409b-d5a2-435a-b735-70370705e668" /> - Enable direct top-up (or subscription if in trial) from AI chat <img width="333" height="215" alt="Screenshot 2026-04-17 at 22 52 00" src="https://github.com/user-attachments/assets/7a20c627-2806-4bcf-a037-b45752232be9" /> <img width="457" height="769" alt="Screenshot 2026-04-17 at 22 51 41" src="https://github.com/user-attachments/assets/d2a90c1b-271f-4fe9-8891-baeb2fabb86d" /> - Inform users if credit limit is reached - Banner <img width="1130" height="127" alt="Screenshot 2026-04-17 at 19 15 11" src="https://github.com/user-attachments/assets/30723e5e-c07e-462f-8eb8-e08f52bbab1c" /> |
||
|
|
5dd7eba911 |
Fix app design 6 (#19827)
Unify application display page and isntalled page --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
42f57db005 |
fix(server): add registration_client_uri to DCR response for Claude.ai connector (#19858)
## Summary Claude.ai's custom remote MCP connector fails with "Couldn't reach the MCP server" after successfully completing OAuth dynamic client registration. Driving the flow through Chrome DevTools showed Claude's backend creates our DCR client (many hundreds of orphan rows visible in the admin panel), then never returns the user to `/authorize` — it gives up silently. **Empirical comparison against known-working MCP servers Claude.ai connects to identified one concrete difference**: every server that works returns `registration_client_uri` in the DCR response. We didn't. | Server | DCR `registration_client_uri` | Claude.ai web connector | |---|---|---| | Linear (`mcp.linear.app`) | `/register/<client_id>` | ✅ works | | Sentry (`mcp.sentry.dev`) | `/oauth/register/<client_id>` | ✅ works | | Atlassian (`mcp.atlassian.com`) | yes | ✅ works | | **Twenty** (before this PR) | **missing** | ❌ "Couldn't reach" | ## What this PR changes ### 1. Add `registration_client_uri` to the DCR response ``` { "client_id": "…", …existing fields…, + "registration_client_uri": "<issuer>/oauth/register/<client_id>" } ``` Pointer at the registration's management endpoint per RFC 7591 §3.2.1. Marked OPTIONAL in the spec but empirically required by Claude.ai. ### 2. New `GET /oauth/register/:clientId` endpoint (RFC 7592 read-back) Returns public registration metadata (`client_name`, `redirect_uris`, `grant_types`, `scope`, etc.). 404 for unknown clients. No `registration_access_token` is issued (and none required to hit this endpoint): the `client_id` is an unguessable UUID and the fields returned are already public-readable via `findApplicationRegistrationByClientId` GraphQL. This matches Linear's behaviour — they return a `registration_client_uri` but issue no access token. ### 3. Advertise `response_modes_supported: ["query"]` in AS metadata RFC 8414 default, but explicitly listed by Linear / Sentry / Atlassian and absent from ours. Some clients treat its absence as a capability gap. ## Why I'm confident this is the root cause - The failure mode exactly matches an orphaned-DCR retry loop (hundreds of registrations, none `installed` on a workspace). - #19847 reporter confirmed Claude Desktop + VS Code work — those clients use the MCP Python SDK which doesn't require `registration_client_uri`. **Claude.ai web** uses Anthropic's proprietary backend client (`User-Agent: Claude-User`), which empirically does. - All 3 working reference servers return the field; we were the odd one out. ## Test plan - [x] `tsc --noEmit` clean on touched files - [x] `yarn jest --testPathPatterns="oauth-discovery.controller|mcp-auth.guard"` → 4/4 pass - [ ] After deploy: ```bash curl -s -X POST https://<host>/oauth/register -H 'Content-Type: application/json' \ -d '{"client_name":"probe","redirect_uris":["https://claude.ai/api/mcp/auth_callback"],"token_endpoint_auth_method":"none"}' \ | jq .registration_client_uri # expect: "https://<host>/oauth/register/<uuid>" ``` - [ ] After deploy: add the MCP connector in Claude.ai — user should now reach the Twenty `/authorize` page ## Honesty This is the nth fix in a long debugging chain. Unlike the earlier round of fixes (which were real spec-compliance bugs but not Claude's blocker), this one is backed by empirical evidence across 3 known-working implementations. If Claude.ai still fails after this deploys, the remaining delta is `cli_client_id` in AS metadata (non-standard field, could confuse strict parsers) or a field we advertise that others don't (e.g. `client_credentials` grant) — both small, removable, not disruptive. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
46aedcf133 |
chore: sync AI model catalog from models.dev (#19866)
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev). This PR updates pricing, context windows, and model availability based on the latest data. New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically. Deprecated models are detected based on cost-efficiency within the same model family. **Please review before merging** — verify no critical models were incorrectly deprecated. Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com> |
||
|
|
75848ff8ea |
feat: move admin panel to dedicated /admin-panel GraphQL endpoint (#19852)
## Summary Splits admin-panel resolvers off the shared `/metadata` GraphQL endpoint onto a dedicated `/admin-panel` endpoint. The backend plumbing mirrors the existing `metadata` / `core` pattern (new scope, decorator, module, factory), and admin types now live in their own `generated-admin/graphql.ts` on the frontend — dropping 877 lines of admin noise from `generated-metadata`. ## Why - **Smaller attack surface on `/metadata`** — every authenticated user hits that endpoint; admin ops don't belong there. - **Independent complexity limits and monitoring** per endpoint. - **Cleaner module boundaries** — admin is a cross-cutting concern that doesn't match the "shared-schema configuration" meaning of `/metadata`. - **Deploy / blast-radius isolation** — a broken admin query can't affect `/metadata`. Runtime behavior, auth, and authorization are unchanged — this is a relocation, not a re-permissioning. All existing guards (`WorkspaceAuthGuard`, `UserAuthGuard`, `SettingsPermissionGuard(SECURITY)` at class level; `AdminPanelGuard` / `ServerLevelImpersonateGuard` at method level) remain on `AdminPanelResolver`. ## What changed ### Backend - `@AdminResolver()` decorator with scope `'admin'`, naming parallels `CoreResolver` / `MetadataResolver`. - `AdminPanelGraphQLApiModule` + `adminPanelModuleFactory` registered at `/admin-panel`, same Yoga hook set as the metadata factory (Sentry tracing, error handler, introspection-disabling in prod, complexity validation). - Middleware chain on `/admin-panel` is identical to `/metadata`. - `@nestjs/graphql` patch extended: `resolverSchemaScope?: 'core' | 'metadata' | 'admin'`. - `AdminPanelResolver` class decorator swapped from `@MetadataResolver()` to `@AdminResolver()` — no other changes. ### Frontend - `codegen-admin.cjs` → `src/generated-admin/graphql.ts` (982 lines). - `codegen-metadata.cjs` excludes admin paths; metadata file shrinks by 877 lines. - `ApolloAdminProvider` / `useApolloAdminClient` follow the existing `ApolloCoreProvider` / `useApolloCoreClient` pattern, wired inside `AppRouterProviders` alongside the core provider. - 37 admin consumer files migrated: imports switched to `~/generated-admin/graphql` and `client: useApolloAdminClient()` is passed to `useQuery` / `useMutation`. - Three files intentionally kept on `generated-metadata` because they consume non-admin Documents: `useHandleImpersonate.ts`, `SettingsAdminApplicationRegistrationDangerZone.tsx`, `SettingsAdminApplicationRegistrationGeneralToggles.tsx`. ### CI - `ci-server.yaml` runs all three `graphql:generate` configurations and diff-checks all three generated dirs. ## Authorization (unchanged, but audited while reviewing) Every one of the 38 methods on `AdminPanelResolver` has a method-level guard: - `AdminPanelGuard` (32 methods) — requires `canAccessFullAdminPanel === true` - `ServerLevelImpersonateGuard` (6 methods: user/workspace lookup + chat thread views) — requires `canImpersonate === true` On top of the class-level guards above. No resolver method is accessible without these flags + `SECURITY` permission in the workspace. ## Test plan - [ ] Dev server boots; `/graphql`, `/metadata`, `/admin-panel` all mapped as separate GraphQL routes (confirmed locally during development). - [ ] `nx typecheck twenty-server` passes. - [ ] `nx typecheck twenty-front` passes. - [ ] `nx lint:diff-with-main twenty-server` and `twenty-front` both clean. - [ ] Manual smoke test: log in with a user who has `canAccessFullAdminPanel=true`, open the admin panel at `/settings/admin-panel`, verify each tab loads (General, Health, Config variables, AI, Apps, Workspace details, User details, chat threads). - [ ] Manual smoke test: log in with a user who has `canImpersonate=false` and `canAccessFullAdminPanel=false`, hit `/admin-panel` directly with a raw GraphQL request, confirm permission error on every operation. - [ ] Production deploy note: reverse proxy / ingress must route the new `/admin-panel` path to the Nest server. If the proxy has an explicit allowlist, infra change required before cutover. ## Follow-ups (out of scope here) - Consider cutting over the three `SettingsAdminApplicationRegistration*` components to admin-scope versions of the app-registration operations so the admin page is fully on the admin endpoint. - The `renderGraphiQL` double-assignment in `admin-panel.module-factory.ts` is copied from `metadata.module-factory.ts` — worth cleaning up in both. |
||
|
|
6117a1d6c0 |
refactor: standardize AI acronym to Ai (PascalCase) across internal identifiers (#19837)
## Summary
The "AI" acronym was rendered inconsistently across the codebase. The
backend AI module had settled on PascalCase `Ai` (`AiAgentModule`,
`AiBillingService`, `AiChatModule`, `AiModelRegistryService`, etc.),
while frontend components, several DTOs, a few types, and shared
identifiers still used all-caps `AI` (`AIChatTab`,
`AISystemPromptPreviewDTO`, `SettingsPath.AIPrompts`, ...). CLAUDE.md
specifies PascalCase for classes; this PR normalizes everything internal
to `Ai`.
**This is a pure internal rename.** The GraphQL schema is untouched —
`@ObjectType` decorator string arguments, resolver method names (which
become Query/Mutation field names), gql template contents, and the
`generated-metadata/graphql.ts` file are preserved verbatim. The only
visible change is TypeScript identifiers and file names.
## Also folded in (adjacent cleanups)
- **`AgentModelConfigService` → `AiModelConfigService`**. Lives in
`ai-models/` and is used by multiple AI code paths, not just the Agent
entity. The "Agent" prefix was misleading.
- **`generate-text-input.dto.ts` → `generate-text.input.ts`**. The
`ai-agent/dtos/` folder already uses `<entity>.input.ts` convention for
Input classes (`create-agent.input.ts` etc.); the old path mixed
`.dto.ts` file extension with a class that has no DTO suffix. File
rename only; class stays `GenerateTextInput`.
- **Removed stale TODO** in `ai-model-config.type.ts` that asked for the
`AiModelConfig` rename that this PR performs.
## Rename methodology
Bulk rename via perl with anchored regex
`(?<!['"])(?<![A-Z.])AI([A-Z])(?=[a-z])/Ai$1/g`:
- **Lookbehind for non-uppercase** skips adjacent acronyms (`MOSAIC`,
`OIDCSSO`) and leaves `AIRBNB_ID` alone.
- **Lookbehind for non-quote** protects most string literals.
- **Lookahead for lowercase** restricts matches to PascalCase
identifiers (`AIChatTab`), leaving SCREAMING_SNAKE constants untouched.
Strict file-scope exclusions: `generated-metadata/**`, `generated/**`,
`locales/**`, `migrations/**`, `illustrations/**`, `halftone/**`, and
the two gql template files (`queries/getAISystemPromptPreview.ts`,
`mutations/uploadAIChatFile.ts`).
Post-rename reverts for identifiers where the regex was too eager:
- Backend resolver method names kept: `getAISystemPromptPreview`,
`uploadAIChatFile` (they are GraphQL field names).
- `@ObjectType('AdminAIModels')` / `('AISystemPromptPreview')` /
`('AISystemPromptSection')` kept as-is.
- Backend classes `ClientAIModelConfig` / `AdminAIModelConfig` kept
as-is (they use `@ObjectType()` with no argument, so the class name IS
the schema name).
- External-library symbols restored: `OpenAIProvider`,
`createOpenAICompatible`, `vercelAIIntegration`.
File renames use a two-step rename to work on macOS case-insensitive
filesystems: `git mv X.tsx X.tsx.tmp && git mv X.tsx.tmp renamed.tsx`.
## Diff audit
- 0 changes to migrations
- 0 changes to locale `.po` / `.ts` files
- 0 changes to `generated-metadata/graphql.ts`
- 0 changes to website illustration files (base64 blobs preserved)
- 0 renames inside user-facing translation strings (`t\`…\``,
`msg\`…\``, `<Trans>…</Trans>`)
## Test plan
- [x] `npx nx typecheck twenty-server` — PASS
- [x] `npx nx typecheck twenty-front` — PASS
- [x] `npx jest ai-model admin agent-role` — 79/79 PASS
- [x] `npx oxlint --type-aware` on 118 changed files — 0 errors
- [x] `npx prettier --check` on 118 changed files — clean
- [ ] CI
|
||
|
|
1e27c3b621 |
fix: correct sSOService → ssoService camelCase typo (#19845)
## Summary Fixes the pre-existing camelCase typo mentioned in #19839. The injected `SSOService` property was named `sSOService` instead of the correct camelCase `ssoService` across the auth module. This is a straightforward mechanical rename of the property/variable name — no logic changes. > **Bonus: pre-existing typo to fix** — `private sSOService: SSOService` — The variable name is a camelCase typo (`sSOService` instead of `ssoService`). — #19839 ## Changes Renamed `sSOService` → `ssoService` in 7 files: - `auth/guards/oidc-auth.guard.ts` - `auth/guards/saml-auth.guard.ts` - `auth/guards/oidc-auth.spec.ts` - `auth/auth.resolver.ts` - `auth/controllers/sso-auth.controller.ts` - `auth/strategies/saml.auth.strategy.ts` - `sso/sso.resolver.ts` Note: The type `SSOService` (PascalCase class name) is intentionally left unchanged — it will be addressed in the broader SSO acronym PR from #19839. ## Test plan - [ ] Verify `typecheck twenty-server` passes - [ ] Verify existing auth/SSO tests pass Co-authored-by: Abhay <abhayjnayakpro@gmail.com> |
||
|
|
5223c4771d |
fix(server): align OAuth discovery metadata with MCP / RFC 9728 spec (#19838)
## Summary Three small spec-compliance fixes called out in an audit against the [MCP authorization spec (draft)](https://modelcontextprotocol.io/specification/draft/basic/authorization) and RFC 9728 / RFC 9207. ### 1. Split Protected Resource Metadata by path (RFC 9728 §3.2) > The `resource` value returned MUST be identical to the protected resource's resource identifier value into which the well-known URI path suffix was inserted. Today a single handler serves both \`/.well-known/oauth-protected-resource\` and \`/.well-known/oauth-protected-resource/mcp\` and returns \`resource: <origin>/mcp\` from both. That's wrong for the root form — per RFC 9728 the root URL corresponds to the **origin as resource**, and only the \`/mcp\`-suffixed URL corresponds to \`<origin>/mcp\`. After this PR: | Request | `resource` field | |---|---| | `GET /.well-known/oauth-protected-resource` | `https://<host>` | | `GET /.well-known/oauth-protected-resource/mcp` | `https://<host>/mcp` | Both still return the same `authorization_servers`, `scopes_supported`, and `bearer_methods_supported`. Claude's current flow happens to work because our WWW-Authenticate points at the root form and Claude compares `resource` against what it connected to. Strict clients probing the path-aware URL first were rejecting us. ### 2. Advertise `authorization_response_iss_parameter_supported: true` (RFC 9207) Defense against OAuth mix-up attacks. Required by the [OAuth 2.1 security BCP](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1). Signals that clients receiving an authorization response will find the issuer in the `iss` parameter and can validate it. ### 3. Fix `WWW-Authenticate` challenge: point at path-aware PRM URL, add `scope` param - Was: `Bearer resource_metadata=\"https://<host>/.well-known/oauth-protected-resource\"` - Now: `Bearer resource_metadata=\"https://<host>/.well-known/oauth-protected-resource/mcp\", scope=\"api profile\"` After change (1), only the path-aware URL returns a PRM document whose `resource` matches what the MCP client connected to (\`<host>/mcp\`). Pointing clients at the right URL keeps discovery consistent. The `scope` parameter is a SHOULD in RFC 6750 and lets clients ask for least-privilege scopes on first authorization. ## Not in this PR (queued separately) From the same audit: - **Audit JWT `aud` (audience) validation** — the spec requires the server to reject tokens whose audience doesn't match this resource. Need a read-only code review to confirm; filing as a follow-up. - **Audit PKCE enforcement** — we advertise `code_challenge_methods_supported: [\"S256\"]`; need to confirm the \`/authorize\` flow actually rejects requests missing `code_challenge`. - **403 `insufficient_scope` challenge format** for step-up auth. - **CIMD (Client ID Metadata Documents)** support — newer spec alternative to DCR. ## Test plan - [x] \`yarn jest --testPathPatterns=\"mcp-auth.guard|oauth-discovery.controller\"\` → 4/4 passing - [x] \`tsc --noEmit\` clean on touched files - [ ] After deploy: \`\`\`bash curl -s https://<host>/.well-known/oauth-protected-resource | jq .resource # expect: \"https://<host>\" curl -s https://<host>/.well-known/oauth-protected-resource/mcp | jq .resource # expect: \"https://<host>/mcp\" curl -sI -X POST https://<host>/mcp | grep -i www-authenticate # expect: Bearer resource_metadata=\"…/oauth-protected-resource/mcp\", scope=\"api profile\" \`\`\` ## Related - #19836 — CORS exposes `WWW-Authenticate` + `MCP-Protocol-Version` so browser clients can read them. Pairs with this PR. - #19755 / #19766 / #19824 — the earlier chain that got host-aware discovery and \`TRUST_PROXY\` working. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
53d22a3b70 |
fix(server): require PKCE code_challenge for public OAuth clients (#19840)
## Summary
OAuth 2.1 and the MCP authorization spec mandate PKCE (S256) for public
clients — clients registered with \`token_endpoint_auth_method=none\`
(no client secret). We advertise \`code_challenge_methods_supported:
[\"S256\"]\` in \`/.well-known/oauth-authorization-server\` but our
\`/authorize\` flow accepted requests from public clients without
\`code_challenge\`.
## Why this was a soft failure today
\`oauth.service.ts:178\` already rejects token exchange when a client
presents neither \`client_secret\` nor \`code_verifier\`:
\`\`\`ts
if (!clientSecret && !storedCodeChallenge) {
return this.errorResponse('invalid_request', 'Either client_secret or
code_verifier (PKCE) is required');
}
\`\`\`
So a public client attempting to bypass PKCE would **eventually** fail —
but only after:
1. Getting a valid authorization code issued at \`/authorize\`
2. Round-tripping the user through consent
3. Trying to exchange the code at \`/token\` and finally getting
rejected
That's a wasted user interaction and a fuzzy spec boundary. This PR
rejects at \`/authorize\` instead, matching the spec's \"MUST require
PKCE for public clients\" expectation.
## Fix
Single check in \`AuthService.generateAuthorizationCode\`:
\`\`\`ts
const isPublicClient = !applicationRegistration.oAuthClientSecretHash;
if (isPublicClient && !codeChallenge) {
throw new AuthException(
\`code_challenge is required for public clients (PKCE S256, per OAuth
2.1)\`,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
\`\`\`
### Why \`!oAuthClientSecretHash\` is the right \"public\" predicate
- Dynamic registration (\`POST /oauth/register\`) hardcodes
\`oAuthClientSecretHash: null\` and rejects any
\`token_endpoint_auth_method != \"none\"\`
(oauth-registration.controller.ts:120-130).
- Confidential clients registered via the workspace settings UI have a
non-null bcrypt hash.
- The same field is already used as the public/confidential gate in
\`validateClient\` and \`validateClientSecret\`.
## Scope
- ✅ Dynamic-registration clients (Claude, other MCP connectors) — MUST
now supply code_challenge. They already do; no behavior change for
conformant clients.
- ✅ The seeded twenty-cli registration — public client, already uses
PKCE. No change.
- ➖ Confidential clients (workspace-admin-registered OAuth apps with a
client_secret) — unaffected, they authenticate at the token endpoint.
## Related
- #19836 — CORS exposes \`WWW-Authenticate\` / \`MCP-Protocol-Version\`
- #19838 — RFC 9728 PRM split + RFC 9207 iss param + \`scope\` in
WWW-Authenticate challenge
## Test plan
- [x] \`tsc --noEmit\` clean on modified file (pre-existing
\`twenty-shared\` dist errors unrelated)
- [ ] Integration-level smoke test after deploy:
\`\`\`bash
# Register a dynamic client (public)
CLIENT_ID=$(curl -s -X POST -H 'Content-Type: application/json' \\
-d
'{\"client_name\":\"pkce-test\",\"redirect_uris\":[\"http://localhost/cb\"]}'
\\
https://<host>/oauth/register | jq -r .client_id)
# Without code_challenge → should now 4xx at /authorize (cannot easily
test outside the React UI,
# but the GraphQL authorizeApp mutation will throw AuthException)
\`\`\`
- [ ] Claude MCP connector still completes OAuth end-to-end (it always
sends code_challenge, so no-op)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
|
||
|
|
1c54e79d6c |
i18n - translations (#19843)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
be9616db60 | chore: remove draft email feature flag (#19842) | ||
|
|
c28c20143b |
refactor(server): rename Agent exception to Ai; add THREAD_NOT_FOUND / MESSAGE_NOT_FOUND codes (fixes 500s) (#19831)
## Summary - The exception class under `ai-agent/` was serving every AI surface (agent, chat, role, models, generate-text), so `Agent` was a misnomer. Promoted to the `ai/` namespace; renamed `AgentException` → `AiException`, `AgentExceptionCode` → `AiExceptionCode`, and related interceptor / filter / handler / file names accordingly. - Split the single `AGENT_NOT_FOUND` code into entity-specific codes. Chat-thread lookups no longer reuse the agent identifier. - **Fixes Sentry 500s on `GetChatMessages` / `chatThread`.** Every "Thread not found" and "Queued message not found" throw site in ai-chat was previously wired to `AGENT_EXECUTION_FAILED`, which maps to `InternalServerError` (HTTP 500). They now use `THREAD_NOT_FOUND` / `MESSAGE_NOT_FOUND`, both of which map to `NotFoundError` (HTTP 404) in the GraphQL and REST handlers. The underlying cause of *why* clients are asking for threads that no longer resolve for them — per-user chat-thread create events being broadcast workspace-wide — is addressed separately in a follow-up PR. ### Code map - Added: `ai/ai.exception.ts`, `ai/utils/ai-graphql-api-exception-handler.util.ts` (+ spec with new THREAD/MESSAGE cases), `ai/interceptors/ai-graphql-api-exception.interceptor.ts`, `ai/filters/ai-api-exception.filter.ts` - Deleted: `ai/ai-agent/agent.exception.ts`, `ai/ai-agent/utils/agent-graphql-api-exception-handler.util.ts` (+ spec), `ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor.ts`, `ai/ai-agent/filters/agent-api-exception.filter.ts` - Updated: 21 call sites across ai-agent, ai-agent-execution, ai-agent-role, ai-chat, ai-generate-text, ai-models, role, and workspace-migration validators. ## Test plan - [x] `npx nx typecheck twenty-server` - [x] `npx jest ai-graphql-api-exception-handler` (3/3 including new THREAD_NOT_FOUND and MESSAGE_NOT_FOUND cases) - [x] `npx jest agent-role.service` (9/9) - [x] `npx oxlint --type-aware` on all changed files (0 warnings/errors) - [x] `npx prettier --check` on all changed files - [ ] CI |
||
|
|
eb1ca1b9ec |
perf(sdk): split twenty-sdk barrel into per-purpose subpaths to cut logic-function bundle ~700x (#19834)
## Summary
Logic-function bundles produced by the twenty-sdk CLI were ~1.18 MB even
for a one-line handler. Root cause: the SDK shipped as a single bundled
barrel (`twenty-sdk` → `dist/index.mjs`) that co-mingled server-side
definition factories with the front-component runtime, validation (zod),
and React. With no `\"sideEffects\"` declaration on the SDK package,
esbuild had to assume every module-level statement could have side
effects and refused to drop unused code.
This PR restructures the SDK so consumers' bundlers can tree-shake at
the leaf level:
- **Reorganized SDK source.** All server-side definition factories now
live under `src/sdk/define/` (agents, application, fields,
logic-functions, objects, page-layouts, roles, skills, views,
navigation-menu-items, etc.). All front-component runtime
(components, hooks, host APIs, command primitives) lives under
`src/sdk/front-component/`. The legacy bare `src/sdk/index.ts` is
removed; the bare `twenty-sdk` entry no longer exists.
- **Split the build configs by purpose / runtime env.** Replaced
`vite.config.sdk.ts` with two purpose-specific configs:
- `vite.config.define.ts` — node target, externals from package
`dependencies`, emits to `dist/define/**`
- `vite.config.front-component.ts` — browser/React target, emits to
`dist/front-component/**`
Both use `preserveModules: true` so each leaf ships as its own `.mjs`.
- **\`\"sideEffects\": false\`** on `twenty-sdk` so esbuild can drop
unreferenced re-exports.
- **\`package.json\` exports + \`typesVersions\`** updated: dropped the
bare \`.\` entry, added \`./front-component\`, and pointed \`./define\`
at the new per-module dist layout.
- **Migrated every internal/example/community app** to the new subpath
imports (`twenty-sdk/define`, `twenty-sdk/front-component`,
`twenty-sdk/ui`).
- **Added \`bundle-investigation\` internal app** that reproduces the
bundle bloat and demonstrates the fix.
- Cleaned up dead \`twenty-sdk/dist/sdk/...\` references in the
front-component story builder, the call-recording app, and the SDK
tsconfig.
## Bundle size impact
Measured with esbuild using the same options as the SDK CLI
(\`packages/twenty-apps/internal/bundle-investigation\`):
| Variant | Imports | Before | After |
| ----------------------- |
------------------------------------------------------- | ---------- |
--------- |
| \`01-bare\` | \`defineLogicFunction\` from \`twenty-sdk/define\` |
1177 KB | **1.6 KB** |
| \`02-with-sdk-client\` | + \`CoreApiClient\` from
\`twenty-client-sdk/core\` | 1177 KB | **1.9 KB** |
| \`03-fetch-issues\` | + GitHub GraphQL fetch + JWT signing + 2
mutations | 1181 KB | **5.8 KB** |
| \`05-via-define-subpath\` | same as \`01\`, via the public subpath |
1177 KB | **1.7 KB** |
That's a ~735× reduction on the bare baseline. Knock-on benefits for
Lambda warm + cold starts, S3 upload size, and \`/tmp\` disk usage in
warm containers.
## Test plan
- [x] \`npx nx run twenty-sdk:build\` succeeds
- [x] \`npx nx run twenty-sdk:typecheck\` passes
- [x] \`npx nx run twenty-sdk:test:unit\` passes (31 files / 257 tests)
- [x] \`npx nx run-many -t typecheck
--projects=twenty-front,twenty-server,twenty-front-component-renderer,twenty-sdk,twenty-shared,bundle-investigation\`
passes
- [x] \`node
packages/twenty-apps/internal/bundle-investigation/scripts/build-variants.mjs\`
produces the sizes above
- [ ] CI green
Made with [Cursor](https://cursor.com)
|
||
|
|
fd495ee61b |
fix(server): deliver user-scoped metadata events only to the owning user (#19832)
## Summary Fixes cross-user cache contamination for AI chat threads in multi-user workspaces. `agentChatThread` is the only user-scoped (`userWorkspaceId`-filtered) entry in `METADATA_NAME_TO_ENTITY_KEY`, but its create/update events were going through `WorkspaceEventBroadcaster`, which fans out to every active SSE stream in the workspace. Every client therefore received other users' threads into their local `agentChatThreads` metadata store (which is persisted to localStorage). On a subsequent session, `AgentChatThreadInitializationEffect` would pick the most-recently-updated thread — potentially another user's — and fire `GetChatMessages` against it; the server's `userWorkspaceId` filter didn't match, producing "Thread not found" errors (now 404 thanks to twentyhq/twenty#19831). ### The fix mirrors the RLS pattern already used for object records `ObjectRecordEventPublisher` already reads `streamData.authContext.userWorkspaceId` to filter per subscriber. Metadata events had no equivalent. This PR closes that gap with a minimal, opt-in change: - Add optional `recipientUserWorkspaceIds?: string[]` to `WorkspaceBroadcastEvent`. Omit → workspace-wide (unchanged for views, objects, fields, etc.). Set → delivered only to streams whose `authContext.userWorkspaceId` is in the list. - `WorkspaceEventBroadcaster.broadcast` builds the payload per stream and skips events whose recipient doesn't match. - Both `agentChatThread` broadcast call sites in `AgentChatService` now pass `recipientUserWorkspaceIds: [userWorkspaceId]`. ### Scope 3 files, +40/-11 LOC: - `subscriptions/workspace-event-broadcaster/types/workspace-broadcast-event.type.ts` - `subscriptions/workspace-event-broadcaster/workspace-event-broadcaster.service.ts` - `metadata-modules/ai/ai-chat/services/agent-chat.service.ts` ### Stale cache note Existing clients still carry poisoned localStorage from before this lands. They self-heal on sign-out/in because `clearAllSessionLocalStorageKeys` already drops `agentChatThreads`. If we want to actively flush on deploy, a frontend cache-version bump can follow in a separate PR. ### Related - twentyhq/twenty#19831 turns the resulting "Thread not found" from 500 to 404. This PR addresses the root cause; #19831 stops the Sentry noise. ## Test plan - [x] `npx nx typecheck twenty-server` - [x] `npx oxlint --type-aware` on all 3 files (0 warnings/errors) - [x] `npx prettier --check` on all 3 files - [ ] Manual: two users in the same workspace, user A creates/sends a chat thread — confirm user B's session does not receive the event and their `chatThreads` sidebar is unaffected - [ ] CI |
||
|
|
70a73534c0 |
perf(server): reuse ESM module cache across warm Lambda invocations of logic functions (#19830)
## Summary Lambda warm-invocations of logic functions were spending **~440 ms** re-parsing and re-evaluating the user bundle on every call. The executor wrote the user code to a **randomly-named** temp file and `import()`-ed it, so each warm call resolved to a new URL and Node's ESM cache could never reuse the previous module record. This PR makes the executor write to a **content-hash filename**, skip the write when the file already exists, and stop deleting it. Identical code now reuses the same module record across warm calls in the same container, dropping warm-invocation overhead by **~30–40%**. ## What changed - `executor/index.mjs`: temp filename derived from `sha256(code)`, write skipped when file exists, no `fs.rm` on cleanup. - `lambda.driver.ts`: single structured `[lambda-timing]` log per invocation with `totalMs / buildExecutorMs / getBuiltCodeMs / payloadBytes / invokeSendMs / reportDurationMs / billedMs / initDurationMs / coldStart`. Goes through the standard NestJS `Logger`. No behavioural change for callers: same input → same output, same error semantics. ### Caveat: module-scope state now persists across warm calls With a stable filename, the user bundle is evaluated **once per warm container**. Any module-scoped state or top-level side-effects in user code are now shared across invocations of the same container, instead of re-running on every call. This is documented in the executor and is the intended trade-off — module scope should be treated as a per-container cache, not as per-call isolation. ## Findings — measured impact Same logic function (`fetch-prs`, ~12k PRs to page through), same workspace, same Lambda config (eu-west-3, 512 MB), token cache primed. ### Warm invocations | Phase | Before fix | After fix | Δ | | -------------------------------------- | ------------- | -------------- | ------------ | | Executor `import(userBundle)` | ~440 ms | **~0 ms** | **-440 ms** | | Lambda billed duration | ~1.5–1.7 s | **~1.0–1.1 s** | **~30–40%** | | Server-perceived round-trip | ~1.7–2.0 s | **~1.0–1.2 s** | **~30–40%** | ### Cold starts Unchanged — the cache helps subsequent warm calls in the same container, not the first one. Init Duration stays ~130–170 ms; total cold call ~2.5–3.0 s. ### Stress Could not reproduce the previously-reported \"every ~10th call times out\" behaviour after the fix: - 30 sequential calls: max 1.7 s, median ~1.1 s, 0 timeouts - 50 concurrent calls: max 9.4 s (clear cold-start cluster), median ~1.5 s, 0 timeouts Hypothesis: the warm-import overhead was eating into the headroom against the function timeout under bursty load; removing it pushed everything well below the limit. ## Observability One structured log line per invocation, sent through the standard NestJS logger: \`\`\` [lambda-timing] fnId=abc123 totalMs=1187 buildExecutorMs=2 getBuiltCodeMs=3 payloadBytes=1466321 invokeSendMs=1180 reportDurationMs=992 billedMs=1000 initDurationMs=n/a coldStart=false \`\`\` \`coldStart=true\` whenever Lambda spun up a fresh container; on warm calls \`buildExecutorMs\` and \`getBuiltCodeMs\` collapse to single-digit ms, confirming the cache fix is working. ## Test plan - [ ] CI green. - [ ] Deploy to a Lambda-backed env, trigger a logic function several times in a row. - [ ] Confirm \`[lambda-timing]\` warm invocations show \`totalMs\` ~30–40% lower than before, and \`coldStart=false\` after the first call in a container. - [ ] Push a new version of an app; confirm the next call shows higher \`buildExecutorMs\` (new hash, new file written) followed by warm calls again. - [ ] Smoke test: errors thrown by the user handler are still surfaced correctly. Made with [Cursor](https://cursor.com) |
||
|
|
1d575f0496 |
fix oauth permission check (#19829)
was regressed due to https://github.com/twentyhq/twenty/pull/19441 |
||
|
|
768469f2bd |
chore: sync AI model catalog from models.dev (#19828)
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev). This PR updates pricing, context windows, and model availability based on the latest data. New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically. Deprecated models are detected based on cost-efficiency within the same model family. **Please review before merging** — verify no critical models were incorrectly deprecated. Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com> |
||
|
|
b292a93376 |
fix(server): honor X-Forwarded-* via configurable trust proxy (#19824)
## The bug Pasting `https://<workspace>.twenty.com/mcp` into an MCP client (Claude connector, etc.) fails discovery. Curl shows why: ```bash $ curl -si https://twentyfortwenty.twenty.com/.well-known/oauth-protected-resource HTTP/2 200 ... { \"resource\": \"http://{workspace}.twenty.com/mcp\", \"authorization_servers\": [\"http://twentyfortwenty.twenty.com\"], ... } ``` The response advertises `http://` even though the request came in on `https://`. RFC 9728 / RFC 8707 require the client to validate that the advertised `resource` matches the URL it connected to, so strict MCP clients reject the mismatch and OAuth never starts. ## Why request.protocol returns \"http\" Per [Express docs](https://expressjs.com/en/guide/behind-proxies.html), `request.protocol` returns the socket-level protocol unless `app.set('trust proxy', ...)` is configured. In our deployment: ``` client -- https --> Cloudflare -- https --> ingress-nginx -- http --> NestJS pod ``` TLS is terminated at the edge. The upstream TCP connection into the pod is plain HTTP, and nginx sets `X-Forwarded-Proto: https` for the pod to read. Without a `trust proxy` setting, Express ignores `X-Forwarded-Proto` and `request.protocol === 'http'`. `main.ts` currently has no `app.set('trust proxy', ...)` call anywhere. ## Why this only surfaced now `grep -rn request.protocol` finds three pre-existing call sites — `RestApiMetadataService`, `OpenApiService`, `RouteTriggerService`. All three wrap it in `getServerUrl({ serverUrlEnv: SERVER_URL, serverUrlFallback: \`${request.protocol}://${request.get('host')}\` })`, which returns `SERVER_URL` whenever it's non-empty. In production `SERVER_URL` is always set (e.g. \`api.twenty.com\`), so the \`request.protocol\` branch is effectively dead code there. #19755 introduced the first call site that uses `request.protocol` unconditionally — the OAuth discovery controller has to echo the request host, because the whole point is supporting multiple paste-able origins (workspace subdomains, custom domains, etc.). That's why this is the first \"wrong protocol\" bug anyone has seen in our app. ## The fix One line in `main.ts`: ```ts app.set('trust proxy', twentyConfigService.get('TRUST_PROXY')); ``` Backed by a new `TRUST_PROXY` env var with a default. `request.protocol` then honors `X-Forwarded-Proto`, `request.ip` honors `X-Forwarded-For`, etc. OAuth discovery URLs come out on the right scheme, and any future `request.protocol` callers Just Work. ## Why this needs to be configurable (not hardcoded) Twenty is open-source and deployed in at least three distinct topologies: 1. **Kubernetes with ingress** (us, enterprise self-hosters) — TLS terminated upstream, needs `trust proxy` **on**. 2. **Self-host behind a user-supplied reverse proxy** (Caddy, Traefik, nginx — our [recommended setup](https://twenty.com/developers/section/self-hosting)) — same as above, needs `trust proxy` **on**. 3. **Self-host with NestJS exposed directly to the internet** — no upstream proxy, needs `trust proxy` **off** (otherwise any curl with `X-Forwarded-For: 1.2.3.4` spoofs `request.ip`, poisoning rate-limiters and audit logs). There is no single static value that's correct for all three. Express makes this a setting for exactly this reason — we follow suit. ## Why the default is `'loopback, linklocal, uniquelocal'` Shorthand for loopback (127/8, ::1), link-local (169.254/16, fe80::/10), and unique-local (10/8, 172.16/12, 192.168/16, fc00::/7). In practical terms: **trust peers coming from private networks; don't trust the public internet**. This default is correct for shapes 1 and 2 (cloud, proxied self-host) because the ingress/proxy peer is always a private-network IP in every sane deployment. For shape 3 (directly exposed), the default is still safe because public clients have public IPs, which are not in any of those ranges — so `X-Forwarded-For` from an attacker on the internet is ignored. The only way to be bitten is the exotic case where a public client reaches NestJS through a private-network hop that isn't a proxy (e.g. a NAT appliance that forwards to the pod on a private IP and blindly appends headers). Narrow attack surface, and an operator running that kind of setup is expected to configure `TRUST_PROXY=false` explicitly. \"Safer than the naïve `true`, more useful than `false`\" — this matches what Rails, Django, and many other frameworks recommend for Kubernetes-style deployments. ## Why an env var instead of hardcoded - Rejecting hardcoded `true`: would expose shape-3 self-hosters to IP spoofing without a way to opt out. - Rejecting hardcoded `false`: would leave cloud + shape-2 self-hosters broken, same bug as today. - Accepting string-typed env (not boolean): Express's `trust proxy` accepts booleans, hop counts (`1`, `2`), IP ranges (`'10.0.0.0/8'`), and named CIDRs (`'loopback'`). A boolean would hide that flexibility; operators occasionally need the richer values. The string maps 1:1 onto what Express accepts. ## Deployment matrix | Deployment | Default works? | Override needed? | |---|---|---| | Cloud (us, K8s + nginx ingress + Cloudflare) | ✓ | — | | Self-host behind reverse proxy (recommended) | ✓ | — | | Self-host exposed directly on public IP | ✓ (public IPs not in private ranges) | Optional: `TRUST_PROXY=false` for strictness | | Local dev (direct, no proxy) | ✓ (no `X-Forwarded-*` headers arrive) | — | | Exotic: multi-hop through non-sanitizing private-network middlebox | Risky | `TRUST_PROXY=false` | ## Related - Blocks MCP connector OAuth on `<ws>.twenty.com` / custom domains. After deploy: `curl -s https://<ws>.twenty.com/.well-known/oauth-protected-resource | jq .resource` should return `https://...` (not `http://...`). - Fixes latent issue in `RestApiMetadataService`, `OpenApiService`, `RouteTriggerService` fallback paths (pre-existing but dead in production because `SERVER_URL` is always set — no behavior change there). ## Test plan - [x] `tsc --noEmit` clean - [ ] After deploy: `curl -s https://twentyfortwenty.twenty.com/.well-known/oauth-protected-resource` returns `https://` URLs - [ ] After deploy: MCP connector in Claude successfully completes OAuth against `https://<ws>.twenty.com/mcp` - [ ] No change in `request.ip` logging behavior on cloud (nginx-ingress peer is already private-network, was already being trusted implicitly by every framework layer that wasn't `request.protocol`) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
e878d646ed |
chore(server): bump logic-function executor lambda memory to 512MB (#19826)
## Summary The executor lambda for user logic functions is created in `LambdaDriver` without a `MemorySize` parameter, so AWS Lambda falls back to its 128 MB default. That cap is too tight for non-trivial logic functions — large upstream GraphQL responses, JSON parsing of paginated batches, and chained Twenty Core API mutations push the process over the limit and trigger an OOM SIGKILL surfaced to the user as: ``` Runtime exited with error: signal: killed ``` This bumps the executor lambda memory to **512 MB** (matching the existing `BUILDER_LAMBDA_MEMORY_MB`). The change is applied on both: - The `CreateFunctionCommand` path used when a logic function is first deployed. - The `UpdateFunctionConfigurationCommand` path used when the deps/SDK layer wiring is refreshed — so existing functions get reconfigured on next deploy without any additional manual action. ## Why 512 MB Lambda compute is allocated proportionally to memory. 512 MB: - Matches the builder lambda already in this file (`BUILDER_LAMBDA_MEMORY_MB`). - Is comfortably above the 128 MB default that the existing OOMs are hitting. - Stays well below the higher tiers, keeping the per-invocation cost increase modest. Made with [Cursor](https://cursor.com) |
||
|
|
59e4ed715a |
fix(server): normalize empty composite phone sub-fields to NULL (#19775)
Fixed using Opus 4.7, I wanted to test this model out and in this repo I know you guys care about quality, pls let me know if this is good code. It looks good to me Fixes #19740. ## Summary PostgreSQL UNIQUE indexes treat two `''` values as duplicates but two `NULL`s as distinct. `validateAndInferPhoneInput` was persisting blank `primaryPhoneNumber` as `''` instead of `NULL`, so a second record with an empty unique phone failed with a constraint violation. The sibling composite transforms (`transformEmailsValue`, `removeEmptyLinks`, `transformTextField`) already canonicalize null-equivalent values; phones was the outlier. - Empty-string phone sub-fields now normalize to `null`. `undefined` is preserved so partial updates leave columns the user did not touch alone. - `PhonesFieldGraphQLInput` drops the aspirational `CountryCode` brand on input. GraphQL delivers raw strings at the boundary; branding happens during validation. --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
df9c4e26b5 |
Disable reset to default when custom tab or widget (#19814)
## Context "Reset to default" action is rejected by the backend for custom entities because there is no "default" concept for them ## Implementation Grey out the Reset to default action on record page-layout tabs and widgets when the entity either has no applicationId yet (unsaved draft — previously slipped through the existing check), or belongs to the workspace custom application. <img width="926" height="370" alt="Screenshot 2026-04-17 at 18 50 31" src="https://github.com/user-attachments/assets/c7c163f4-17a6-4b69-a66d-90f9085d27a2" /> |
||
|
|
0cb50f8a9d | Fix indexFieldMetadata select missing workspaceId (#19806) | ||
|
|
68746e22a0 |
Send Email Tool: Don't persist message on SMTP only connections (#19756)
Previously this blocked users who only had SMTP configured to send outbound emails, this fixes it by making messageChannel and persist layer conditional |
||
|
|
f94ee2d495 |
i18n - translations (#19795)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
bb464b2ffb |
Forbid other app role extension (#19783)
# Introduction Even though this would not possible through API at the moment, from neither API metadata or manifest ( as manifest `permissionsFlag` declarations etc are done from within a declared role ) Prevent any app to create permissions entities over another app role from the validation engine itself ## `isEditable` We might wanna deprecate this column at some point from the entity it self as now the grain would rather be `what app owns that role ?` --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
beeb8b7406 |
chore: move pageLayoutWidget.conditionalAvailabilityExpression migration to 1.23 fast instance command (#19792)
## Summary - Replaces the standalone TypeORM migration `1775654781000-addConditionalAvailabilityExpressionToPageLayoutWidget.ts` with a registered fast instance command under `packages/twenty-server/src/database/commands/upgrade-version-command/1-23/`, so the `pageLayoutWidget.conditionalAvailabilityExpression` column is created through the unified upgrade pipeline. - Uses `ADD COLUMN IF NOT EXISTS` / `DROP COLUMN IF EXISTS` so the new instance command is a safe no-op for environments that already applied the previous TypeORM migration. - Keeps the original timestamp `1775654781000` so the command slots chronologically into the existing 1.23 sequence; auto-discovered via `@RegisteredInstanceCommand`, no module wiring needed. ## Context Reported error when creating a new workspace on `main`: > column PageLayoutWidgetEntity.conditionalAvailabilityExpression does not exist Aligns this column addition with the rest of the 1.23 schema changes that already use the instance-command pattern. |
||
|
|
5cd8b7899d |
shouldIncludeRecordPageLayouts deprecation (#19774)
## Context Deprecating shouldIncludeRecordPageLayouts in preparation for page layout release. See new workspace with standard page layout from standard app <img width="570" height="682" alt="Screenshot 2026-04-16 at 18 35 23" src="https://github.com/user-attachments/assets/bf7fa621-d40d-4c29-8d96-537c58b3eb40" /> |
||
|
|
76ea0f37ed |
Surface structured validation errors during application install (#19787)
## Summary - Add `WorkspaceMigrationGraphqlApiExceptionInterceptor` to `MarketplaceResolver` and `ApplicationInstallResolver` so validation failures during app install return `METADATA_VALIDATION_FAILED` with structured `extensions.errors` instead of generic `INTERNAL_SERVER_ERROR` - Update SDK `installTarballApp()` to pass the full GraphQL error object (including extensions) through the install flow - Add `formatInstallValidationErrors` utility to format structured validation errors for CLI output - Add integration test verifying structured error responses for invalid navigation menu items and view fields --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
b31f84fbb8 |
fix(server): workspace member permissions and profile onboarding (#19786)
## Summary Aligns **workspace member** editing and **onboarding** with how the product is actually used: profile and other “settings” fields go through **`updateWorkspaceMemberSettings`**, while **`/graphql`** record APIs follow **object-level** permissions for the `workspaceMember` object. ## Product behaviour ### Completing “Create profile” onboarding Users who must create a profile (empty name at sign-up) get `ONBOARDING_CREATE_PROFILE_PENDING` set. The onboarding UI saves the name with **`updateWorkspaceMemberSettings`**, not with a workspace record **`updateOne`**. **Before:** The server only cleared the pending flag on **`workspaceMember.updateOne`**, so the flag could stay set and onboarding appeared stuck. **After:** Clearing the profile step runs when **`updateWorkspaceMemberSettings`** persists an update that includes a **name** (same rules as before: non-empty name parts). Onboarding can advance normally after **Continue** on Create profile. ### Two ways to change workspace member data | Path | Typical use | Who can change what | |------|----------------|---------------------| | **`updateWorkspaceMemberSettings`** (metadata API) | Standard member fields the app treats as “my profile / preferences” (name, avatar-related settings, locale, time zone, etc.) | **Always** your **own** workspace member. Changing **another** member still requires **Workspace members** in role settings (`WORKSPACE_MEMBERS`). Custom fields are **not** allowed on this endpoint (unchanged). | | **`/graphql`** record mutations on **`workspaceMember`** | Custom fields, integrations, anything that goes through the generic record API | **`WorkspaceMember`** is special-cased in permissions: **read** stays **on** for everyone, but **update / create / delete** require **`WORKSPACE_MEMBERS`**, including updating **your own** row via `/graphql`. So a **Member** without that permission cannot fix their name through **`updateWorkspaceMember`**; they use **Settings** / **`updateWorkspaceMemberSettings`** instead. | This matches **`WorkspaceRolesPermissionsCacheService`**: for the workspace member object, `canReadObjectRecords` is always true; `canUpdateObjectRecords` (and delete-related flags) follow **`WORKSPACE_MEMBERS`**. ### Hooks and delete side-effects - Removed **`workspaceMember.updateOne`** pre-query hook and **`WorkspaceMemberPreQueryHookService`**: they duplicated the same rules the permission cache already enforces for `/graphql`. - **`WorkspaceMember.deleteOne`** pre-hook still tells users to remove members via the dedicated flow; the post-hook only runs the **`deleteUserWorkspace`** side-effect when a member row is actually removed—**no** extra settings-permission check there, since only callers that already passed **object** delete permission can remove the row. ## Tests - **`workspace-members.integration-spec.ts`**: clarifies and extends coverage so **`/graphql`** **`updateOne`** is denied for **own** record on a **standard** name field and on a **custom** field when the role lacks **`WORKSPACE_MEMBERS`**. ## Implementation notes - **`OnboardingService.completeOnboardingProfileStepIfNameProvided`** centralises the “clear profile pending if name present” logic; **`UserResolver.updateWorkspaceMemberSettings`** calls it after save, using the typed update payload’s **`name`** (no cast). - **`UserWorkspaceService.updateUserWorkspaceLocaleForUserWorkspace`**: drops a redundant **`coreEntityCacheService.invalidate`**; **`updateWorkspaceMemberSettings`** still invalidates the user-workspace cache after the mutation. |
||
|
|
d7453303b8 |
chore: sync AI model catalog from models.dev (#19782)
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev). This PR updates pricing, context windows, and model availability based on the latest data. New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically. Deprecated models are detected based on cost-efficiency within the same model family. **Please review before merging** — verify no critical models were incorrectly deprecated. Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com> |
||
|
|
75235f4621 |
Validate universalIdentifier uniqueness among application and its dependencies (#19767)
# Introduction Gracefully validating that when creating an entity its `universalIdentifier` is available within the all application metadata maps context ( current app + twenty standard, currently the only managed dependencies ) |
||
|
|
bf410ae438 |
upgrade:status command (#19584)
## Introduction Introducing a new command in order to determine the curent twenty instance and workspaces status as it's not stored in database but a derivation of each current curors ## `upgrade:status` all healthy <img width="1376" height="1202" alt="image" src="https://github.com/user-attachments/assets/e90d6987-07d2-4b6b-b573-105249aca325" /> ## `upgrade:status` Nearly use cases <img width="1442" height="1304" alt="image" src="https://github.com/user-attachments/assets/c336cb9d-eb9d-4c7d-9392-ec1ef54a7326" /> ## `upgrade:status --failed-only` <img width="1442" height="940" alt="image" src="https://github.com/user-attachments/assets/93a3dfdb-0d2f-4a01-b185-118e5cf0a078" /> ## `upgrade:status -w aa8fdcb1-8ee1-4012-98af-44a97caa7411 -w 20202020-1c25-4d02-bf25-6aeccf7ea419 -w 20202020-1c25-4d02-bf25-6aeccf7ea412` <img width="1486" height="928" alt="image" src="https://github.com/user-attachments/assets/ec1b1abc-46e8-4e36-9799-ab3a4b85e410" /> |
||
|
|
aecbc89a3f |
Fix slow db query issue (#19770)
https://github.com/twentyhq/twenty/pull/19586#discussion_r3074136617 |
||
|
|
446a3923f2 |
Add workspace id in job logs (#19764)
Cannot currently investigate spikes |
||
|
|
4f4f723ed0 |
Fix MCP discovery: path-aware well-known URL and protocol version (#19766)
## Summary Adding `https://api.twenty.com/mcp` as an MCP server in Claude fails with `Couldn't reach the MCP server` before OAuth can start. Two independent bugs cause this: 1. **Missing path-aware well-known route.** The latest MCP spec instructs clients to probe `/.well-known/oauth-protected-resource/mcp` before `/.well-known/oauth-protected-resource`. Only the root path was registered, so the path-aware request fell through to `ServeStaticModule` and returned the SPA's `index.html` with HTTP 200. Strict clients (Claude.ai) tried to parse it as JSON and gave up. Fixed by registering both paths on the same handler. 2. **Stale protocol version.** Server advertised `2024-11-05`, which predates Streamable HTTP. We've implemented Streamable HTTP (SSE response format was added in #19528), so bumped to `2025-06-18`. Reproduction before the fix: ``` $ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" https://api.twenty.com/.well-known/oauth-protected-resource/mcp 200 text/html; charset=UTF-8 ``` After the fix this returns `application/json` with the RFC 9728 metadata document. Note: this is separate from #19755 (host-aware resource URL for multi-host deployments). ## Test plan - [x] `npx jest oauth-discovery.controller` — 2/2 tests pass, including one asserting both routes are registered - [x] `npx nx lint:diff-with-main twenty-server` passes - [ ] After deploy, `curl https://api.twenty.com/.well-known/oauth-protected-resource/mcp` returns JSON (not HTML) - [ ] Adding `https://api.twenty.com/mcp` in Claude reaches the OAuth authorization screen 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
4103efcb84 |
fix: replace slow deep-equal with fastDeepEqual to resolve CPU bottleneck (#19771)
## Summary - Replaced the `deep-equal` npm package with the existing `fastDeepEqual` from `twenty-shared/utils` across 5 files in the server and shared packages - `deep-equal` was causing severe CPU overhead in the record update hot path (`executeMany` → `formatTwentyOrmEventToDatabaseBatchEvent` → `objectRecordChangedValues` → `deepEqual`, called **per field per record**) - `fastDeepEqual` is ~100x faster for plain JSON database records since it skips unnecessary prototype chain inspection and edge-case handling - Removed the now-unnecessary `LARGE_JSON_FIELDS` branching in `objectRecordChangedValues` since all fields now use the fast implementation |
||
|
|
d3df58046c |
chore(server): drop api-host branch in OAuth discovery (#19768)
## Summary Follow-up to #19755. Simplifies `OAuthDiscoveryController` by dropping the `authorization_endpoint → frontend base URL` branch that was there to make `api.twenty.com/mcp` paste-able in MCP clients. We've decided not to support pasting `api.twenty.com/mcp` — users can paste `app.twenty.com/mcp`, `<workspace>.twenty.com/mcp`, or a custom domain, all of which serve both frontend and API. On those hosts, `authorization_endpoint` was already pointed at the same host as `issuer`, which is what we want. ## Change - Remove `isApiHost` helper and the `authorizeBase` branch — use `issuer` for `authorization_endpoint`. - Drop now-unused `TwentyConfigService` and `DomainServerConfigService` injections. - Drop duplicate `DomainServerConfigModule` import from `application-oauth.module.ts` (the module is no longer needed). Net diff: +1 / -22 across 2 files. ## Breaking change MCP clients configured with `https://api.twenty.com/mcp` will stop working. They should be reconfigured with the host matching the workspace they're connecting to (`<workspace>.twenty.com/mcp`, `app.twenty.com/mcp`, or a custom domain). ## Test plan - [x] `yarn jest --testPathPatterns="mcp-auth.guard"` → 2/2 passing (unchanged) - [x] `tsc --noEmit` clean on modified files - [ ] Manual verification on staging: `app.twenty.com/mcp` and `<workspace>.twenty.com/mcp` OAuth flow still works end-to-end Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
cb6953abe3 |
fix(server): make OAuth discovery and MCP auth metadata host-aware (#19755)
## Summary OAuth discovery metadata (RFC 9728 protected-resource, RFC 8414 authorization-server) and the MCP `WWW-Authenticate` header were hardcoded to `SERVER_URL`. This breaks MCP clients that paste any URL other than `api.twenty.com/mcp` — the metadata declares `resource: https://api.twenty.com/mcp`, which doesn't match the URL the client connected to, so the client rejects it and the OAuth flow never starts. Reproduced with Claude's MCP integration: pasting `<workspace>.twenty.com/mcp`, `app.twenty.com/mcp`, or a custom domain returned *"Couldn't reach the MCP server"* because discovery returned a resource URL for a different host. Related memory: MCP clients POST to the URL the user entered, not the discovered resource URL — so every paste-able hostname has to advertise `resource` for that same hostname. ## What the server now does `WorkspaceDomainsService.getValidatedRequestBaseUrl(req)` resolves the canonical base URL for the host the request came in on, validated against the set of hosts we actually serve: - `SERVER_URL` (e.g. `api.twenty.com`) — API host - default base URL (e.g. `app.twenty.com`) — the `DEFAULT_SUBDOMAIN` base - `FRONTEND_URL` bare host - any `<workspace>.twenty.com` subdomain (DB lookup) - any workspace `customDomain` where `isCustomDomainEnabled = true` - any registered `publicDomain` An unrecognized / spoofed Host falls back to `DomainServerConfigService.getBaseUrl()`. **We never reflect arbitrary Host values into the response.** Callers updated: - `OAuthDiscoveryController.getProtectedResourceMetadata` — echoes the validated host into `resource` and `authorization_servers`. - `OAuthDiscoveryController.getAuthorizationServerMetadata` — uses the validated host for `issuer` and `*_endpoint`, **except** `authorization_endpoint`: when the request came in via `SERVER_URL` (API-only, no `/authorize` route), we keep that one pointed at the default frontend base URL. - `McpAuthGuard` — sets `WWW-Authenticate: Bearer resource_metadata=\"<validatedBase>/.well-known/oauth-protected-resource\"` on 401s, so the MCP client's follow-up discovery fetch lands on the same host it started on. ## Security - Workspace identity is already bound to the JWT via per-workspace signing secrets (`jwtWrapperService.generateAppSecret(tokenType, workspaceId)`). Host-aware discovery does not weaken that. - Custom domains are only accepted once `isCustomDomainEnabled = true` (i.e. after DNS verification), so an attacker can't register a custom-domain mapping on a workspace and have discovery reflect it before it's been proven. - Unknown / spoofed Hosts fall through to the default base URL. ## Drive-by Fixed a duplicate `DomainServerConfigModule` import in `application-oauth.module.ts` while adding `WorkspaceDomainsModule`. ## Companion infra change required for custom domains Customer custom domains (`crm.acme.com/mcp`) also require an ingress-level fix to exclude `/mcp`, `/oauth`, and `/.well-known` from the `/s\$uri` rewrite applied when `X-Twenty-Public-Domain: true`. Shipping that in a twenty-infra PR (will cross-link here). ## Test plan - [x] 14 new tests in `WorkspaceDomainsService.getValidatedRequestBaseUrl` covering: missing Host, SERVER_URL, base URL, FRONTEND_URL, workspace subdomain, unknown subdomain fallback, enabled custom domain, disabled custom domain, public domain, completely unrecognized host, lowercase coercion, malformed Host, single-workspace mode fallback, DB throwing → fallback - [x] New `oauth-discovery.controller.spec.ts` covering both endpoints across api / app / workspace-subdomain / custom-domain hosts, plus `cli_client_id` propagation - [x] Rewrote `mcp-auth.guard.spec.ts` to cover `WWW-Authenticate` for all four host types (api, workspace subdomain, custom domain, spoofed fallback) - [x] `yarn jest --testPathPatterns=\"workspace-domains.service|oauth-discovery.controller|mcp-auth.guard\"` → 41/41 passing - [x] `tsc --noEmit` clean on all modified files - [ ] Manual verification against staging: connect Claude to `api.twenty.com/mcp`, `app.twenty.com/mcp`, `<workspace>.twenty.com/mcp`, and a custom domain and confirm OAuth flow completes on each 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> |