import { z } from "zod/v4"; /** * Collection declarations (SDK 2.1, Coline Sites). * * Collections are still created on first use. Declaring one is only needed * to open it to site visitors: `access` says which visitors may read, * create, update and delete its records. A collection that is not declared, * or declared without `access`, is invisible to visitors. * * Members and the app's own logic never read these rules. They keep the * per-record access model (`ColineRecordAccess`). * * Subjects: * - `public` anyone who opens the site * - `visitor` any guest or signed-in visitor (the same as `public` for * writes, because a write always creates a guest session) * - `signedIn` visitors who signed in with Coline * - `owner` the visitor who created the record * - `team` nobody on the site; workspace members and app logic only */ export const COLLECTION_ACCESS_SUBJECTS = ["public", "visitor", "signedIn", "owner", "team"] as const; export const collectionAccessSubjectSchema = z.enum(COLLECTION_ACCESS_SUBJECTS); export type CollectionAccessSubject = z.infer; export const collectionAccessSchema = z .strictObject({ read: collectionAccessSubjectSchema.default("team"), create: collectionAccessSubjectSchema.default("team"), update: collectionAccessSubjectSchema.default("team"), delete: collectionAccessSubjectSchema.default("team"), }) .superRefine((access, ctx) => { if (access.create === "owner") { ctx.addIssue({ code: "custom", path: ["create"], message: 'A record has no owner before it exists. Use "visitor" or "signedIn" for create.', }); } }); export type CollectionAccess = z.infer; export type CollectionAccessInput = z.input; export const appCollectionNameSchema = z .string() .trim() .regex(/^[a-z][a-z0-9_-]*$/, "Collection names are lowercase identifiers.") .max(120); export const appCollectionDeclarationSchema = z.strictObject({ description: z.string().trim().max(500).optional(), access: collectionAccessSchema.optional(), /** * JSON Schema for each record's data, generated from the collection's zod * `schema` in app.config.ts. Coline checks every visitor write against * it; the app's own writes are checked by TypeScript. */ schema: z .record(z.string(), z.unknown()) .refine((schema) => JSON.stringify(schema).length <= 20_000, "Collection schemas are limited to 20 kB.") .optional(), }); export type AppCollectionDeclaration = z.infer; export const appCollectionsSchema = z .record(appCollectionNameSchema, appCollectionDeclarationSchema) .default({}) .refine((collections) => Object.keys(collections).length <= 50, "Declare at most 50 collections."); export type AppCollections = z.infer; export type CollectionAction = "read" | "create" | "update" | "delete"; /** The visitor rule for one action on one collection; `team` when undeclared. */ export function collectionRuleFor( collections: AppCollections, collection: string, action: CollectionAction, ): CollectionAccessSubject { return collections[collection]?.access?.[action] ?? "team"; } /** Collections that any visitor action can reach, for consent and review copy. */ export function visitorReachableCollections(collections: AppCollections): string[] { return Object.entries(collections) .filter(([, declaration]) => { const access = declaration.access; if (!access) return false; return [access.read, access.create, access.update, access.delete].some((subject) => subject !== "team"); }) .map(([name]) => name) .sort(); }