import { z } from "zod/v4"; import { appCommandSchema } from "./command"; import { appFileTypeV2Schema } from "./file-type"; import { appHostingV2Schema, appNetworkPolicySchema } from "./hosting"; import { appNotificationsV2Schema } from "./notifications"; import { appPermissionV2ListSchema } from "./permissions"; import { appSurfacesSchema } from "./surfaces"; import { appToolMetadataSchema } from "./tool"; import { appTriggersV2Schema } from "./triggers"; /** * The Coline App manifest, v2 (plan §6). * * The manifest is the serializable artifact of an app definition: it tells * Coline everything the app contributes without executing app code. It is * produced by the extraction step (`define/extraction.ts`), re-verified at * publish time, and stored on `app_versions`. */ export const appKeySchema = z .string() .trim() .regex(/^[a-z][a-z0-9-]{1,63}$/, "App keys are lowercase kebab-case, 2-64 chars."); export const colineAppManifestV2Schema = z .strictObject({ key: appKeySchema, name: z.string().trim().min(1).max(120), description: z.string().trim().max(1000).optional(), iconUrl: z.url().max(2000).optional(), icon: z.string().trim().max(120).optional(), permissions: appPermissionV2ListSchema, surfaces: appSurfacesSchema, files: z .array(appFileTypeV2Schema) .max(50) .default([]) .refine( (files) => new Set(files.map((file) => file.typeKey)).size === files.length, "File type keys must be unique.", ), tools: z .array(appToolMetadataSchema) .max(100) .default([]) .refine( (tools) => new Set(tools.map((tool) => tool.name)).size === tools.length, "Tool names must be unique.", ), commands: z .array(appCommandSchema) .max(50) .default([]) .refine( (commands) => new Set(commands.map((command) => command.id)).size === commands.length, "Command ids must be unique.", ), notifications: appNotificationsV2Schema, hosting: appHostingV2Schema, network: appNetworkPolicySchema, kairo: z .strictObject({ enabled: z.boolean().default(true), description: z.string().trim().min(1).max(500).optional(), instructions: z.string().trim().min(1).max(4000).optional(), }) .optional(), triggers: appTriggersV2Schema, }) .superRefine((manifest, ctx) => { const prefix = `${manifest.key}.`; for (const [index, tool] of manifest.tools.entries()) { if (!tool.name.startsWith(prefix)) { ctx.addIssue({ code: "custom", path: ["tools", index, "name"], message: `Tool names must be namespaced under the app key ("${prefix}…").`, }); } } for (const [index, file] of manifest.files.entries()) { if (!file.typeKey.startsWith(prefix)) { ctx.addIssue({ code: "custom", path: ["files", index, "typeKey"], message: `File type keys must be namespaced under the app key ("${prefix}…").`, }); } } const toolNames = new Set(manifest.tools.map((tool) => tool.name)); for (const [index, command] of manifest.commands.entries()) { if (command.target.kind === "tool" && !toolNames.has(command.target.tool)) { ctx.addIssue({ code: "custom", path: ["commands", index, "target", "tool"], message: `Command targets undeclared tool "${command.target.tool}".`, }); } } if (manifest.tools.length > 0 && !manifest.permissions.includes("ai.tools")) { ctx.addIssue({ code: "custom", path: ["permissions"], message: 'Declaring tools requires the "ai.tools" permission.', }); } if (manifest.commands.length > 0 && !manifest.permissions.includes("commands.register")) { ctx.addIssue({ code: "custom", path: ["permissions"], message: 'Declaring commands requires the "commands.register" permission.', }); } if ( manifest.triggers.webhooks.length > 0 && !manifest.permissions.includes("webhooks.subscribe") ) { ctx.addIssue({ code: "custom", path: ["permissions"], message: 'Declaring webhook triggers requires the "webhooks.subscribe" permission.', }); } if (manifest.notifications.channels.length > 0 && !manifest.permissions.includes("notifications.write")) { ctx.addIssue({ code: "custom", path: ["permissions"], message: 'Declaring notification channels requires the "notifications.write" permission.', }); } const fileTypeKeys = new Set(manifest.files.map((file) => file.typeKey)); const validOverrideKeys = new Set(["home", ...toolNames, ...fileTypeKeys]); for (const key of Object.keys(manifest.hosting.overrides)) { if (!validOverrideKeys.has(key)) { ctx.addIssue({ code: "custom", path: ["hosting", "overrides", key], message: "Hosting override keys must name a declared tool, a declared file type, or \"home\".", }); } } const webSurfaceTiers: string[] = []; if (manifest.surfaces.home?.tier === "web") webSurfaceTiers.push("surfaces.home"); for (const file of manifest.files) { if (file.surfaces.editor?.tier === "web") { webSurfaceTiers.push(`files.${file.typeKey}.editor`); } } if (webSurfaceTiers.length > 0 && !manifest.hosting.external) { ctx.addIssue({ code: "custom", path: ["hosting", "external"], message: `Web-tier surfaces (${webSurfaceTiers.join(", ")}) require external hosting.`, }); } if (manifest.triggers.webhooks.length > 0 && !manifest.hosting.external) { ctx.addIssue({ code: "custom", path: ["hosting", "external"], message: "Webhook triggers deliver to the app backend and require external hosting.", }); } }); export type ColineAppManifestV2 = z.infer; export type ColineAppManifestV2Input = z.input; /** Registration payload: what `coline apps publish` submits (plan §5.3, §6.2). */ export const colineAppRegistrationPayloadV2Schema = z.strictObject({ protocolVersion: z.literal(2), manifest: colineAppManifestV2Schema, }); export type ColineAppRegistrationPayloadV2 = z.infer< typeof colineAppRegistrationPayloadV2Schema >;