From 6a05e446ae333eb6e0429d1a99c4830b78fe5c16 Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Thu, 29 Jan 2026 15:08:44 +0200 Subject: [PATCH 1/5] refactor: Restrict entity schema according to backend --- src/core/resources/entity/schema.ts | 157 +++++++++++++++++- .../fixtures/full-project/entities/task.json | 9 +- 2 files changed, 160 insertions(+), 6 deletions(-) diff --git a/src/core/resources/entity/schema.ts b/src/core/resources/entity/schema.ts index 9fdea5943..162dbf7ab 100644 --- a/src/core/resources/entity/schema.ts +++ b/src/core/resources/entity/schema.ts @@ -1,7 +1,160 @@ import { z } from "zod"; -export const EntitySchema = z.looseObject({ - name: z.string().min(1, "Entity name cannot be empty"), +const FieldConditionSchema = z.union([ + z.string(), + z.object({ + $in: z.unknown().optional(), + $nin: z.unknown().optional(), + $ne: z.unknown().optional(), + $all: z.unknown().optional(), + }), +]); + +const userConditionAllowedKeys = new Set(["role", "email", "id"]); + +const UserConditionSchema = z + .looseObject({ + role: z.string().optional(), + email: z.string().optional(), + id: z.string().optional(), + }) + .refine( + (val) => + Object.keys(val).every( + (key) => userConditionAllowedKeys.has(key) || key.startsWith("data.") + ), + "Keys must be role, email, id, or match data.* pattern" + ) + +const rlsConditionAllowedKeys = new Set([ + "user_condition", + "created_by", + "created_by_id", + "$or", + "$and", + "$nor", +]); + +const RLSConditionSchema = z + .looseObject({ + user_condition: UserConditionSchema.optional(), + created_by: FieldConditionSchema.optional(), + created_by_id: FieldConditionSchema.optional(), + get $or(): z.ZodOptional> { + return z.array(RefineRLSConditionSchema).optional(); + }, + get $and(): z.ZodOptional> { + return z.array(RefineRLSConditionSchema).optional(); + }, + get $nor(): z.ZodOptional> { + return z.array(RefineRLSConditionSchema).optional(); + }, + }); + +const fieldConditionOperators = new Set(["$in", "$nin", "$ne", "$all"]); + +const isValidFieldCondition = (value: unknown): boolean => { + // Server accepts: string, number, boolean, null, or operator object + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return true; + } + if (typeof value === "object") { + return Object.keys(value).every((k) => fieldConditionOperators.has(k)); + } + return false; +}; + +const RefineRLSConditionSchema = RLSConditionSchema.refine( + (val) => + Object.entries(val).every(([key, value]) => { + if (rlsConditionAllowedKeys.has(key)) {return true;} + if (!key.startsWith("data.")) {return false;} + return isValidFieldCondition(value); + }), + "Keys must be known RLS keys or match data.* pattern with valid value" +); + +const RLSRuleSchema = z.union([z.boolean(), RefineRLSConditionSchema]); + +const EntityRLSSchema = z.strictObject({ + create: RLSRuleSchema.optional(), + read: RLSRuleSchema.optional(), + update: RLSRuleSchema.optional(), + delete: RLSRuleSchema.optional(), + write: RLSRuleSchema.optional(), +}); + +const FieldRLSSchema = z.strictObject({ + read: RLSRuleSchema.optional(), + write: RLSRuleSchema.optional(), + create: RLSRuleSchema.optional(), + update: RLSRuleSchema.optional(), + delete: RLSRuleSchema.optional(), +}); + +const PropertyTypeSchema = z.enum([ + "string", + "number", + "integer", + "boolean", + "array", + "object", + "binary", +]); + +const StringFormatSchema = z.enum([ + "date", + "date-time", + "time", + "email", + "uri", + "hostname", + "ipv4", + "ipv6", + "uuid", + "file", + "regex", +]); + +const PropertyDefinitionSchema = z.object({ + type: PropertyTypeSchema, + title: z.string().optional(), + description: z.string().optional(), + minLength: z.number().int().min(0).optional(), + maxLength: z.number().int().min(0).optional(), + pattern: z.string().optional(), + format: StringFormatSchema.optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + enum: z.array(z.string()).optional(), + enumNames: z.array(z.string()).optional(), + default: z.unknown().optional(), + $ref: z.string().optional(), + rls: FieldRLSSchema.optional(), + required: z.array(z.string()).optional(), + get items() { + return PropertyDefinitionSchema.optional(); + }, + get properties() { + return z.record(z.string(), PropertyDefinitionSchema).optional(); + }, +}); + +export const EntitySchema = z.object({ + type: z.literal("object"), + name: z + .string() + .regex(/^[a-zA-Z0-9]+$/, "Entity name must be alphanumeric only"), + title: z.string().optional(), + description: z.string().optional(), + properties: z.record(z.string(), PropertyDefinitionSchema), + required: z.array(z.string()).optional(), + rls: EntityRLSSchema.optional(), }); export type Entity = z.infer; diff --git a/tests/fixtures/full-project/entities/task.json b/tests/fixtures/full-project/entities/task.json index 828e4e51f..5a33687a5 100644 --- a/tests/fixtures/full-project/entities/task.json +++ b/tests/fixtures/full-project/entities/task.json @@ -1,7 +1,8 @@ { "name": "Task", - "fields": [ - { "name": "title", "type": "string" }, - { "name": "description", "type": "string" } - ] + "type": "object", + "properties": { + "title": { "type": "string" }, + "description": { "type": "string" } + } } From b02d5aebc9618dc212375bda513ef582108da47f Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Thu, 29 Jan 2026 15:09:10 +0200 Subject: [PATCH 2/5] refactor: Restrict agent schema according to backend --- src/core/resources/agent/schema.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/core/resources/agent/schema.ts b/src/core/resources/agent/schema.ts index 999f879f5..864d2707d 100644 --- a/src/core/resources/agent/schema.ts +++ b/src/core/resources/agent/schema.ts @@ -1,7 +1,36 @@ import { z } from "zod"; +const EntityOperationSchema = z.enum(["create", "update", "delete", "read"]); + +const EntityToolConfigSchema = z.object({ + entity_name: z.string().min(1), + allowed_operations: z.array(EntityOperationSchema), +}); + +const BackendFunctionToolConfigSchema = z.object({ + function_name: z.string().min(1), + description: z.string().default("agent backend function"), +}); + +const ToolConfigSchema = z.union([ + EntityToolConfigSchema, + BackendFunctionToolConfigSchema, +]); + +const ModelSchema = z + .string() + .regex( + /^(default|(anthropic|gemini|openai|vertex_ai)\/.+)$/, + 'Model must be "default" or start with: anthropic/, gemini/, openai/, vertex_ai/' + ); + export const AgentConfigSchema = z.looseObject({ name: z.string().regex(/^[a-z0-9_]+$/, "Agent name must be lowercase alphanumeric with underscores").min(1).max(100), + description: z.string().trim().min(1, "Description is required"), + instructions: z.string().trim().min(1, "Instructions are required"), + tool_configs: z.array(ToolConfigSchema).optional().default([]), + whatsapp_greeting: z.string().nullable().optional().default(null), + model: ModelSchema.optional().default("default"), }); export type AgentConfig = z.infer; From 8d2b0470484f930f0fbc9e088b92bdb65046023a Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Thu, 29 Jan 2026 15:09:19 +0200 Subject: [PATCH 3/5] refactor: Restrict function schema according to backend --- src/core/resources/function/schema.ts | 33 +++++++++++++++++++-------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/core/resources/function/schema.ts b/src/core/resources/function/schema.ts index a1640c811..b83865980 100644 --- a/src/core/resources/function/schema.ts +++ b/src/core/resources/function/schema.ts @@ -1,17 +1,30 @@ import { z } from "zod"; +const FunctionNameSchema = z + .string() + .trim() + .min(1, "Function name cannot be empty") + .regex(/^[^.]+$/, "Function name cannot contain dots"); + +const FunctionFileSchema = z.object({ + path: z.string().min(1), + content: z.string(), +}); + export const FunctionConfigSchema = z.object({ - name: z - .string() - .min(1, "Function name cannot be empty") - .refine((name) => !name.includes("."), "Function name cannot contain dots"), + name: FunctionNameSchema, entry: z.string().min(1, "Entry point cannot be empty"), - triggers: z.tuple([]).optional(), }); export const FunctionSchema = FunctionConfigSchema.extend({ entryPath: z.string().min(1, "Entry path cannot be empty"), - files: z.array(z.string()).min(1, "Files array cannot be empty"), + files: z.array(z.string()).min(1, "Function must have at least one file"), +}); + +export const FunctionDeploySchema = z.object({ + name: FunctionNameSchema, + entry: z.string().min(1), + files: z.array(FunctionFileSchema).min(1, "Function must have at least one file"), }); export const DeployFunctionsResponseSchema = z.object({ @@ -24,11 +37,11 @@ export const DeployFunctionsResponseSchema = z.object({ export type FunctionConfig = z.infer; export type Function = z.infer; -export type FunctionFile = { path: string; content: string }; +export type FunctionFile = z.infer; +export type FunctionDeploy = z.infer; +export type DeployFunctionsResponse = z.infer; + export type FunctionWithCode = Omit & { files: FunctionFile[]; }; -export type DeployFunctionsResponse = z.infer< - typeof DeployFunctionsResponseSchema ->; From 470ef076836dab065c34319171312a552b8d6486 Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Thu, 29 Jan 2026 15:22:25 +0200 Subject: [PATCH 4/5] remove model as it's currently ignored on the server --- src/core/resources/agent/schema.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/core/resources/agent/schema.ts b/src/core/resources/agent/schema.ts index 864d2707d..69993c79d 100644 --- a/src/core/resources/agent/schema.ts +++ b/src/core/resources/agent/schema.ts @@ -17,20 +17,12 @@ const ToolConfigSchema = z.union([ BackendFunctionToolConfigSchema, ]); -const ModelSchema = z - .string() - .regex( - /^(default|(anthropic|gemini|openai|vertex_ai)\/.+)$/, - 'Model must be "default" or start with: anthropic/, gemini/, openai/, vertex_ai/' - ); - export const AgentConfigSchema = z.looseObject({ name: z.string().regex(/^[a-z0-9_]+$/, "Agent name must be lowercase alphanumeric with underscores").min(1).max(100), description: z.string().trim().min(1, "Description is required"), instructions: z.string().trim().min(1, "Instructions are required"), tool_configs: z.array(ToolConfigSchema).optional().default([]), - whatsapp_greeting: z.string().nullable().optional().default(null), - model: ModelSchema.optional().default("default"), + whatsapp_greeting: z.string().nullable().optional(), }); export type AgentConfig = z.infer; From e3586f9839bb3a2c2975cab637e1615ed73c1f67 Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Thu, 29 Jan 2026 15:51:08 +0200 Subject: [PATCH 5/5] mention resource path for Zod errors --- src/core/resources/agent/config.ts | 10 +++++++++- src/core/resources/entity/config.ts | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/core/resources/agent/config.ts b/src/core/resources/agent/config.ts index 3c4a30651..abb01f88c 100644 --- a/src/core/resources/agent/config.ts +++ b/src/core/resources/agent/config.ts @@ -26,7 +26,15 @@ export function generateAgentConfigContent(name: string): string { async function readAgentFile(agentPath: string): Promise { const parsed = await readJsonFile(agentPath); - return AgentConfigSchema.parse(parsed); + const result = AgentConfigSchema.safeParse(parsed); + + if (!result.success) { + throw new Error( + `Invalid agent config in ${agentPath}: ${result.error.message}` + ); + } + + return result.data; } export async function readAllAgents(agentsDir: string): Promise { diff --git a/src/core/resources/entity/config.ts b/src/core/resources/entity/config.ts index 836a78521..eacf7213f 100644 --- a/src/core/resources/entity/config.ts +++ b/src/core/resources/entity/config.ts @@ -6,7 +6,15 @@ import { CONFIG_FILE_EXTENSION_GLOB } from "@/core/consts.js"; async function readEntityFile(entityPath: string): Promise { const parsed = await readJsonFile(entityPath); - return EntitySchema.parse(parsed); + const result = EntitySchema.safeParse(parsed); + + if (!result.success) { + throw new Error( + `Invalid entity in ${entityPath}: ${result.error.message}` + ); + } + + return result.data; } export async function readAllEntities(entitiesDir: string): Promise {