Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/core/resources/agent/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,15 @@ export function generateAgentConfigContent(name: string): string {

async function readAgentFile(agentPath: string): Promise<AgentConfig> {
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<AgentConfig[]> {
Expand Down
21 changes: 21 additions & 0 deletions src/core/resources/agent/schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,28 @@
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,
]);

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(),
});

export type AgentConfig = z.infer<typeof AgentConfigSchema>;
Expand Down
10 changes: 9 additions & 1 deletion src/core/resources/entity/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@ import { CONFIG_FILE_EXTENSION_GLOB } from "@/core/consts.js";

async function readEntityFile(entityPath: string): Promise<Entity> {
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<Entity[]> {
Expand Down
157 changes: 155 additions & 2 deletions src/core/resources/entity/schema.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets remove some of the huge commends, and also we don't need to export everything since it's not used progrematically.

Original file line number Diff line number Diff line change
@@ -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<z.ZodArray<typeof RLSConditionSchema>> {
return z.array(RefineRLSConditionSchema).optional();
},
get $and(): z.ZodOptional<z.ZodArray<typeof RLSConditionSchema>> {
return z.array(RefineRLSConditionSchema).optional();
},
get $nor(): z.ZodOptional<z.ZodArray<typeof RLSConditionSchema>> {
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({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is verified against the apper codebase?
I remember only read and write being supported in FLS, might be wrong

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<typeof EntitySchema>;
Expand Down
33 changes: 23 additions & 10 deletions src/core/resources/function/schema.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -24,11 +37,11 @@ export const DeployFunctionsResponseSchema = z.object({

export type FunctionConfig = z.infer<typeof FunctionConfigSchema>;
export type Function = z.infer<typeof FunctionSchema>;
export type FunctionFile = { path: string; content: string };
export type FunctionFile = z.infer<typeof FunctionFileSchema>;
export type FunctionDeploy = z.infer<typeof FunctionDeploySchema>;
export type DeployFunctionsResponse = z.infer<typeof DeployFunctionsResponseSchema>;

export type FunctionWithCode = Omit<Function, "files"> & {
files: FunctionFile[];
};
export type DeployFunctionsResponse = z.infer<
typeof DeployFunctionsResponseSchema
>;

9 changes: 5 additions & 4 deletions tests/fixtures/full-project/entities/task.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"name": "Task",
"fields": [
{ "name": "title", "type": "string" },
{ "name": "description", "type": "string" }
]
"type": "object",
"properties": {
"title": { "type": "string" },
"description": { "type": "string" }
}
}