fix(API): Stop advertising server-managed fields as writable

The generated request spec listed `id`, `active`, `createdAt` and the other
server-managed fields as ordinary properties with empty schemas, because the
generator cannot express a "must be absent" check. Alongside
`additionalProperties: false`, the published contract positively invited callers
to send fields the server always rejects, and an SDK generated from it would
expose them as settable. The hand-written spec marked them `readOnly`.

They are now left out of the request shape, so the spec describes only what a
caller may send. The strict object rejects them as unrecognised keys, and an
error map keeps the specific message rather than letting a read-only field look
like a typo:

    { "id": "abc" }   -> 'id' is a read-only field and cannot be set
    { "nam": "oops" } -> Unrecognized key(s) in object: 'nam'

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uddish Verma 2026-08-13 17:10:24 +01:00
parent 5a8b698a6e
commit 9cbd5cb97b
No known key found for this signature in database
5 changed files with 60 additions and 40 deletions

View File

@ -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');
}
});
});
});

View File

@ -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<undefined>((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;

View File

@ -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 },
) {}

View File

@ -39,14 +39,20 @@ export const Z = {
class: <T extends z.ZodRawShape>(
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<z.objectOutputType<T, z.ZodTypeAny>, 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<T, z.ZodTypeAny>;
const DtoClass = class {

View File

@ -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: