import { z } from "zod/v4"; /** * Surface declarations (plan §7): where an app renders and with which tier. * * - `tree` — tier 1: app logic returns a declarative UI tree; the host * renders it with its own components on every platform. * - `react` — tier 2: a Coline-built React bundle runs in the sandboxed * client runtime. `entry` is the module path within the app * source. * - `web` — tier 3: an iframe served from the app's external origin. * Requires external hosting; excluded from mobile and preview * composition. */ const surfaceEntrySchema = z.string().trim().min(1).max(500); const surfacePathSchema = z .string() .trim() .min(1) .max(2000) .startsWith("/", "Web surface paths must be origin-relative (start with /)."); export const treeSurfaceSchema = z.object({ tier: z.literal("tree"), }); export const reactSurfaceSchema = z.object({ tier: z.literal("react"), entry: surfaceEntrySchema, }); export const webSurfaceSchema = z.object({ tier: z.literal("web"), path: surfacePathSchema, }); export const surfaceVariantSchema = z.discriminatedUnion("tier", [ treeSurfaceSchema, reactSurfaceSchema, webSurfaceSchema, ]); export type SurfaceVariant = z.infer; export type SurfaceTier = SurfaceVariant["tier"]; /** * A surface with an optional mobile override (plan §13): universal by * default; specialize only when the interaction genuinely differs. */ export const appSurfaceSchema = z.discriminatedUnion("tier", [ treeSurfaceSchema.extend({ mobile: surfaceVariantSchema.optional() }), reactSurfaceSchema.extend({ mobile: surfaceVariantSchema.optional() }), webSurfaceSchema.extend({ mobile: surfaceVariantSchema.optional() }), ]); export type AppSurface = z.infer; /** * The site surface (SDK 2.1, Coline Sites): the app served as a website on * its own origin, `{slug}.coline.space` or a custom domain. * * The page is a client-rendered React bundle built from `entry`. Coline * serves an HTML shell per route with the route's `head`, the bundle as a * content-hashed script, and the files under `assets` at the site root. * * Visitors are a principal of their own, never members and never the app. * They reach data only through collections whose `access` opens them (see * `collections.ts`), and logic only through the tools listed in `tools`, * which run at most at the `write` effect. */ const SITE_ROUTE_SEGMENT = String.raw`(?:[A-Za-z0-9._~-]+|:[a-z][A-Za-z0-9_]*)`; export const siteRoutePathSchema = z .string() .trim() .max(300) .regex( new RegExp(`^/(?:${SITE_ROUTE_SEGMENT}(?:/${SITE_ROUTE_SEGMENT})*)?(?:/\\*)?$|^/\\*$`), 'Route paths start with "/", use plain segments or ":params", and may end with "/*".', ); const siteImageSchema = z .string() .trim() .max(2000) .refine( (value) => value.startsWith("/") || value.startsWith("https://"), "Images are a path under the site's assets or an https URL.", ); export const siteHeadSchema = z.strictObject({ title: z.string().trim().min(1).max(120).optional(), description: z.string().trim().max(500).optional(), /** Link preview image. */ image: siteImageSchema.optional(), }); export type SiteHead = z.infer; export const siteRouteSchema = z.strictObject({ path: siteRoutePathSchema, head: siteHeadSchema.optional(), }); export type SiteRoute = z.infer; /** How visitors sign in. Google needs no Coline account; Coline accounts can offer Google, Microsoft and email. */ export const SITE_SIGN_IN_PROVIDERS = ["google", "coline"] as const; export type SiteSignInProvider = (typeof SITE_SIGN_IN_PROVIDERS)[number]; /** * Who may use the site's data and tools: * - `open`: everyone, guests included (the default); * - `signedIn`: signed-in visitors only; guests can load pages and sign in; * - `approved`: signed-in visitors the team approved in the console. */ export const SITE_VISITOR_ACCESS = ["open", "signedIn", "approved"] as const; export type SiteVisitorAccess = (typeof SITE_VISITOR_ACCESS)[number]; const MB = 1024 * 1024; const mimePatternSchema = z .string() .trim() .toLowerCase() .regex(/^(?:[a-z0-9][a-z0-9.+-]*\/(?:\*|[a-z0-9][a-z0-9.+-]*))$/, 'Types are MIME types like "image/png" or "image/*".'); export const siteUploadsSchema = z.strictObject({ /** Who may upload. Sites deployed without an account never accept uploads. */ who: z.enum(["visitor", "signedIn"]).default("signedIn"), /** Largest file, in bytes; the plan caps it (10 MB free, 25 MB paid). */ maxFileBytes: z.number().int().min(1024).max(25 * MB).default(10 * MB), /** Accepted types. Scripts, HTML and executables are always refused. */ types: z.array(mimePatternSchema).min(1).max(30).default(["image/*", "application/pdf", "text/plain"]), }); export type SiteUploads = z.infer; /** An app endpoint served at a path on the site's own address. */ export const siteEndpointRouteSchema = z.strictObject({ path: siteRoutePathSchema.refine((path) => !path.startsWith("/_coline"), "Paths under /_coline are reserved."), /** The `endpoints[].id` that handles it. */ endpoint: z.string().trim().min(1).max(80), }); export type SiteEndpointRoute = z.infer; export const siteSurfaceSchema = z .strictObject({ /** Module path of the site's React entry, relative to the app root. */ entry: surfaceEntrySchema, /** Directory of static files served at the site root (favicon, images, fonts). */ assets: surfaceEntrySchema.optional(), /** Site name and default head for routes that set none. */ title: z.string().trim().min(1).max(120).optional(), description: z.string().trim().max(500).optional(), image: siteImageSchema.optional(), /** Page language, for the `lang` attribute. */ lang: z.string().trim().regex(/^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/).default("en"), routes: z.array(siteRouteSchema).min(1).max(200).default([{ path: "/" }]), visitors: z .strictObject({ /** Offer sign-in. Off by default: everyone is a guest. */ signIn: z.boolean().default(false), /** Which sign-in options to offer, in order. */ providers: z .array(z.enum(SITE_SIGN_IN_PROVIDERS)) .min(1) .max(SITE_SIGN_IN_PROVIDERS.length) .default(["google", "coline"]) .refine((providers) => new Set(providers).size === providers.length, "List each provider once."), access: z.enum(SITE_VISITOR_ACCESS).default("open"), /** * A tool to run, as the app, after a guest signs in, with input * `{ visitorId, previousVisitorId }`. Records the guest created have * already moved to the signed-in visitor; use this to merge anything else. */ onSignIn: z.string().trim().min(1).max(160).optional(), }) .default({ signIn: false, providers: ["google", "coline"], access: "open" }), /** Tools visitors may run. Each runs at most at the `write` effect. */ tools: z.array(z.string().trim().min(1).max(160)).max(20).default([]), /** Let visitors upload files. Off unless declared. */ uploads: siteUploadsSchema.optional(), /** App endpoints served on the site's own address, such as `/api/summary`. */ endpoints: z.array(siteEndpointRouteSchema).max(20).default([]), /** * Register the listed tools with in-browser agents (WebMCP), so an agent * in the visitor's tab can call them as the visitor. */ agentTools: z.boolean().default(false), }) .refine( (site) => new Set(site.routes.map((route) => route.path)).size === site.routes.length, "Route paths must be unique.", ) .refine( (site) => new Set(site.endpoints.map((route) => route.path)).size === site.endpoints.length, "Endpoint paths must be unique.", ) .refine( (site) => site.visitors.access === "open" || site.visitors.signIn, "Requiring sign-in or approval needs visitors.signIn: true.", ); export type SiteSurface = z.infer; export type SiteSurfaceInput = z.input; export const appSurfacesSchema = z .object({ home: appSurfaceSchema.optional(), site: siteSurfaceSchema.optional(), }) .default({}); export type AppSurfaces = z.infer;