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; 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 with Coline. Off by default: everyone is a guest. */ signIn: z.boolean().default(false), }) .default({ signIn: false }), /** 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([]), }) .refine( (site) => new Set(site.routes.map((route) => route.path)).size === site.routes.length, "Route paths must be unique.", ); 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;