import { z } from "zod/v4"; import { appCollectionsSchema, visitorReachableCollections } from "./collections"; import { appCommandSchema } from "./command"; import { appConnectionsSchema } from "./connections"; import { appEndpointsSchema } from "./endpoints"; import { appFileTypeV2Schema } from "./file-type"; import { appHostingV2Schema, appNetworkPolicySchema } from "./hosting"; import { appJobsSchema } from "./jobs"; import { appNotificationsV2Schema } from "./notifications"; import { appPermissionV2ListSchema } from "./permissions"; import { appSurfacesSchema } from "./surfaces"; import { appToolMetadataSchema } from "./tool"; import { appTriggersV2Schema, isPlatformEventNamespace, PLATFORM_EVENT_TYPES, eventPatternMatches, platformEventPermission, } from "./triggers"; function hostCoveredByAllowlist(host: string, allowedHosts: readonly string[]): boolean { return allowedHosts.some((allowed) => { if (allowed === host) return true; if (!allowed.startsWith("*.")) return false; const suffix = allowed.slice(1); const candidate = host.startsWith("*.") ? host.slice(1) : host; return candidate.endsWith(suffix) && candidate.length >= suffix.length; }); } /** * 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."); /** * SDK 2.1 retired 0.6 public pages. Versions stored before then still carry * `public.pages`, `surfaces.public` and a `public` hosting override; drop * them so the rest of those apps (home, tools, jobs) keeps working. */ function withoutRetiredPublicPages(input: unknown): unknown { if (!isPlainObject(input)) return input; const next: Record = { ...input }; if (Array.isArray(next.permissions)) { next.permissions = next.permissions.filter((permission) => permission !== "public.pages"); } if (isPlainObject(next.surfaces) && "public" in next.surfaces) { const { public: _retired, ...surfaces } = next.surfaces; next.surfaces = surfaces; } if (isPlainObject(next.hosting) && isPlainObject(next.hosting.overrides) && "public" in next.hosting.overrides) { const { public: _retired, ...overrides } = next.hosting.overrides; next.hosting = { ...next.hosting, overrides }; } return next; } function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } const manifestObjectSchema = 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, endpoints: appEndpointsSchema, jobs: appJobsSchema, connections: appConnectionsSchema, collections: appCollectionsSchema, }) .superRefine((manifest, ctx) => { const prefix = `${manifest.key}.`; const requirePermission = ( permission: (typeof manifest.permissions)[number], reason: string, ) => { if (!manifest.permissions.includes(permission)) { ctx.addIssue({ code: "custom", path: ["permissions"], message: `${reason} requires the "${permission}" permission.`, }); } }; if (manifest.endpoints.length > 0) requirePermission("endpoints.receive", "Declaring endpoints"); if (manifest.jobs.length > 0) requirePermission("jobs.run", "Declaring background jobs"); if (manifest.connections.length > 0) { requirePermission("connections.use", "Declaring account connections"); requirePermission("network.external", "Declaring account connections"); } for (const [index, connection] of manifest.connections.entries()) { for (const host of connection.hosts) { if (!hostCoveredByAllowlist(host, manifest.network.allowedHosts)) { ctx.addIssue({ code: "custom", path: ["connections", index, "hosts"], message: `Connection host "${host}" must also be listed in network.allowedHosts.`, }); } } } const siteSurface = manifest.surfaces.site; if (siteSurface) { requirePermission("site.serve", "A site surface"); const declaredTools = new Set(manifest.tools.map((tool) => tool.name)); for (const [index, tool] of siteSurface.tools.entries()) { if (!declaredTools.has(tool)) { ctx.addIssue({ code: "custom", path: ["surfaces", "site", "tools", index], message: `The site lists undeclared tool "${tool}".`, }); } } const onSignIn = siteSurface.visitors.onSignIn; if (onSignIn && !declaredTools.has(onSignIn)) { ctx.addIssue({ code: "custom", path: ["surfaces", "site", "visitors", "onSignIn"], message: `onSignIn names undeclared tool "${onSignIn}".`, }); } if (siteSurface.uploads) requirePermission("storage.app", "Accepting uploads"); const endpointIds = new Set(manifest.endpoints.map((endpoint) => endpoint.id)); for (const [index, route] of siteSurface.endpoints.entries()) { if (!endpointIds.has(route.endpoint)) { ctx.addIssue({ code: "custom", path: ["surfaces", "site", "endpoints", index, "endpoint"], message: `The site routes ${route.path} to undeclared endpoint "${route.endpoint}".`, }); } } } const openCollections = visitorReachableCollections(manifest.collections); if (openCollections.length > 0) { requirePermission("storage.app", "Opening collections to visitors"); if (!siteSurface) { ctx.addIssue({ code: "custom", path: ["collections"], message: `Collections open to visitors (${openCollections.join(", ")}) need a site surface.`, }); } } for (const [index, subscription] of manifest.triggers.webhooks.entries()) { const pattern = subscription.event; if (!isPlatformEventNamespace(pattern)) continue; const matches = PLATFORM_EVENT_TYPES.filter((type) => eventPatternMatches(pattern, type)); if (matches.length === 0) { ctx.addIssue({ code: "custom", path: ["triggers", "webhooks", index, "event"], message: `"${pattern}" is not a Coline event. Known events: ${PLATFORM_EVENT_TYPES.join(", ")}.`, }); continue; } for (const type of matches) { const permission = platformEventPermission(type); if (permission && !manifest.permissions.includes(permission)) { ctx.addIssue({ code: "custom", path: ["triggers", "webhooks", index, "event"], message: `Receiving "${type}" requires the "${permission}" permission.`, }); break; } } } 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", "events", ...toolNames, ...fileTypeKeys, ...manifest.endpoints.map((endpoint) => `endpoint:${endpoint.id}`), ...manifest.jobs.map((job) => `job:${job.name}`), ]); 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, file type, `endpoint:`, `job:`, "home", or "events".', }); } } 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.`, }); } }); export const colineAppManifestV2Schema = z.preprocess(withoutRetiredPublicPages, manifestObjectSchema); 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 >;