Compare commits

...
Author SHA1 Message Date
sonarly-bot 17a69abdd1 fix: validate workspace package manifests during Docker build
https://sonarly.com/issue/38418?type=bug

The published Docker digest for v2.5.3/latest contains a 0-byte

Authored by Sonarly by autonomous analysis (run 43801).
2026-05-18 14:58:44 +00:00
Charles BochetandGitHub d03480472c perf(server): index messageChannel/calendarChannel for per-workspace sync crons (#20678)
## Summary

The messaging/calendar import crons each iterate every active workspace
and execute one `find` per workspace against `core."messageChannel"` /
`core."calendarChannel"` with the shape:

```
WHERE "workspaceId" = $1 AND "isSyncEnabled" = true AND "syncStage" = $2 [AND "type" <> $3]
```

There is currently no index supporting that shape, so the planner does a
seq scan on each table for every iteration. On prod-eu (RDS Performance
Insights, `rds-prod-eu-one`), these two queries are the top two by load
— together ~12 AAS, ~12 calls/sec — and have been the primary
contributor to the sustained 100% CPU since active workspace count grew.

This PR adds composite indexes on `(workspaceId, isSyncEnabled,
syncStage)` for both tables as an instance migration in 2.6.0.
2026-05-18 14:31:03 +00:00
8f3c336e62 i18n - docs translations (#20680)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-18 15:37:33 +02:00
Thomas des FrancsandGitHub e7a1448414 Add OpenAI Apps domain challenge file (#20677)
## Summary
- Add the OpenAI Apps domain verification token as a static well-known
file on the Twenty website.

## Why
The OpenAI Apps submission form allows a challenge base URL on the MCP
hostname or a parent hostname. Since the MCP hostname is
`api.twenty.com`, the parent origin `https://twenty.com` can serve the
challenge at `/.well-known/openai-apps-challenge` without adding an API
route.

## Validation
- `curl -I -L https://twenty.com/.well-known/openai-apps-challenge`
currently returns 404, confirming the file is not already live.
- `git diff --check origin/main...HEAD`
- Verified the PR diff is a single static file:
`packages/twenty-website-new/public/.well-known/openai-apps-challenge`.

## Submission setting
Use `https://twenty.com` as the Challenge Base URL after this is
deployed.
2026-05-18 12:29:53 +00:00
EtienneandGitHub fc74938d7b fix(billing) - query timeout (#20669)
Sonarly context : https://sonarly.com/issue/33412
Sentry issue :
https://twenty-v7.sentry.io/issues/7454613767/?project=4507072499810304
2026-05-18 12:29:47 +00:00
Thomas des FrancsandGitHub d5e65c563e Add MCP tool annotations (#20672)
## Summary

Adds explicit MCP tool annotations for the Twenty MCP server so ChatGPT
app submission review can inspect the exposed tools without relying on
protocol defaults.

## Changes

- Adds one-export annotation constants for closed-world read-only tools,
open-world read-only tools, and `execute_tool`.
- Attaches annotations to the five exposed MCP tools:
`search_help_center`, `get_tool_catalog`, `learn_tools`, `execute_tool`,
and `load_skills`.
- Marks `search_help_center` as read-only and open-world because it
performs outbound help-center HTTP requests.
- Keeps `get_tool_catalog`, `learn_tools`, and `load_skills` read-only
and closed-world.
- Keeps `execute_tool` non-read-only, open-world, and destructive
because it can route to tools that create/update/delete records or send
email.
- Returns annotations through `tools/list` and updates MCP tests to
cover them.

No output schemas are included in this PR.

## Validation

- `git diff --check origin/main...HEAD`
- `jest --config packages/twenty-server/jest.config.mjs
packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-tool-executor.service.spec.ts
packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts
--runInBand`

Note: the Jest command was run with arm64 Node because the available
shared `node_modules` install contains the arm64 SWC native binding.
2026-05-18 12:14:34 +00:00
5c8ddb0c12 i18n - translations (#20674)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-18 14:15:45 +02:00
db0547f503 [1/3] Rename permissionFlag to rolePermissionFlag + add permissionFlag catalog/backfill (#20481)
Split of #20377.

## Summary

This PR separates available permission flags from per-role permission
flag grants.

Previously, `core.permissionFlag` stored the role assignment directly:
`roleId + flag`. This PR renames that legacy grant table to
`core.rolePermissionFlag`, then recreates `core.permissionFlag` as the
catalog of available permission flags.

## What changed

- Rename the existing `core.permissionFlag` grant table to
`core.rolePermissionFlag`.
- Add the new syncable `core.permissionFlag` catalog entity with key,
label, description, icon, permission type, relevance flags, and
custom/standard metadata.
- Add stable `SystemPermissionFlag` universal identifiers for the
built-in `PermissionFlagType` values.
- Seed the standard permission flags for every workspace under the
Twenty standard application.
- Backfill existing role grants:
  - create missing catalog rows for existing grant keys,
  - add `rolePermissionFlag.permissionFlagId`,
- migrate grants from the old string `flag` column to the new catalog
FK,
- replace the old `(flag, roleId)` uniqueness with `(permissionFlagId,
roleId)`.
- Rewire role permission flag caches, permission checks, role DTO
mapping, and `upsertPermissionFlags` to resolve through the catalog.
- Keep the existing public role permission API shape: product/app
surfaces still talk about `permissionFlags` and return `{ id, roleId,
flag }`.
- Update metadata flat-entity machinery, migration builders, validators,
action handlers, snapshots, generated schemas, docs, and app fixtures
for the new `permissionFlag` / `rolePermissionFlag` split.

## Behavior after this PR

- Existing permission flag grants keep working.
- Existing GraphQL role permission flows keep the same public naming.
- Standard permission flags are represented as catalog rows.
- Permission checks now compare grants through catalog universal
identifiers instead of the legacy `flag` column.
- Workspace deletion cleanup now verifies both `permissionFlag` and
`rolePermissionFlag`.

## What is not in this PR

- Public GraphQL CRUD for custom permission flags.
- App manifest support for declaring new custom permission flags.
- Frontend UI for creating or assigning custom permission flags beyond
the existing role permission flow.

---------

Co-authored-by: Weiko <corentin@twenty.com>
2026-05-18 11:57:47 +00:00
01535a3b3e fix(server): handle network errors in RestApiService catch block (#20644)
## Summary
- Added safe null check for `err.response?.data?.errors` in
`RestApiService.call()` catch block
- When the internal HTTP client fails with a network-level error
(ECONNREFUSED, timeout), `err.response` is `undefined` — accessing
`.data.errors` on it throws a `TypeError` which gets silently swallowed,
returning an empty 500
- Now falls back to throwing the raw error message for network failures
instead of crashing

## Changes
- `packages/twenty-server/src/engine/api/rest/rest-api.service.ts`

Fixes #20136

---------

Co-authored-by: Marie Stoppa <marie@twenty.com>
2026-05-18 09:51:42 +00:00
Shubham SinghandGitHub 45ac3e8218 fix(front): align currency icon vertically with amount text (#20646)
## Summary
- Replaced inline `<span>` wrapping the currency icon with a Linaria
styled component using `display: flex` and `align-items: center`
- The icon was misaligned with the amount text in table views and
settings because the inline span didn't vertically center the SVG icon

## Changes
-
`packages/twenty-front/src/modules/ui/field/display/components/CurrencyDisplay.tsx`

Fixes #20640
2026-05-18 08:48:54 +00:00
cd09690d5d fix(server): correct OpenAPI schema for phones.additionalPhones (#20631)
Fixes #20629

Problem

The OpenAPI schema for PHONES composite fields documented
additionalPhones as string[], but the actual runtime type (defined in
phones.composite-type.ts) is Array<{ number: string, countryCode:
string, callingCode: string }>. This caused generated SDK types and API
docs for create/update payloads to be incorrect.

Root cause

A hardcoded mistake in
convert-object-metadata-to-schema-properties.util.ts — the
FieldMetadataType.PHONES branch set additionalPhones.items to { type:
'string' } instead of an object schema.

Changes


packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts
- Changed additionalPhones.items from { type: 'string' } to { type:
'object', properties: { number, countryCode, callingCode } }, matching
AdditionalPhoneMetadata.


packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts
- Updated all three inline snapshot occurrences (for ObjectName,
ObjectNameForResponse, ObjectNameForUpdate) to expect the correct object
shape instead of string.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 08:41:59 +00:00
282 changed files with 9098 additions and 1932 deletions
@@ -244,7 +244,7 @@ type FieldPermission {
canUpdateFieldValue: Boolean
}
type PermissionFlag {
type RolePermissionFlag {
id: UUID!
roleId: UUID!
flag: PermissionFlagType!
@@ -276,7 +276,7 @@ type Role {
canUpdateAllObjectRecords: Boolean!
canSoftDeleteAllObjectRecords: Boolean!
canDestroyAllObjectRecords: Boolean!
permissionFlags: [PermissionFlag!]
permissionFlags: [RolePermissionFlag!]
objectPermissions: [ObjectPermission!]
fieldPermissions: [FieldPermission!]
rowLevelPermissionPredicates: [RowLevelPermissionPredicate!]
@@ -2884,6 +2884,7 @@ enum AllMetadataName {
pageLayoutTab
commandMenuItem
navigationMenuItem
rolePermissionFlag
permissionFlag
objectPermission
fieldPermission
@@ -3237,7 +3238,7 @@ type Mutation {
updateOneRole(updateRoleInput: UpdateRoleInput!): Role!
deleteOneRole(roleId: UUID!): String!
upsertObjectPermissions(upsertObjectPermissionsInput: UpsertObjectPermissionsInput!): [ObjectPermission!]!
upsertPermissionFlags(upsertPermissionFlagsInput: UpsertPermissionFlagsInput!): [PermissionFlag!]!
upsertPermissionFlags(upsertPermissionFlagsInput: UpsertPermissionFlagsInput!): [RolePermissionFlag!]!
upsertFieldPermissions(upsertFieldPermissionsInput: UpsertFieldPermissionsInput!): [FieldPermission!]!
upsertRowLevelPermissionPredicates(input: UpsertRowLevelPermissionPredicatesInput!): UpsertRowLevelPermissionPredicatesResult!
assignRoleToAgent(agentId: UUID!, roleId: UUID!): Boolean!
@@ -190,11 +190,11 @@ export interface FieldPermission {
__typename: 'FieldPermission'
}
export interface PermissionFlag {
export interface RolePermissionFlag {
id: Scalars['UUID']
roleId: Scalars['UUID']
flag: PermissionFlagType
__typename: 'PermissionFlag'
__typename: 'RolePermissionFlag'
}
export interface ApiKeyForRole {
@@ -224,7 +224,7 @@ export interface Role {
canUpdateAllObjectRecords: Scalars['Boolean']
canSoftDeleteAllObjectRecords: Scalars['Boolean']
canDestroyAllObjectRecords: Scalars['Boolean']
permissionFlags?: PermissionFlag[]
permissionFlags?: RolePermissionFlag[]
objectPermissions?: ObjectPermission[]
fieldPermissions?: FieldPermission[]
rowLevelPermissionPredicates?: RowLevelPermissionPredicate[]
@@ -2516,7 +2516,7 @@ export interface CollectionHash {
__typename: 'CollectionHash'
}
export type AllMetadataName = 'fieldMetadata' | 'objectMetadata' | 'view' | 'viewField' | 'viewFieldGroup' | 'viewGroup' | 'viewSort' | 'rowLevelPermissionPredicate' | 'rowLevelPermissionPredicateGroup' | 'viewFilterGroup' | 'index' | 'logicFunction' | 'viewFilter' | 'role' | 'roleTarget' | 'agent' | 'skill' | 'pageLayout' | 'pageLayoutWidget' | 'pageLayoutTab' | 'commandMenuItem' | 'navigationMenuItem' | 'permissionFlag' | 'objectPermission' | 'fieldPermission' | 'frontComponent' | 'webhook' | 'applicationVariable' | 'connectionProvider'
export type AllMetadataName = 'fieldMetadata' | 'objectMetadata' | 'view' | 'viewField' | 'viewFieldGroup' | 'viewGroup' | 'viewSort' | 'rowLevelPermissionPredicate' | 'rowLevelPermissionPredicateGroup' | 'viewFilterGroup' | 'index' | 'logicFunction' | 'viewFilter' | 'role' | 'roleTarget' | 'agent' | 'skill' | 'pageLayout' | 'pageLayoutWidget' | 'pageLayoutTab' | 'commandMenuItem' | 'navigationMenuItem' | 'rolePermissionFlag' | 'permissionFlag' | 'objectPermission' | 'fieldPermission' | 'frontComponent' | 'webhook' | 'applicationVariable' | 'connectionProvider'
export interface MinimalObjectMetadata {
id: Scalars['UUID']
@@ -2772,7 +2772,7 @@ export interface Mutation {
updateOneRole: Role
deleteOneRole: Scalars['String']
upsertObjectPermissions: ObjectPermission[]
upsertPermissionFlags: PermissionFlag[]
upsertPermissionFlags: RolePermissionFlag[]
upsertFieldPermissions: FieldPermission[]
upsertRowLevelPermissionPredicates: UpsertRowLevelPermissionPredicatesResult
assignRoleToAgent: Scalars['Boolean']
@@ -3072,7 +3072,7 @@ export interface FieldPermissionGenqlSelection{
__scalar?: boolean | number
}
export interface PermissionFlagGenqlSelection{
export interface RolePermissionFlagGenqlSelection{
id?: boolean | number
roleId?: boolean | number
flag?: boolean | number
@@ -3108,7 +3108,7 @@ export interface RoleGenqlSelection{
canUpdateAllObjectRecords?: boolean | number
canSoftDeleteAllObjectRecords?: boolean | number
canDestroyAllObjectRecords?: boolean | number
permissionFlags?: PermissionFlagGenqlSelection
permissionFlags?: RolePermissionFlagGenqlSelection
objectPermissions?: ObjectPermissionGenqlSelection
fieldPermissions?: FieldPermissionGenqlSelection
rowLevelPermissionPredicates?: RowLevelPermissionPredicateGenqlSelection
@@ -5847,7 +5847,7 @@ export interface MutationGenqlSelection{
updateOneRole?: (RoleGenqlSelection & { __args: {updateRoleInput: UpdateRoleInput} })
deleteOneRole?: { __args: {roleId: Scalars['UUID']} }
upsertObjectPermissions?: (ObjectPermissionGenqlSelection & { __args: {upsertObjectPermissionsInput: UpsertObjectPermissionsInput} })
upsertPermissionFlags?: (PermissionFlagGenqlSelection & { __args: {upsertPermissionFlagsInput: UpsertPermissionFlagsInput} })
upsertPermissionFlags?: (RolePermissionFlagGenqlSelection & { __args: {upsertPermissionFlagsInput: UpsertPermissionFlagsInput} })
upsertFieldPermissions?: (FieldPermissionGenqlSelection & { __args: {upsertFieldPermissionsInput: UpsertFieldPermissionsInput} })
upsertRowLevelPermissionPredicates?: (UpsertRowLevelPermissionPredicatesResultGenqlSelection & { __args: {input: UpsertRowLevelPermissionPredicatesInput} })
assignRoleToAgent?: { __args: {agentId: Scalars['UUID'], roleId: Scalars['UUID']} }
@@ -6426,10 +6426,10 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const PermissionFlag_possibleTypes: string[] = ['PermissionFlag']
export const isPermissionFlag = (obj?: { __typename?: any } | null): obj is PermissionFlag => {
if (!obj?.__typename) throw new Error('__typename is missing in "isPermissionFlag"')
return PermissionFlag_possibleTypes.includes(obj.__typename)
const RolePermissionFlag_possibleTypes: string[] = ['RolePermissionFlag']
export const isRolePermissionFlag = (obj?: { __typename?: any } | null): obj is RolePermissionFlag => {
if (!obj?.__typename) throw new Error('__typename is missing in "isRolePermissionFlag"')
return RolePermissionFlag_possibleTypes.includes(obj.__typename)
}
@@ -8899,6 +8899,7 @@ export const enumAllMetadataName = {
pageLayoutTab: 'pageLayoutTab' as const,
commandMenuItem: 'commandMenuItem' as const,
navigationMenuItem: 'navigationMenuItem' as const,
rolePermissionFlag: 'rolePermissionFlag' as const,
permissionFlag: 'permissionFlag' as const,
objectPermission: 'objectPermission' as const,
fieldPermission: 'fieldPermission' as const,
@@ -500,7 +500,7 @@ export default {
1
]
},
"PermissionFlag": {
"RolePermissionFlag": {
"id": [
3
],
+2
View File
@@ -51,6 +51,8 @@ RUN npx nx run twenty-server:lingui:extract && \
RUN npx nx run twenty-server:build
RUN node -e "const fs = require('fs'); const manifests = ['/app/packages/twenty-shared/package.json', '/app/packages/twenty-server/package.json', '/app/packages/twenty-emails/package.json', '/app/packages/twenty-client-sdk/package.json']; for (const manifestPath of manifests) { if (!fs.existsSync(manifestPath)) { throw new Error(`Missing workspace manifest: ${manifestPath}`); } const content = fs.readFileSync(manifestPath, 'utf8'); if (content.trim().length === 0) { throw new Error(`Empty workspace manifest: ${manifestPath}`); } JSON.parse(content); } console.log('Workspace manifests are valid.');"
# Clean server build output (type declarations and compiled tests are not needed at runtime;
# source maps are kept because twenty-infra extracts them from the image for Sentry uploads)
RUN find /app/packages/twenty-server/dist -name '*.d.ts' -delete \
@@ -1,6 +1,27 @@
#!/bin/sh
set -e
validate_workspace_manifest() {
manifest_path="$1"
if [ ! -s "${manifest_path}" ]; then
echo "ERROR: Invalid runtime workspace manifest (missing or empty): ${manifest_path}" >&2
return 1
fi
if ! node -e "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'))" "${manifest_path}" >/dev/null 2>&1; then
echo "ERROR: Invalid runtime workspace manifest (malformed JSON): ${manifest_path}" >&2
return 1
fi
}
validate_workspace_manifests() {
validate_workspace_manifest "/app/packages/twenty-shared/package.json" || return 1
validate_workspace_manifest "/app/packages/twenty-server/package.json" || return 1
validate_workspace_manifest "/app/packages/twenty-emails/package.json" || return 1
validate_workspace_manifest "/app/packages/twenty-client-sdk/package.json" || return 1
}
setup_and_migrate_db() {
if [ "${DISABLE_DB_MIGRATIONS}" = "true" ]; then
echo "Database setup and migrations are disabled, skipping..."
@@ -45,6 +66,11 @@ register_background_jobs() {
fi
}
if ! validate_workspace_manifests; then
echo "ERROR: Aborting startup because runtime workspace manifests are invalid." >&2
exit 1
fi
setup_and_migrate_db
register_background_jobs
@@ -1,7 +1,7 @@
---
title: Roles & Permissions
description: Declare what objects and fields your app's logic functions and front components can read and write.
icon: "shield-halved"
icon: 'shield-halved'
---
A **role** is a permission set: which objects an app can read or write, which fields it can see, and which platform-level capabilities it can use. Every app's logic functions and front components inherit the permissions of the role marked with `defineApplicationRole()` (see [The default function role](#the-default-function-role) below).
@@ -40,7 +40,8 @@ export default defineRole({
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name
.universalIdentifier,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
@@ -80,6 +81,7 @@ export default defineApplicationRole({
`defineApplicationRole()` is a thin wrapper around `defineRole()` that flags **the** role used as your application's default at install time. Validation is identical to `defineRole`, but the build pipeline auto-wires its `universalIdentifier` into the application manifest's `defaultRoleUniversalIdentifier` — so you do not need to reference it from [`defineApplication`](/developers/extend/apps/config/application) yourself.
Notes:
- Exactly **one** `defineApplicationRole(...)` is allowed per app — the manifest build will fail if it finds more than one.
- Use `defineRole()` (not `defineApplicationRole()`) for any **additional** roles your app ships.
- Setting `defaultRoleUniversalIdentifier` explicitly on `defineApplication()` is still supported for backward compatibility, but is deprecated in favor of `defineApplicationRole()`.
@@ -40,7 +40,8 @@ export default defineRole({
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name
.universalIdentifier,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
@@ -40,7 +40,8 @@ export default defineRole({
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name
.universalIdentifier,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
@@ -40,7 +40,8 @@ export default defineRole({
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name
.universalIdentifier,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
@@ -40,7 +40,8 @@ export default defineRole({
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name
.universalIdentifier,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
@@ -40,7 +40,8 @@ export default defineRole({
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name
.universalIdentifier,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
@@ -40,7 +40,8 @@ export default defineRole({
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name
.universalIdentifier,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
File diff suppressed because one or more lines are too long
@@ -13178,6 +13178,11 @@ msgstr "Rolnaam"
msgid "Role name cannot be empty"
msgstr "Rolnaam kan nie leeg wees nie"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "اسم الدور"
msgid "Role name cannot be empty"
msgstr "لا يمكن أن يكون اسم الدور فارغًا"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Nom del rol"
msgid "Role name cannot be empty"
msgstr "El nom del rol no pot estar buit"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Název role"
msgid "Role name cannot be empty"
msgstr "Název role nesmí být prázdný"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Rollenavn"
msgid "Role name cannot be empty"
msgstr "Rollens navn kan ikke være tomt"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Rollenname"
msgid "Role name cannot be empty"
msgstr "Rollenname darf nicht leer sein"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Όνομα ρόλου"
msgid "Role name cannot be empty"
msgstr "Το όνομα του ρόλου δεν μπορεί να είναι κενό"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
+5
View File
@@ -13196,6 +13196,11 @@ msgstr "Role name"
msgid "Role name cannot be empty"
msgstr "Role name cannot be empty"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr "role permission flag"
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Nombre del rol"
msgid "Role name cannot be empty"
msgstr "El nombre del rol no puede estar vacío"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Roolin nimi"
msgid "Role name cannot be empty"
msgstr "Roolinimi ei saa olla tyhjä"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Nom du rôle"
msgid "Role name cannot be empty"
msgstr "Le nom du rôle ne peut pas être vide"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -13178,6 +13178,11 @@ msgstr "שם התפקיד"
msgid "Role name cannot be empty"
msgstr "שם תפקיד לא יכול להיות ריק"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Szerepkör neve"
msgid "Role name cannot be empty"
msgstr "A szerep neve nem lehet üres"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Nome del ruolo"
msgid "Role name cannot be empty"
msgstr "Il nome del ruolo non può essere vuoto"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "役割名"
msgid "Role name cannot be empty"
msgstr "役割名を空にすることはできません"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "역할 이름"
msgid "Role name cannot be empty"
msgstr "역할 이름은 비워 둘 수 없습니다"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Rolnaam"
msgid "Role name cannot be empty"
msgstr "Rolnaam mag niet leeg zijn"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Rollenavn"
msgid "Role name cannot be empty"
msgstr "Rollens navn kan ikke være tomt"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Nazwa roli"
msgid "Role name cannot be empty"
msgstr "Nazwa roli nie może być pusta"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13173,6 +13173,11 @@ msgstr ""
msgid "Role name cannot be empty"
msgstr ""
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Nome do cargo"
msgid "Role name cannot be empty"
msgstr "O nome da função não pode estar vazio"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Nome do papel"
msgid "Role name cannot be empty"
msgstr "O nome do papel não pode estar vazio"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Numele rolului"
msgid "Role name cannot be empty"
msgstr "Numele rolului nu poate fi gol"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
Binary file not shown.
@@ -13178,6 +13178,11 @@ msgstr "Назив улоге"
msgid "Role name cannot be empty"
msgstr "Назив улоге не може бити празан"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13180,6 +13180,11 @@ msgstr "Rollnamn"
msgid "Role name cannot be empty"
msgstr "Rollnamn får inte vara tomt"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Rol adı"
msgid "Role name cannot be empty"
msgstr "Rol adı boş olamaz"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Назва ролі"
msgid "Role name cannot be empty"
msgstr "Назва ролі не може бути порожньою"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "Tên vai trò"
msgid "Role name cannot be empty"
msgstr "Tên vai trò không thể để trống"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "角色名称"
msgid "Role name cannot be empty"
msgstr "角色名称不能为空"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -13178,6 +13178,11 @@ msgstr "角色名稱"
msgid "Role name cannot be empty"
msgstr "角色名稱不能為空"
#. js-lingui-id: VMX1Oy
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role permission flag"
msgstr ""
#. js-lingui-id: jLNGne
#: src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
msgid "role target"
@@ -30,6 +30,7 @@ export const useMetadataErrorHandler = () => {
viewFilter: t`view filter`,
index: t`index`,
logicFunction: t`logic function`,
rolePermissionFlag: t`role permission flag`,
permissionFlag: t`permission flag`,
objectPermission: t`object permission`,
fieldPermission: t`field permission`,
@@ -1,9 +0,0 @@
import { gql } from '@apollo/client';
export const PERMISSION_FLAG_FRAGMENT = gql`
fragment PermissionFlagFragment on PermissionFlag {
id
flag
roleId
}
`;
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const ROLE_PERMISSION_FLAG_FRAGMENT = gql`
fragment RolePermissionFlagFragment on RolePermissionFlag {
id
flag
roleId
}
`;
@@ -1,15 +1,15 @@
import { PERMISSION_FLAG_FRAGMENT } from '@/settings/roles/graphql/fragments/permissionFlagFragment';
import { ROLE_PERMISSION_FLAG_FRAGMENT } from '@/settings/roles/graphql/fragments/rolePermissionFlagFragment';
import { gql } from '@apollo/client';
export const UPSERT_PERMISSION_FLAGS = gql`
${PERMISSION_FLAG_FRAGMENT}
${ROLE_PERMISSION_FLAG_FRAGMENT}
mutation UpsertPermissionFlags(
$upsertPermissionFlagsInput: UpsertPermissionFlagsInput!
) {
upsertPermissionFlags(
upsertPermissionFlagsInput: $upsertPermissionFlagsInput
) {
...PermissionFlagFragment
...RolePermissionFlagFragment
}
}
`;
@@ -2,7 +2,7 @@ import { AGENT_FRAGMENT } from '@/ai/graphql/fragments/agentFragment';
import { API_KEY_FOR_ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/apiKeyForRoleFragment';
import { FIELD_PERMISSION_FRAGMENT } from '@/settings/roles/graphql/fragments/fieldPermissionFragment';
import { OBJECT_PERMISSION_FRAGMENT } from '@/settings/roles/graphql/fragments/objectPermissionFragment';
import { PERMISSION_FLAG_FRAGMENT } from '@/settings/roles/graphql/fragments/permissionFlagFragment';
import { ROLE_PERMISSION_FLAG_FRAGMENT } from '@/settings/roles/graphql/fragments/rolePermissionFlagFragment';
import { ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/roleFragment';
import { ROW_LEVEL_PERMISSION_PREDICATE_FRAGMENT } from '@/settings/roles/graphql/fragments/rowLevelPermissionPredicateFragment';
import { ROW_LEVEL_PERMISSION_PREDICATE_GROUP_FRAGMENT } from '@/settings/roles/graphql/fragments/rowLevelPermissionPredicateGroupFragment';
@@ -14,7 +14,7 @@ export const GET_ROLES = gql`
${ROLE_FRAGMENT}
${AGENT_FRAGMENT}
${API_KEY_FOR_ROLE_FRAGMENT}
${PERMISSION_FLAG_FRAGMENT}
${ROLE_PERMISSION_FLAG_FRAGMENT}
${OBJECT_PERMISSION_FRAGMENT}
${FIELD_PERMISSION_FRAGMENT}
${ROW_LEVEL_PERMISSION_PREDICATE_FRAGMENT}
@@ -32,7 +32,7 @@ export const GET_ROLES = gql`
...ApiKeyForRoleFragment
}
permissionFlags {
...PermissionFlagFragment
...RolePermissionFlagFragment
}
objectPermissions {
...ObjectPermissionFragment
@@ -6,16 +6,18 @@ import {
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useHasPermissionFlag = (permissionFlag?: PermissionFlagType) => {
export const useHasPermissionFlag = (
permissionFlagKey?: PermissionFlagType,
) => {
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const currentUserWorkspace = useAtomStateValue(currentUserWorkspaceState);
if (!permissionFlag) {
if (!permissionFlagKey) {
return true;
}
if (
permissionFlag === PermissionFlagType.WORKSPACE &&
permissionFlagKey === PermissionFlagType.WORKSPACE &&
currentWorkspace?.activationStatus ===
WorkspaceActivationStatus.PENDING_CREATION
) {
@@ -23,5 +25,5 @@ export const useHasPermissionFlag = (permissionFlag?: PermissionFlagType) => {
}
const userFlags = currentUserWorkspace?.permissionFlags ?? [];
return userFlags.includes(permissionFlag);
return userFlags.includes(permissionFlagKey);
};
@@ -1,6 +1,9 @@
import { useContext, useId, useState } from 'react';
import { createPortal } from 'react-dom';
import { styled } from '@linaria/react';
import { AppTooltip, TooltipDelay, TooltipPosition } from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme-constants';
import { isDefined, formatToShortNumber } from 'twenty-shared/utils';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
@@ -10,10 +13,13 @@ import {
} from '@/object-record/record-field/ui/types/FieldMetadata';
import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes';
import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
import { isDefined, formatToShortNumber } from 'twenty-shared/utils';
import { DEFAULT_DECIMAL_VALUE } from '~/utils/format/formatNumber';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { ThemeContext } from 'twenty-ui/theme-constants';
const StyledCurrencyIconContainer = styled.span`
align-items: center;
display: flex;
`;
type CurrencyDisplayProps = {
currencyValue: FieldCurrencyValue | null | undefined;
@@ -57,7 +63,7 @@ export const CurrencyDisplay = ({
<EllipsisDisplay>
{shouldShowCurrencyTooltip && (
<>
<span
<StyledCurrencyIconContainer
id={tooltipAnchorId}
onMouseEnter={() => setShouldRenderTooltip(true)}
onMouseLeave={() => setShouldRenderTooltip(false)}
@@ -67,7 +73,7 @@ export const CurrencyDisplay = ({
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</span>{' '}
</StyledCurrencyIconContainer>{' '}
</>
)}
{amountToDisplay !== null
@@ -92,11 +92,6 @@ export const SettingsApplicationDetails = () => {
const displayName =
app?.displayName ?? application?.name ?? t`Application details`;
const description = app?.description ?? resolvedDescription;
const logoUrl =
app?.logoUrl ??
application?.logo ??
application?.applicationRegistration?.logoUrl ??
undefined;
const getScreenshots = () => {
if (app?.screenshots?.length) return app.screenshots;
@@ -123,7 +123,7 @@ const buildSyntheticRole = (
})),
permissionFlags: (defaultRole.permissionFlags ?? []).map(
(permissionFlag) => ({
__typename: 'PermissionFlag' as const,
__typename: 'RolePermissionFlag' as const,
id: uuidv4(),
roleId: defaultRole.universalIdentifier,
flag: permissionFlag.flag,
@@ -32,6 +32,7 @@ import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permi
import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity';
import { PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { RolePermissionFlagEntity } from 'src/engine/metadata-modules/role-permission-flag/role-permission-flag.entity';
import { PermissionFlagEntity } from 'src/engine/metadata-modules/permission-flag/permission-flag.entity';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@@ -66,7 +67,7 @@ const WORKSPACE_RELATED_ENTITIES: EntityTarget<ObjectLiteral>[] = [
ViewFilterGroupEntity,
FieldPermissionEntity,
ObjectPermissionEntity,
PermissionFlagEntity,
RolePermissionFlagEntity,
RoleTargetEntity,
SearchFieldMetadataEntity,
RowLevelPermissionPredicateEntity,
@@ -81,6 +82,7 @@ const WORKSPACE_RELATED_ENTITIES: EntityTarget<ObjectLiteral>[] = [
// Level 2: Children that depend on core entities
FieldMetadataEntity,
PageLayoutEntity,
PermissionFlagEntity,
SkillEntity,
LogicFunctionEntity,
@@ -0,0 +1,102 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.6.0', 1778235340020)
export class RenamePermissionFlagToRolePermissionFlagFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
// The legacy permissionFlag table stores per-role grants (`roleId` + `flag`).
// Rename it to preserve those rows before creating the new permissionFlag catalog table.
await queryRunner.query(
`ALTER TABLE "core"."permissionFlag" RENAME TO "rolePermissionFlag"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" RENAME CONSTRAINT "PK_a02789db60620a1e9f90147b50f" TO "PK_76591adc8035c2e7b0cd6115136"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" RENAME CONSTRAINT "IDX_PERMISSION_FLAG_FLAG_ROLE_ID_UNIQUE" TO "IDX_ROLE_PERMISSION_FLAG_FLAG_ROLE_ID_UNIQUE"`,
);
await queryRunner.query(
`ALTER INDEX "core"."IDX_PERMISSION_FLAG_ROLE_ID" RENAME TO "IDX_ROLE_PERMISSION_FLAG_ROLE_ID"`,
);
// Re-hash inherited constraints/indexes so TypeORM's schema diff matches
// the renamed table. Original names were derived from "permissionFlag"
// and stay free for the new catalog table created by the next migration.
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" DROP CONSTRAINT "FK_13f8ca9c517976733a1ce4c10eb"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" DROP CONSTRAINT "FK_835bc9f7ef959debfc5cd268049"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" DROP CONSTRAINT "FK_b26a9d39a88d0e72373c677c6c5"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_da8ffd3c24b4a819430a861067"`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_e4559ae0dba56e53714137c704" ON "core"."rolePermissionFlag" ("workspaceId", "universalIdentifier")`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" ADD CONSTRAINT "FK_d47b1ebee75d98daa0c870c26e3" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" ADD CONSTRAINT "FK_3835ecc1019327566d35728c8ba" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" ADD CONSTRAINT "FK_4c6ea38698de230b0ec18fa2110" FOREIGN KEY ("roleId") REFERENCES "core"."role"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" DROP CONSTRAINT "FK_4c6ea38698de230b0ec18fa2110"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" DROP CONSTRAINT "FK_3835ecc1019327566d35728c8ba"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" DROP CONSTRAINT "FK_d47b1ebee75d98daa0c870c26e3"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_e4559ae0dba56e53714137c704"`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_da8ffd3c24b4a819430a861067" ON "core"."rolePermissionFlag" ("workspaceId", "universalIdentifier")`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" ADD CONSTRAINT "FK_b26a9d39a88d0e72373c677c6c5" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" ADD CONSTRAINT "FK_835bc9f7ef959debfc5cd268049" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" ADD CONSTRAINT "FK_13f8ca9c517976733a1ce4c10eb" FOREIGN KEY ("roleId") REFERENCES "core"."role"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER INDEX "core"."IDX_ROLE_PERMISSION_FLAG_ROLE_ID" RENAME TO "IDX_PERMISSION_FLAG_ROLE_ID"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" RENAME CONSTRAINT "IDX_ROLE_PERMISSION_FLAG_FLAG_ROLE_ID_UNIQUE" TO "IDX_PERMISSION_FLAG_FLAG_ROLE_ID_UNIQUE"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" RENAME CONSTRAINT "PK_76591adc8035c2e7b0cd6115136" TO "PK_a02789db60620a1e9f90147b50f"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" RENAME TO "permissionFlag"`,
);
}
}
@@ -0,0 +1,69 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.6.0', 1778235340021)
export class PermissionFlagSyncableEntityFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."permissionFlag" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"workspaceId" uuid NOT NULL,
"applicationId" uuid NOT NULL,
"universalIdentifier" uuid NOT NULL,
"key" varchar NOT NULL,
"label" varchar NOT NULL,
"description" text,
"icon" varchar,
"permissionType" varchar NOT NULL,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "IDX_PERMISSION_FLAG_KEY_WORKSPACE_ID_UNIQUE" UNIQUE ("key", "workspaceId"),
CONSTRAINT "PK_a02789db60620a1e9f90147b50f" PRIMARY KEY ("id")
)`,
);
await queryRunner.query(
`CREATE INDEX "IDX_PERMISSION_FLAG_APPLICATION_ID" ON "core"."permissionFlag" ("applicationId")`,
);
// Reuses canonical hash names freed by the rename migration so TypeORM's
// schema diff matches without further intervention.
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_da8ffd3c24b4a819430a861067" ON "core"."permissionFlag" ("workspaceId", "universalIdentifier")`,
);
await queryRunner.query(
`ALTER TABLE "core"."permissionFlag"
ADD CONSTRAINT "FK_835bc9f7ef959debfc5cd268049"
FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."permissionFlag"
ADD CONSTRAINT "FK_b26a9d39a88d0e72373c677c6c5"
FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."permissionFlag" DROP CONSTRAINT IF EXISTS "FK_b26a9d39a88d0e72373c677c6c5"`,
);
await queryRunner.query(
`ALTER TABLE "core"."permissionFlag" DROP CONSTRAINT IF EXISTS "FK_835bc9f7ef959debfc5cd268049"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "core"."IDX_da8ffd3c24b4a819430a861067"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "core"."IDX_PERMISSION_FLAG_APPLICATION_ID"`,
);
await queryRunner.query(`DROP TABLE IF EXISTS "core"."permissionFlag"`);
}
}
@@ -0,0 +1,55 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.6.0', 1778235340022)
export class LinkRolePermissionFlagToPermissionFlagFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
ADD COLUMN IF NOT EXISTS "permissionFlagId" uuid`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
ADD CONSTRAINT "IDX_ROLE_PERMISSION_FLAG_PERMISSION_FLAG_ID_ROLE_ID_UNIQUE"
UNIQUE ("permissionFlagId", "roleId")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_ROLE_PERMISSION_FLAG_PERMISSION_FLAG_ID"
ON "core"."rolePermissionFlag" ("permissionFlagId")`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
ADD CONSTRAINT "FK_8724e63323f1331591a3e91b0b3"
FOREIGN KEY ("permissionFlagId") REFERENCES "core"."permissionFlag"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
DROP CONSTRAINT IF EXISTS "FK_8724e63323f1331591a3e91b0b3"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "core"."IDX_ROLE_PERMISSION_FLAG_PERMISSION_FLAG_ID"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
DROP CONSTRAINT IF EXISTS "IDX_ROLE_PERMISSION_FLAG_PERMISSION_FLAG_ID_ROLE_ID_UNIQUE"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
DROP COLUMN IF EXISTS "permissionFlagId"`,
);
}
}
@@ -0,0 +1,32 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
const MESSAGE_CHANNEL_INDEX_NAME =
'IDX_MESSAGE_CHANNEL_WORKSPACE_ID_SYNC_ENABLED_SYNC_STAGE';
const CALENDAR_CHANNEL_INDEX_NAME =
'IDX_CALENDAR_CHANNEL_WORKSPACE_ID_SYNC_ENABLED_SYNC_STAGE';
@RegisteredInstanceCommand('2.6.0', 1798000010000)
export class AddChannelSyncStageIndexesFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "${MESSAGE_CHANNEL_INDEX_NAME}" ON "core"."messageChannel" ("workspaceId", "isSyncEnabled", "syncStage")`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "${CALENDAR_CHANNEL_INDEX_NAME}" ON "core"."calendarChannel" ("workspaceId", "isSyncEnabled", "syncStage")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS "core"."${CALENDAR_CHANNEL_INDEX_NAME}"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "core"."${MESSAGE_CHANNEL_INDEX_NAME}"`,
);
}
}
@@ -0,0 +1,88 @@
import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application';
import { PermissionFlagType } from 'twenty-shared/constants';
import { DataSource, QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
import { STANDARD_PERMISSION_FLAG_DEFINITIONS } from 'src/engine/metadata-modules/permission-flag/constants/standard-permission-flag-definitions.constant';
const PERMISSION_FLAG_TYPES = Object.values(PermissionFlagType) as string[];
@RegisteredInstanceCommand('2.6.0', 1778235340023, { type: 'slow' })
export class BackfillRolePermissionFlagPermissionFlagIdSlowInstanceCommand
implements SlowInstanceCommand
{
async runDataMigration(dataSource: DataSource): Promise<void> {
const unknownFlagRows: { flag: string }[] = await dataSource.query(
`SELECT DISTINCT "flag" FROM "core"."rolePermissionFlag"
WHERE "flag" <> ALL($1::varchar[])`,
[PERMISSION_FLAG_TYPES],
);
if (unknownFlagRows.length > 0) {
const unknownFlags = unknownFlagRows.map((row) => row.flag).join(', ');
throw new Error(
`Cannot migrate: rolePermissionFlag rows reference unknown flag value(s): ${unknownFlags}`,
);
}
for (const definition of STANDARD_PERMISSION_FLAG_DEFINITIONS) {
await dataSource.query(
`INSERT INTO "core"."permissionFlag" (
"id",
"workspaceId",
"applicationId",
"universalIdentifier",
"key",
"label",
"description",
"icon",
"permissionType",
"createdAt",
"updatedAt"
)
SELECT
uuid_generate_v4(),
workspace."id",
standardApplication."id",
$1::uuid,
$2,
$3,
$4,
$5,
$6,
now(),
now()
FROM "core"."workspace" workspace
INNER JOIN "core"."application" standardApplication
ON standardApplication."workspaceId" = workspace."id"
AND standardApplication."universalIdentifier" = $7
AND standardApplication."deletedAt" IS NULL
ON CONFLICT ("key", "workspaceId") DO NOTHING`,
[
definition.universalIdentifier,
definition.key,
definition.label,
definition.description,
definition.icon,
definition.permissionType,
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
],
);
}
await dataSource.query(
`UPDATE "core"."rolePermissionFlag" rolePermissionFlag
SET "permissionFlagId" = permissionFlag."id"
FROM "core"."permissionFlag" permissionFlag
WHERE permissionFlag."workspaceId" = rolePermissionFlag."workspaceId"
AND permissionFlag."key" = rolePermissionFlag."flag"
AND rolePermissionFlag."permissionFlagId" IS NULL`,
);
}
public async up(_queryRunner: QueryRunner): Promise<void> {}
public async down(_queryRunner: QueryRunner): Promise<void> {}
}
@@ -31,6 +31,10 @@ import { AddToolAndWorkflowActionTriggerSettingsFastInstanceCommand } from 'src/
import { BackfillApplicationVariableUniversalIdentifierSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-slow-1777966965588-backfill-application-variable-universal-identifier';
import { MigrateToolTriggerSettingsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-slow-1797000002000-migrate-tool-trigger-settings';
import { AddMetadataToBillingPriceFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1777100000000-add-metadata-to-billing-price';
import { RenamePermissionFlagToRolePermissionFlagFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1778235340020-rename-permission-flag-to-role-permission-flag';
import { PermissionFlagSyncableEntityFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1778235340021-permission-flag-syncable-entity';
import { LinkRolePermissionFlagToPermissionFlagFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1778235340022-link-role-permission-flag-to-permission-flag';
import { BackfillRolePermissionFlagPermissionFlagIdSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-slow-1778235340023-backfill-role-permission-flag-permission-flag-id';
import { AddEmailGroupChannelTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1778256809018-add-email-group-channel-type';
import { AddApplicationIdToPublicDomainFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1798000003000-add-application-id-to-public-domain';
import { AddIsInternalMessagesImportEnabledFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778525104406-add-is-internal-messages-import-enabled';
@@ -44,6 +48,7 @@ import { EncryptTotpSecretsSlowInstanceCommand } from 'src/database/commands/upg
import { AddSubFieldNameToViewSortFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778502963794-add-sub-field-name-to-view-sort';
import { DropPostgresCredentialsTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1798500000000-drop-postgres-credentials-table';
import { AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000005000-add-relation-target-field-metadata-id-to-view-filter';
import { AddChannelSyncStageIndexesFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000010000-add-channel-sync-stage-indexes';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -77,6 +82,10 @@ export const INSTANCE_COMMANDS = [
RemoveUserDefaultAvatarUrlFastInstanceCommand,
TransformApplicationVariableToSyncableEntityFastInstanceCommand,
BackfillApplicationVariableUniversalIdentifierSlowInstanceCommand,
RenamePermissionFlagToRolePermissionFlagFastInstanceCommand,
PermissionFlagSyncableEntityFastInstanceCommand,
LinkRolePermissionFlagToPermissionFlagFastInstanceCommand,
BackfillRolePermissionFlagPermissionFlagIdSlowInstanceCommand,
AddEmailGroupChannelTypeFastInstanceCommand,
AddApplicationIdToPublicDomainFastInstanceCommand,
AddIsInternalMessagesImportEnabledFastInstanceCommand,
@@ -90,4 +99,5 @@ export const INSTANCE_COMMANDS = [
AddSubFieldNameToViewSortFastInstanceCommand,
DropPostgresCredentialsTableFastInstanceCommand,
AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand,
AddChannelSyncStageIndexesFastInstanceCommand,
];
@@ -0,0 +1,7 @@
import { type McpToolAnnotations } from 'src/engine/api/mcp/types/mcp-tool-annotations.type';
export const MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS: McpToolAnnotations = {
readOnlyHint: true,
openWorldHint: false,
destructiveHint: false,
};
@@ -0,0 +1,7 @@
import { type McpToolAnnotations } from 'src/engine/api/mcp/types/mcp-tool-annotations.type';
export const MCP_EXECUTE_TOOL_ANNOTATIONS: McpToolAnnotations = {
readOnlyHint: false,
openWorldHint: true,
destructiveHint: true,
};
@@ -0,0 +1,7 @@
import { type McpToolAnnotations } from 'src/engine/api/mcp/types/mcp-tool-annotations.type';
export const MCP_OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS: McpToolAnnotations = {
readOnlyHint: true,
openWorldHint: true,
destructiveHint: false,
};
@@ -2,12 +2,16 @@ import { HttpException, HttpStatus } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const';
import { MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-closed-world-read-only-tool-annotations.const';
import { MCP_EXECUTE_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-execute-tool-annotations.const';
import { MCP_OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-open-world-read-only-tool-annotations.const';
import { MCP_PROTOCOL_VERSION } from 'src/engine/api/mcp/constants/mcp-protocol-version.const';
import { MCP_SERVER_INFO } from 'src/engine/api/mcp/constants/mcp-server-info.const';
import { MCP_SERVER_INSTRUCTIONS } from 'src/engine/api/mcp/constants/mcp-server-instructions.const';
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
import { type McpToolAnnotations } from 'src/engine/api/mcp/types/mcp-tool-annotations.type';
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { EXECUTE_TOOL_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/execute-tool.tool';
@@ -41,7 +45,18 @@ describe('McpProtocolService', () => {
EXECUTE_TOOL_TOOL_NAME,
LOAD_SKILL_TOOL_NAME,
'search_help_center',
];
] as const;
const EXPECTED_MCP_TOOL_ANNOTATIONS: Record<
(typeof EXPECTED_MCP_TOOL_NAMES)[number],
McpToolAnnotations
> = {
[GET_TOOL_CATALOG_TOOL_NAME]: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
[LEARN_TOOLS_TOOL_NAME]: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
[EXECUTE_TOOL_TOOL_NAME]: MCP_EXECUTE_TOOL_ANNOTATIONS,
[LOAD_SKILL_TOOL_NAME]: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
search_help_center: MCP_OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
};
beforeEach(async () => {
const mockSearchHelpCenterTool = {
@@ -255,6 +270,7 @@ describe('McpProtocolService', () => {
name,
expect.objectContaining({
description: expect.any(String),
annotations: EXPECTED_MCP_TOOL_ANNOTATIONS[name],
execute: expect.any(Function),
}),
]),
@@ -294,6 +310,7 @@ describe('McpProtocolService', () => {
name,
expect.objectContaining({
description: expect.any(String),
annotations: EXPECTED_MCP_TOOL_ANNOTATIONS[name],
}),
]),
),
@@ -1,4 +1,5 @@
import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const';
import { MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-closed-world-read-only-tool-annotations.const';
import {
MCP_PROGRESS_NOTIFICATION_METHOD,
TOOL_CALL_PROGRESS_TOKEN_PREFIX,
@@ -24,6 +25,7 @@ describe('McpToolExecutorService', () => {
required: ['query'],
},
},
annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
},
} as any;
@@ -42,6 +44,7 @@ describe('McpToolExecutorService', () => {
properties: { query: { type: 'string' } },
required: ['query'],
},
annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
},
],
},
@@ -4,12 +4,16 @@ import { type ToolSet, zodSchema } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const';
import { MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-closed-world-read-only-tool-annotations.const';
import { MCP_EXCLUDED_TOOL_NAMES } from 'src/engine/api/mcp/constants/mcp-excluded-tool-names.const';
import { MCP_EXECUTE_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-execute-tool-annotations.const';
import { MCP_OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-open-world-read-only-tool-annotations.const';
import { MCP_PROTOCOL_VERSION } from 'src/engine/api/mcp/constants/mcp-protocol-version.const';
import { MCP_SERVER_INFO } from 'src/engine/api/mcp/constants/mcp-server-info.const';
import { MCP_SERVER_INSTRUCTIONS } from 'src/engine/api/mcp/constants/mcp-server-instructions.const';
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
import { type McpToolAnnotations } from 'src/engine/api/mcp/types/mcp-tool-annotations.type';
import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util';
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
@@ -41,6 +45,33 @@ import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
type McpAnnotatedTool = ToolSet[string] & {
annotations: McpToolAnnotations;
};
const MCP_PRELOADED_TOOL_ANNOTATIONS: Record<string, McpToolAnnotations> = {
search_help_center: MCP_OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
};
const annotatePreloadedMcpTools = (toolSet: ToolSet): ToolSet =>
Object.fromEntries(
Object.entries(toolSet).map(([name, toolDefinition]) => {
const annotations = MCP_PRELOADED_TOOL_ANNOTATIONS[name];
if (!isDefined(annotations)) {
throw new Error(`Missing MCP annotations for preloaded tool "${name}"`);
}
return [
name,
{
...toolDefinition,
annotations,
} as McpAnnotatedTool,
];
}),
);
@Injectable()
export class McpProtocolService {
constructor(
@@ -121,7 +152,7 @@ export class McpProtocolService {
);
return {
...preloadedTools,
...annotatePreloadedMcpTools(preloadedTools),
[GET_TOOL_CATALOG_TOOL_NAME]: {
...createGetToolCatalogTool(this.toolRegistry, workspace.id, roleId, {
userId: options?.userId,
@@ -129,7 +160,8 @@ export class McpProtocolService {
excludeTools: MCP_EXCLUDED_TOOL_NAMES,
}),
inputSchema: zodSchema(getToolCatalogInputSchema),
},
annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
} as McpAnnotatedTool,
[LEARN_TOOLS_TOOL_NAME]: {
...createLearnToolsTool(
this.toolRegistry,
@@ -137,13 +169,15 @@ export class McpProtocolService {
MCP_EXCLUDED_TOOL_NAMES,
),
inputSchema: zodSchema(learnToolsInputSchema),
},
annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
} as McpAnnotatedTool,
[EXECUTE_TOOL_TOOL_NAME]: {
...createExecuteToolTool(this.toolRegistry, toolContext, {
excludeTools: MCP_EXCLUDED_TOOL_NAMES,
}),
inputSchema: executeToolInputSchema,
},
annotations: MCP_EXECUTE_TOOL_ANNOTATIONS,
} as McpAnnotatedTool,
[LOAD_SKILL_TOOL_NAME]: {
...createLoadSkillTool(
(names) =>
@@ -157,7 +191,8 @@ export class McpProtocolService {
},
),
inputSchema: zodSchema(loadSkillInputSchema),
},
annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
} as McpAnnotatedTool,
};
}
@@ -8,8 +8,18 @@ import {
MCP_PROGRESS_NOTIFICATION_METHOD,
TOOL_CALL_PROGRESS_TOKEN_PREFIX,
} from 'src/engine/api/mcp/constants/mcp-progress-notification.const';
import { type McpToolAnnotations } from 'src/engine/api/mcp/types/mcp-tool-annotations.type';
import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util';
type McpToolDefinition = ToolSet[string] & {
annotations?: McpToolAnnotations;
};
const unwrapJsonSchema = (schema: unknown) =>
schema && typeof schema === 'object' && 'jsonSchema' in schema
? schema.jsonSchema
: schema;
@Injectable()
export class McpToolExecutorService {
async handleToolCall(
@@ -76,20 +86,18 @@ export class McpToolExecutorService {
const toolsArray = Object.entries(toolSet)
.filter(([, def]) => !!def.inputSchema)
.map(([name, def]) => {
const toolDefinition = def as McpToolDefinition;
// Unwrap the AI SDK's jsonSchema wrapper if present
// The AI SDK serializes schemas as { jsonSchema: {...} } but MCP expects {...} directly
const inputSchema = def.inputSchema;
const unwrappedSchema =
inputSchema &&
typeof inputSchema === 'object' &&
'jsonSchema' in inputSchema
? inputSchema.jsonSchema
: inputSchema;
const inputSchema = unwrapJsonSchema(toolDefinition.inputSchema);
return {
name,
description: def.description,
inputSchema: unwrappedSchema,
description: toolDefinition.description,
inputSchema,
...(isDefined(toolDefinition.annotations) && {
annotations: toolDefinition.annotations,
}),
};
});
@@ -0,0 +1,5 @@
export type McpToolAnnotations = {
readOnlyHint: boolean;
openWorldHint: boolean;
destructiveHint: boolean;
};
@@ -6,6 +6,7 @@ import { type Query } from 'src/engine/api/rest/core/types/query.type';
import { RestApiException } from 'src/engine/api/rest/errors/RestApiException';
import { type RequestContext } from 'src/engine/api/rest/types/RequestContext';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { isDefined } from 'twenty-shared/utils';
export enum GraphqlApiType {
CORE = 'core',
@@ -41,10 +42,14 @@ export class RestApiService {
},
});
} catch (err) {
throw new RestApiException(err.response.data.errors);
if (isDefined(err.response?.data?.errors)) {
throw new RestApiException(err.response.data.errors);
}
throw err;
}
if (response.data.errors?.length) {
if (isDefined(response.data.errors) && response.data.errors.length > 0) {
throw new RestApiException(response.data.errors);
}

Some files were not shown because too many files have changed in this diff Show More