diff --git a/packages/@n8n/api-types/src/dto/workflows/__tests__/create-workflow-public.dto.test.ts b/packages/@n8n/api-types/src/dto/workflows/__tests__/create-workflow-public.dto.test.ts index 11d460ef961..60301832619 100644 --- a/packages/@n8n/api-types/src/dto/workflows/__tests__/create-workflow-public.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/workflows/__tests__/create-workflow-public.dto.test.ts @@ -228,5 +228,15 @@ describe('CreateWorkflowPublicDto', () => { expect(result.error.issues[0].message).toContain(field); } }); + + test('does not call an unrecognised field read-only', () => { + const result = CreateWorkflowPublicDto.safeParse({ ...minimalWorkflow, nam: 'typo' }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).not.toContain('read-only'); + expect(result.error.issues[0].message).toContain('nam'); + } + }); }); }); diff --git a/packages/@n8n/api-types/src/dto/workflows/base-workflow-public.dto.ts b/packages/@n8n/api-types/src/dto/workflows/base-workflow-public.dto.ts index af510d8728a..e192e8f2ac0 100644 --- a/packages/@n8n/api-types/src/dto/workflows/base-workflow-public.dto.ts +++ b/packages/@n8n/api-types/src/dto/workflows/base-workflow-public.dto.ts @@ -9,16 +9,43 @@ import { z } from 'zod'; const GROUP_DESCRIPTION_PUBLIC_MAX_LENGTH = 155; /** - * Fields the public GET/POST/PUT response includes but that are server-managed. The previous - * hand-written OpenAPI schema (`workflow.yml`/`workflowCreate.yml`) marked these `readOnly`, - * which express-openapi-validator enforced by rejecting the request when one was present. This - * reproduces that rejection instead of silently accepting or stripping the value. + * Server-managed fields: present in the response, rejected in a request. The hand-written spec + * marked them `readOnly`, which express-openapi-validator enforced. + * + * They are deliberately absent from the shape below rather than declared and rejected. A declared + * field would appear in the generated request spec, and the generator cannot express `readOnly` + * for it — so the published contract would invite callers to send fields the server always + * rejects. Left out, the `strict` object rejects them as unrecognised keys, and the error map + * below restores the specific message. */ -function readOnlyPublicField(fieldName: string) { - return z.custom((value) => value === undefined, { - message: `'${fieldName}' is a read-only field and cannot be set`, - }); -} +const READ_ONLY_PUBLIC_FIELDS = [ + 'id', + 'active', + 'createdAt', + 'updatedAt', + 'isArchived', + 'versionId', + 'triggerCount', + 'meta', + 'tags', + 'shared', + 'activeVersion', +]; + +/** Tells a caller a rejected key is read-only, rather than leaving it to look like a typo. */ +export const readOnlyPublicFieldErrorMap: z.ZodErrorMap = (issue, ctx) => { + if (issue.code === z.ZodIssueCode.unrecognized_keys) { + const readOnly = issue.keys.filter((key) => READ_ONLY_PUBLIC_FIELDS.includes(key)); + + if (readOnly.length > 0) { + const names = readOnly.map((key) => `'${key}'`).join(', '); + const verb = readOnly.length > 1 ? 'are read-only fields' : 'is a read-only field'; + return { message: `${names} ${verb} and cannot be set` }; + } + } + + return { message: ctx.defaultError }; +}; // Nodes, connections, settings, static data, and pin data all carry arbitrary, // node-type-specific shapes that only the workflow engine can fully validate -- @@ -102,16 +129,4 @@ export const workflowWritePublicShape = { staticData: staticDataWritePublicSchema.optional(), pinData: pinDataWritePublicSchema.optional(), parentFolderId: z.string().nullable().optional(), - - id: readOnlyPublicField('id'), - active: readOnlyPublicField('active'), - createdAt: readOnlyPublicField('createdAt'), - updatedAt: readOnlyPublicField('updatedAt'), - isArchived: readOnlyPublicField('isArchived'), - versionId: readOnlyPublicField('versionId'), - triggerCount: readOnlyPublicField('triggerCount'), - meta: readOnlyPublicField('meta'), - tags: readOnlyPublicField('tags'), - shared: readOnlyPublicField('shared'), - activeVersion: readOnlyPublicField('activeVersion'), } as const; diff --git a/packages/@n8n/api-types/src/dto/workflows/create-workflow-public.dto.ts b/packages/@n8n/api-types/src/dto/workflows/create-workflow-public.dto.ts index b940da5f5ec..fcd830924f5 100644 --- a/packages/@n8n/api-types/src/dto/workflows/create-workflow-public.dto.ts +++ b/packages/@n8n/api-types/src/dto/workflows/create-workflow-public.dto.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { workflowWritePublicShape } from './base-workflow-public.dto'; +import { readOnlyPublicFieldErrorMap, workflowWritePublicShape } from './base-workflow-public.dto'; import { Z } from '../../zod-class'; export class CreateWorkflowPublicDto extends Z.class( @@ -8,5 +8,5 @@ export class CreateWorkflowPublicDto extends Z.class( ...workflowWritePublicShape, projectId: z.string().optional(), }, - { strict: true }, + { strict: true, errorMap: readOnlyPublicFieldErrorMap }, ) {} diff --git a/packages/@n8n/api-types/src/zod-class.ts b/packages/@n8n/api-types/src/zod-class.ts index ed0f997ee02..2c7d63ac79d 100644 --- a/packages/@n8n/api-types/src/zod-class.ts +++ b/packages/@n8n/api-types/src/zod-class.ts @@ -39,14 +39,20 @@ export const Z = { class: ( shape: T, /** - * Pass `strict` when the endpoint must reject an unrecognised key rather than drop it. - * Needed by Public API request bodies whose hand-written spec set - * `additionalProperties: false`: Zod strips by default, so without this a client's typo - * would be silently accepted where it used to return 400. + * `strict` rejects an unrecognised key rather than dropping it. Needed by Public API + * request bodies whose hand-written spec set `additionalProperties: false`: Zod strips by + * default, so without this a client's typo would be silently accepted where it used to + * return 400. + * + * `errorMap` lets a DTO explain those rejections in its own terms - for example, saying a + * key is read-only rather than unrecognised. */ - options: { strict?: boolean } = {}, + options: { strict?: boolean; errorMap?: z.ZodErrorMap } = {}, ): ZodClass, T> => { - const schema = options.strict ? z.object(shape).strict() : z.object(shape); + const object = options.errorMap + ? z.object(shape, { errorMap: options.errorMap }) + : z.object(shape); + const schema = options.strict ? object.strict() : object; type Output = z.objectOutputType; const DtoClass = class { diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/createWorkflow.generated.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/createWorkflow.generated.yml index abb159ff5b1..b558ae8a36e 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/createWorkflow.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/createWorkflow.generated.yml @@ -127,17 +127,6 @@ requestBody: parentFolderId: type: string nullable: true - id: {} - active: {} - createdAt: {} - updatedAt: {} - isArchived: {} - versionId: {} - triggerCount: {} - meta: {} - tags: {} - shared: {} - activeVersion: {} projectId: type: string required: