42f57db005a2c40bcc6d39d5f81d955993e3dd39
3321
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
2413af0ba9 |
[run-instance-commands] Preserve fast slow sequentiality (#19757)
# Introduction The command was wrongly running all fast and then all slow ignoring instance commands segment Leading to ```ts 1.23.0_AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceCommand_1776090711153 executed successfully [Nest] 32679 - 04/16/2026, 1:21:07 PM LOG [InstanceCommandRunnerService] 1.23.0_DropWorkspaceVersionColumnFastInstanceCommand_1785000000000 executed successfully [Nest] 32679 - 04/16/2026, 1:21:07 PM LOG [InstanceCommandRunnerService] 1.22.0_BackfillWorkspaceIdOnIndirectEntitiesSlowInstanceCommand_1775758621018 executed successfully [Nest] 32679 - 04/16/2026, 1:21:07 PM LOG [RunInstanceCommandsCommand] Instance commands completed ``` No prod/self host impact as 1.23 hasn't been released yet |
||
|
|
2b5b8a8b13 |
Link command menu items to specific page layout (#19706)
- Add a `pageLayoutId` foreign key to `CommandMenuItem`, allowing command menu items to be scoped to a specific page layout instead of being globally available - Filter command menu items by the current page layout on the frontend. Items with a `pageLayoutId` only appear when viewing that layout, while items without one remain globally visible - Create an effect to track the current page layout ID - Include a seed example: a "Show Notification" command pinned to the Star history standalone page layout --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
381f3ba7d9 |
Fix app design 1/2 (#19735)
comply with https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=96977-349627&m=dev ## After <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 40 37" src="https://github.com/user-attachments/assets/6d80191a-79a9-4f0f-aa4f-0e447fff4f6d" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 40 22" src="https://github.com/user-attachments/assets/4f763272-027e-4246-b455-7d46babf7d8c" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 39 11" src="https://github.com/user-attachments/assets/b9b35e18-8068-447e-821d-5ec28bb5bd16" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 39 05" src="https://github.com/user-attachments/assets/57d9318a-902f-4fd7-a2a3-5795ebe0b9dc" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 39 02" src="https://github.com/user-attachments/assets/78a33fa8-6bdd-484e-a82d-bd0f7592a623" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 38 58" src="https://github.com/user-attachments/assets/f7987aed-c6e1-4032-a611-86817655137d" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 38 55" src="https://github.com/user-attachments/assets/d1c451ab-1d2d-41e4-a059-cf4303ecabe7" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 38 48" src="https://github.com/user-attachments/assets/593cae36-2320-443f-a955-93b211a6ee3f" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 37 40" src="https://github.com/user-attachments/assets/c9f602b1-8de3-4e82-a3a6-344594a0c153" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 37 34" src="https://github.com/user-attachments/assets/b54ddddf-5dda-46c8-ace3-cffe6015825a" /> ## before <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42 18" src="https://github.com/user-attachments/assets/c0976a0a-0124-48ec-8e7c-78627cea7063" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42 16" src="https://github.com/user-attachments/assets/d2db926c-4040-411d-9091-8b60e7c519e6" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42 13" src="https://github.com/user-attachments/assets/2d69f2ff-f26e-4249-91a3-2cf3d261e840" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42 07" src="https://github.com/user-attachments/assets/1028aabc-77ac-4c51-a8c3-9a194faba87f" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42 01" src="https://github.com/user-attachments/assets/1caa9f5e-3eaa-433c-9d3b-e0f094f16e8e" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 41 56" src="https://github.com/user-attachments/assets/f42b6976-3a8f-4591-9283-bda79bdb424b" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 41 53" src="https://github.com/user-attachments/assets/93d00df8-0091-4dfa-9ac0-f6f376be5962" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 41 43" src="https://github.com/user-attachments/assets/9deae7e5-39c1-4518-a463-6d79bc5bf132" /> <img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 41 37" src="https://github.com/user-attachments/assets/3e21b521-c47d-482c-ad41-66abfe973772" /> |
||
|
|
f5e8c05267 |
Add logs before and after instance slow data migration (#19753)
As it can take sometime, would result in not seeing any logs until |
||
|
|
7adab8884b |
Add isUnique update in query + invalidate cache on rollback (#19746)
Fix isUnique not sent in field update mutation: Added isUnique and settings to the Pick type in useUpdateOneFieldMetadataItem, which previously silently dropped these properties from the GraphQL payload. Invalidate cache on failed migration rollback: Added invalidateCache() call in the migration runner's catch block so that a failed migration (e.g., unique index creation failing due to duplicate data) regenerates the Redis hash, preventing stale metadata from persisting in frontend localStorage indefinitely. Was |
||
|
|
cddc47b61f |
i18n - translations (#19731)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
446c39d1c0 |
Fix silent failures in logic function route trigger execution (#19698)
## Summary Route-triggered logic functions were returning empty 500 responses with zero server-side logging when the Lambda build chain failed. This PR makes those failures observable and returns meaningful HTTP responses to API clients. - **Observability** — Log errors (with stack traces) at each layer of the execution chain: `LambdaDriver` (deps-layer fetch, SDK-layer fetch, invocation), `LogicFunctionExecutorService`, and `RouteTriggerService`. - **Typed exceptions** — Replace raw `throw error` sites with `LogicFunctionException` carrying an appropriate code and `userFriendlyMessage` (new codes: `LOGIC_FUNCTION_EXECUTION_FAILED`, `LOGIC_FUNCTION_LAYER_BUILD_FAILED`). - **Correct HTTP semantics** — `RouteTriggerService` maps inner exception codes to the right `RouteTriggerExceptionCode` so `LOGIC_FUNCTION_NOT_FOUND` returns 404 and `RATE_LIMIT_EXCEEDED` returns 429 (new code + filter case) instead of a generic 500. - **User-facing messages** — Forward the inner `CustomException.userFriendlyMessage` when wrapping into `RouteTriggerException`, without leaking raw internal error text into the public exception message. - **Infra** — Bump Lambda ephemeral storage from 2048 to 4096 MB to prevent `ENOSPC` errors during yarn install layer builds (root cause of the original silent failures). |
||
|
|
a4cc7fb9c5 |
[Upgrade] Fix workspace creation cursor (#19701)
## Summary ### Problem The upgrade migration system required new workspaces to always start from a workspace command, which was too rigid. When the system was mid-upgrade within an instance command (IC) segment, workspace creation would fail or produce inconsistent state. ### Solution #### Workspace-scoped instance command rows Instance commands now write upgrade migration rows for **all active/suspended workspaces** alongside the global row. This means every workspace has a complete migration history, including instance command records. - `InstanceCommandRunnerService` reloads `activeOrSuspendedWorkspaceIds` immediately before writing records (both success and failure paths) to mitigate race conditions with concurrent workspace creation. - `recordUpgradeMigration` in `UpgradeMigrationService` accepts a discriminated union over `status`, handles `error: unknown` formatting internally, and writes global + workspace rows in batch. #### Flexible initial cursor for new workspaces `getInitialCursorForNewWorkspace` now accepts the last **attempted** (not just completed) instance command with its status: - If the IC is `completed` and the next step is a workspace segment → cursor is set to the last WC of that segment (existing behavior). - If the IC is `failed` or not the last of its segment → cursor is set to that IC itself, preserving its status. This allows workspaces to be created at any point during the upgrade lifecycle, including mid-IC-segment and after IC failure. #### Relaxed workspace segment validation `validateWorkspaceCursorsAreInWorkspaceSegment` accepts workspaces whose cursor is: 1. Within the current workspace segment, OR 2. At the immediately preceding instance command with `completed` status (handles the `-w` single-workspace upgrade scenario). Workspaces with cursors in a previous segment, ahead of the current segment, or at a preceding IC with `failed` status are rejected. ### Test plan created empty workspaces to allow testing upgrade with several active workspaces |
||
|
|
2fccd194f3 |
[Billing for self host] End dummy enterprise key validity (#19560)
<img width="1504" height="755" alt="Screenshot 2026-04-10 at 16 40 07" src="https://github.com/user-attachments/assets/68a12e40-a077-48df-9e18-885493520a32" /> Re-using hasValidEnterpriseKey to avoid breaking changes. This will be entirely removed in the next versions. |
||
|
|
e12b55a951 |
Fix duplicate Fields widget (#19696)
## Context Tab duplication was broken after the view creation logic was deferred and moved to the FE. - Duplicating a tab or a FIELDS widget now produces a fully independent copy: new view, new view field groups, new view fields — all with fresh IDs — while preserving any unsaved edits from the source widget. - Removed the backend auto-seed of default view fields / view field groups in ViewService.createOne for FIELDS_WIDGET views. The frontend always sends the complete layout via upsertFieldsWidget, so the auto-seed was both redundant and the source of potential bugs. - Extracted a shared useDuplicateFieldsWidgetForPageLayout hook used by both tab and widget duplication paths, plus a small useCloneViewInMetadataStore helper that clones the FlatView in the metadata store and returns the copied flat view fields/groups for the caller. |
||
|
|
77e5b06a50 |
i18n - translations (#19707)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
bc28e1557c |
Introduce updateWorkspaceMemberSettings and clarify product (#19441)
## Summary Introduces a dedicated **metadata** mutation to update **standard (non-custom)** workspace member settings, moves profile-related UI to use it, and aligns **workspace member** record permissions with the rest of the CRM so users cannot escalate visibility via RLS by editing their own member record. ## Product behaviour ### Profile and appearance (standard fields) - Users can still update **their own** standard workspace member fields that the product exposes in **Settings / Profile** (e.g. name, locale, color scheme, avatar flow) via the new **`updateWorkspaceMemberSettings`** mutation. - The mutation returns a **boolean**; the app **merges** the updated fields into local state so the UI stays in sync without refetching the full workspace member record. - **Locale** changes also keep **`userWorkspace`** in sync when a locale is present in the payload (including from the workspace `updateOne` path when applicable). ### Custom fields on workspace members - The dedicated metadata mutation **rejects** any **custom** workspace member field (and unknown keys). Those updates must go through the normal **object** `updateOne` pipeline, which is subject to **object- and field-level** permissions like other records. But since we don't have object- and field-level permission configuration for system objects yet, this permission is derived from Workspace member settings permission. - **Workspace member** is no longer exempt from ORM permission validation for updates merely because it is a **system** object. Users who **do not** have workspace member access (e.g. no **Workspace members** settings permission and no equivalent broad settings access on the role) **cannot** use `updateOne` on `workspaceMember` to change **custom** (or other) fields on their own row—even though that row is used for RLS predicates. - This closes a path where someone could widen what they can see by writing to fields that drive row-level rules. ### Who can change another member - Updating **another** user’s workspace member still requires **Workspace members** (or equivalent) settings permission, consistent with admin tooling. |
||
|
|
59a222e0f0 |
i18n - translations (#19703)
Created by Github action Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
307b6c94de |
Align GraphQL error handling for billing and AI chat (#19690)
## What changed This refactor fixes AI chat error surfacing by aligning both the backend and frontend with the existing GraphQL error architecture instead of adding AI-local error translation. On the backend: - add a dedicated GraphQL billing exception path - register billing GraphQL handling globally for GraphQL requests - reuse the existing AI GraphQL interceptor path for agent/chat exceptions - keep billing status classification shared between REST and GraphQL - remove the earlier attempt to preserve `CustomException` metadata in the global GraphQL fallback On the frontend: - keep the original Apollo GraphQL error object in AI chat state - reuse shared Apollo/GraphQL helpers for user-facing messages and error-type checks - delete AI-specific error extraction helpers that duplicated generic GraphQL parsing - replace a few direct `extensions.subCode` call sites with a shared predicate ## Why it changed The original bug was that `BillingException` and AI exceptions thrown from chat were not being translated into GraphQL errors with the expected `extensions.subCode` and `extensions.userFriendlyMessage`, so the AI chat UI had nothing structured to inspect. An intermediate fix worked mechanically but pushed `CustomException` handling into the global GraphQL fallback, which blurred the intended layering. This PR moves the behavior back to explicit GraphQL edges. ## Root cause `AgentChatResolver` could throw `BillingException` and `AgentException`, but: - billing had a REST exception filter and no shared GraphQL equivalent - AI chat was not consistently using the same GraphQL exception translation path as the sibling AI resolver - the frontend chat UI had drifted into AI-specific error parsing instead of consuming the same structured Apollo errors as the rest of the app ## Impact - `BILLING_CREDITS_EXHAUSTED` is now preserved through GraphQL and can render the existing credits-exhausted UI in chat - `API_KEY_NOT_CONFIGURED` is preserved through the AI GraphQL path - AI chat now follows the same general GraphQL error consumption pattern as the rest of the frontend - billing GraphQL handling is less dependent on individual resolver authors remembering to add a filter ## Validation - `yarn jest --config packages/twenty-server/jest.config.mjs packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/billing-graphql-api-exception-handler.util.spec.ts packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/__tests__/agent-graphql-api-exception-handler.util.spec.ts` - `yarn jest --config packages/twenty-front/jest.config.mjs packages/twenty-front/src/utils/__tests__/is-graphql-error-of-type.util.test.ts` - `npx oxlint --type-aware ...` on touched backend/frontend files - `npx prettier --check ...` on touched backend/frontend files ## Follow-up ideas - consolidate frontend GraphQL error helpers further so more existing direct `extensions.subCode` checks move to shared utilities - consider whether common GraphQL exception filter registration should live in a more explicit GraphQL-specific module instead of `CoreEngineModule` - add an end-to-end test for a real `sendChatMessage` GraphQL failure path in AI chat --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> |