diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts index 83fa06c35eb..d9a82e47091 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts @@ -5,7 +5,7 @@ import { saveDocumentTagDefinitionsContract } from '@/lib/api/contracts/knowledg import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants' +import { getFieldTypeForSlot, SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants' import { cleanupUnusedTagDefinitions, createOrUpdateTagDefinitionsBulk, @@ -114,6 +114,16 @@ export const POST = withRouteHandler( { status: 400 } ) } + /** + * Slot validity only, not slot/field-type agreement: this route also renames + * existing definitions, which resend whatever pair is already stored. + */ + if (getFieldTypeForSlot(def.tagSlot) === null) { + return NextResponse.json( + { error: 'Invalid request data', details: `Unsupported tag slot: ${def.tagSlot}` }, + { status: 400 } + ) + } } const bulkData: BulkTagDefinitionsData = { diff --git a/apps/sim/app/api/knowledge/[id]/tag-definitions/route.test.ts b/apps/sim/app/api/knowledge/[id]/tag-definitions/route.test.ts index 23231c66581..9049b9f6e9e 100644 --- a/apps/sim/app/api/knowledge/[id]/tag-definitions/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/tag-definitions/route.test.ts @@ -23,6 +23,7 @@ vi.mock('@/lib/knowledge/tags/service', () => ({ vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) +import { KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH } from '@/lib/knowledge/constants' import { GET, POST } from '@/app/api/knowledge/[id]/tag-definitions/route' const KB_ID = 'kb-victim' @@ -140,5 +141,64 @@ describe('Knowledge Base Tag Definitions API Route', () => { expect(mockCheckKnowledgeBaseWriteAccess).not.toHaveBeenCalled() expect(mockCreateTagDefinition).not.toHaveBeenCalled() }) + + it('rejects a tag slot this schema has no column for', async () => { + authenticateAs('user-1', 'session') + mockCheckKnowledgeBaseWriteAccess.mockResolvedValue(granted) + + const response = await POST( + createMockRequest('POST', { ...CREATE_BODY, tagSlot: 'tag99' }), + params() + ) + + expect(response.status).toBe(400) + expect(mockCreateTagDefinition).not.toHaveBeenCalled() + }) + + it('rejects a slot that belongs to a different field type', async () => { + authenticateAs('user-1', 'session') + mockCheckKnowledgeBaseWriteAccess.mockResolvedValue(granted) + + const response = await POST( + createMockRequest('POST', { + tagSlot: 'number1', + displayName: 'Mismatch', + fieldType: 'text', + }), + params() + ) + + expect(response.status).toBe(400) + expect(mockCreateTagDefinition).not.toHaveBeenCalled() + }) + + it('rejects an unsupported field type', async () => { + authenticateAs('user-1', 'session') + mockCheckKnowledgeBaseWriteAccess.mockResolvedValue(granted) + + const response = await POST( + createMockRequest('POST', { ...CREATE_BODY, fieldType: 'nonsense' }), + params() + ) + + expect(response.status).toBe(400) + expect(mockCreateTagDefinition).not.toHaveBeenCalled() + }) + + it('rejects a display name longer than the shared limit', async () => { + authenticateAs('user-1', 'session') + mockCheckKnowledgeBaseWriteAccess.mockResolvedValue(granted) + + const response = await POST( + createMockRequest('POST', { + ...CREATE_BODY, + displayName: 'a'.repeat(KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH + 1), + }), + params() + ) + + expect(response.status).toBe(400) + expect(mockCreateTagDefinition).not.toHaveBeenCalled() + }) }) }) diff --git a/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts b/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts index 811077245b3..b3729e3188f 100644 --- a/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts +++ b/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts @@ -5,7 +5,7 @@ import { createTagDefinitionContract } from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants' +import { isValidSlotForFieldType } from '@/lib/knowledge/constants' import { createTagDefinition, getTagDefinitions } from '@/lib/knowledge/tags/service' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' @@ -76,9 +76,18 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const validatedData = parsed.data.body - if (!(SUPPORTED_FIELD_TYPES as readonly string[]).includes(validatedData.fieldType)) { + /** + * The contract types `tagSlot` and `fieldType` as plain strings because + * tightening them to enums cascades into UI form state types, so the pair is + * checked here. Nothing downstream enforces it: the slot column is `text` + * (its Drizzle `enum` is types-only) and the service casts before inserting. + */ + if (!isValidSlotForFieldType(validatedData.tagSlot, validatedData.fieldType)) { return NextResponse.json( - { error: 'Invalid request data', details: 'Invalid field type' }, + { + error: 'Invalid request data', + details: `Tag slot "${validatedData.tagSlot}" is not valid for field type "${validatedData.fieldType}"`, + }, { status: 400 } ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx index 64ea09b9305..faa99423d44 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx @@ -18,7 +18,13 @@ import { } from '@sim/emcn' import { createLogger } from '@sim/logger' import { formatDate } from '@sim/utils/formatting' -import { ALL_TAG_SLOTS, type AllTagSlot, MAX_TAG_SLOTS } from '@/lib/knowledge/constants' +import { + ALL_TAG_SLOTS, + type AllTagSlot, + FIELD_TYPE_LABELS, + KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH, + MAX_TAG_SLOTS, +} from '@/lib/knowledge/constants' import type { DocumentTag } from '@/lib/knowledge/tags/types' import type { DocumentData } from '@/lib/knowledge/types' import { @@ -30,14 +36,6 @@ import { useNextAvailableSlotMutation, useUpdateDocumentTags } from '@/hooks/que const logger = createLogger('DocumentTagsModal') -/** Field type display labels */ -const FIELD_TYPE_LABELS: Record = { - text: 'Text', - number: 'Number', - date: 'Date', - boolean: 'Boolean', -} - /** * Gets the appropriate value when changing field types. * Clears value when type changes to allow placeholder to show. @@ -462,6 +460,7 @@ export function DocumentTagsModal({ setEditTagForm({ ...editTagForm, displayName: e.target.value }) } placeholder='Enter tag name' + maxLength={KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH} error={tagNameConflict} onKeyDown={(e) => { if (e.key === 'Enter' && canSaveTag) { @@ -615,6 +614,7 @@ export function DocumentTagsModal({ setEditTagForm({ ...editTagForm, displayName: e.target.value }) } placeholder='Enter tag name' + maxLength={KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH} error={tagNameConflict} onKeyDown={(e) => { if (e.key === 'Enter' && canSaveTag) { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx index 161db5369df..8bf94aec13a 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx @@ -17,7 +17,12 @@ import { } from '@sim/emcn' import { createLogger } from '@sim/logger' import type { TagUsageData } from '@/lib/api/contracts/knowledge' -import { SUPPORTED_FIELD_TYPES, TAG_SLOT_CONFIG } from '@/lib/knowledge/constants' +import { + FIELD_TYPE_LABELS, + KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH, + SUPPORTED_FIELD_TYPES, + TAG_SLOT_CONFIG, +} from '@/lib/knowledge/constants' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { type TagDefinition, @@ -31,13 +36,6 @@ import { const logger = createLogger('BaseTagsModal') -const FIELD_TYPE_LABELS: Record = { - text: 'Text', - number: 'Number', - date: 'Date', - boolean: 'Boolean', -} - interface DocumentListProps { documents: Array<{ id: string; name: string; tagValue: string }> totalCount: number @@ -154,7 +152,9 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM isCreatingTag && !createTagMutation.isPending && hasTagNameConflict(createTagForm.displayName) const canSaveTag = () => { - return createTagForm.displayName.trim() && !hasTagNameConflict(createTagForm.displayName) + return ( + createTagForm.displayName.trim().length > 0 && !hasTagNameConflict(createTagForm.displayName) + ) } const getSlotUsageByFieldType = (fieldType: string): { used: number; max: number } => { @@ -331,6 +331,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM setCreateTagForm({ ...createTagForm, displayName: e.target.value }) } placeholder='Enter tag name' + maxLength={KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH} error={Boolean(tagNameConflict)} onKeyDown={(e) => { if (e.key === 'Enter' && canSaveTag()) { diff --git a/apps/sim/lib/api/contracts/knowledge/tags.ts b/apps/sim/lib/api/contracts/knowledge/tags.ts index c44cc9424d3..c91d9f3cd8f 100644 --- a/apps/sim/lib/api/contracts/knowledge/tags.ts +++ b/apps/sim/lib/api/contracts/knowledge/tags.ts @@ -6,6 +6,7 @@ import { successResponseSchema, } from '@/lib/api/contracts/knowledge/shared' import { defineRouteContract } from '@/lib/api/contracts/types' +import { KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH } from '@/lib/knowledge/constants' export const nextAvailableSlotQuerySchema = z.object({ fieldType: z.string().min(1), @@ -13,13 +14,19 @@ export const nextAvailableSlotQuerySchema = z.object({ export const createTagDefinitionBodySchema = z.object({ tagSlot: z.string().min(1, 'Tag slot is required'), - displayName: z.string().min(1, 'Display name is required'), + displayName: z + .string() + .min(1, 'Display name is required') + .max(KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH, 'Display name too long'), fieldType: z.string().min(1, 'Invalid field type'), }) export const documentTagDefinitionInputSchema = z.object({ tagSlot: z.string().min(1, 'Tag slot is required'), - displayName: z.string().min(1, 'Display name is required').max(100, 'Display name too long'), + displayName: z + .string() + .min(1, 'Display name is required') + .max(KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH, 'Display name too long'), fieldType: z.string().default('text'), _originalDisplayName: z.string().optional(), }) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index d88ce57e8fd..843a61e5794 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -22,6 +22,7 @@ import { type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH } from '@/lib/knowledge/constants' import { createSingleDocument, deleteDocument, @@ -705,6 +706,12 @@ export const knowledgeBaseServerTool: BaseServerTool KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH) { + return { + success: false, + message: `tagDisplayName must be ${KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH} characters or less`, + } + } const writeAccess = await checkKnowledgeBaseWriteAccess( args.knowledgeBaseId, @@ -777,6 +784,15 @@ export const knowledgeBaseServerTool: BaseServerTool KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH + ) { + return { + success: false, + message: `tagDisplayName must be ${KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH} characters or less`, + } + } const existingTag = await getTagDefinitionById(args.tagDefinitionId) if (!existingTag) { diff --git a/apps/sim/lib/knowledge/constants.ts b/apps/sim/lib/knowledge/constants.ts index 84b9d8c9830..7b93ee86ce8 100644 --- a/apps/sim/lib/knowledge/constants.ts +++ b/apps/sim/lib/knowledge/constants.ts @@ -43,6 +43,12 @@ export type TagSlot = (typeof TAG_SLOTS)[number] /** Type for all tag slots */ export type AllTagSlot = (typeof ALL_TAG_SLOTS)[number] +/** + * Max character length for a tag display name, enforced on every write path (UI, + * create API, bulk document API, copilot tools). + */ +export const KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH = 100 + /** Type for number tag slots */ export type NumberTagSlot = (typeof TAG_SLOT_CONFIG.number.slots)[number]