import { describe, expect, it } from "vitest"; import { z } from "zod/v4"; import { defineApp, defineTool } from "../define/define"; import { colineAppManifestV2Schema } from "../manifest/manifest"; import { compareAppSecurityContract } from "../manifest/compare"; import { createTestWorkspace } from "./test-workspace"; type Entry = { text: string }; const sign = defineTool({ name: "guestbook.sign", description: "Sign the guestbook.", input: z.object({ text: z.string().min(1).max(500) }), effect: "write", execute: async ({ text }, { coline, actor }) => { const entry = await coline.storage.collection("entries").insert({ text }); await coline.storage.collection<{ who: string }>("signups").insert({ who: actor.kind === "visitor" ? actor.visitorId : "member", }); return { output: { id: entry.id } }; }, }); const wipe = defineTool({ name: "guestbook.wipe", description: "Delete every entry.", input: z.object({}), effect: "destructive", execute: async (_input, { coline }) => { const { count } = await coline.storage.collection("entries").deleteMany({ where: {} }); return { output: { count } }; }, }); const app = defineApp({ key: "guestbook", name: "Guestbook", permissions: ["site.serve", "storage.app", "ai.tools"], hosting: { default: "coline" }, surfaces: { site: { entry: "site/main.tsx", tools: ["guestbook.sign", "guestbook.wipe"] } }, collections: { entries: { access: { read: "public", create: "visitor", update: "owner", delete: "owner" } }, signups: {}, }, tools: [sign, wipe], }); describe("site manifest", () => { it("defaults routes, sign-in and lang", () => { expect(app.manifest.surfaces.site?.routes).toEqual([{ path: "/" }]); expect(app.manifest.surfaces.site?.visitors.signIn).toBe(false); expect(app.manifest.surfaces.site?.lang).toBe("en"); }); it("requires site.serve for a site", () => { const result = colineAppManifestV2Schema.safeParse({ key: "nosite", name: "No site", surfaces: { site: { entry: "main.tsx" } }, }); expect(result.success).toBe(false); expect(JSON.stringify(result.error?.issues)).toContain("site.serve"); }); it("rejects collections open to visitors without a site", () => { const result = colineAppManifestV2Schema.safeParse({ key: "open", name: "Open", permissions: ["storage.app"], collections: { notes: { access: { read: "public" } } }, }); expect(result.success).toBe(false); expect(JSON.stringify(result.error?.issues)).toContain("need a site surface"); }); it("rejects owner as a create rule", () => { const result = colineAppManifestV2Schema.safeParse({ key: "owner-create", name: "Owner create", permissions: ["site.serve", "storage.app"], surfaces: { site: { entry: "main.tsx" } }, collections: { notes: { access: { create: "owner" } } }, }); expect(result.success).toBe(false); }); it("rejects malformed route paths", () => { const result = colineAppManifestV2Schema.safeParse({ key: "routes", name: "Routes", permissions: ["site.serve"], surfaces: { site: { entry: "main.tsx", routes: [{ path: "no-slash" }] } }, }); expect(result.success).toBe(false); }); it("asks for reconsent when a site or visitor access is added", () => { const before = colineAppManifestV2Schema.parse({ key: "guestbook", name: "Guestbook" }); const delta = compareAppSecurityContract(before, app.manifest); expect(delta.requiresReconsent).toBe(true); expect(delta.riskFlags).toContain("gains_site_surface"); expect(delta.siteAccessOpened).toEqual(["entries.create", "entries.delete", "entries.read", "entries.update"]); }); it("asks for reconsent when a rule widens, not only when it leaves team", () => { const withRead = (read: "owner" | "signedIn" | "public") => colineAppManifestV2Schema.parse({ ...app.manifest, collections: { ...app.manifest.collections, entries: { access: { ...app.manifest.collections.entries!.access, read } } }, }); const widened = compareAppSecurityContract(withRead("owner"), withRead("public")); expect(widened.requiresReconsent).toBe(true); expect(widened.siteAccessOpened).toEqual(["entries.read"]); expect(compareAppSecurityContract(withRead("public"), withRead("signedIn")).siteAccessOpened).toEqual([]); // Owners include guests, so this lets guests in. expect(compareAppSecurityContract(withRead("signedIn"), withRead("owner")).siteAccessOpened).toEqual(["entries.read"]); }); it("still reads versions stored with 0.6 public pages", () => { const parsed = colineAppManifestV2Schema.safeParse({ key: "legacy", name: "Legacy", permissions: ["public.pages", "storage.app"], surfaces: { public: { entry: "public.tsx" } }, hosting: { default: "coline", overrides: { public: "coline" } }, }); expect(parsed.success).toBe(true); expect(parsed.data?.permissions).toEqual(["storage.app"]); expect(parsed.data?.surfaces).not.toHaveProperty("public"); }); }); describe("site visitors", () => { it("needs ai.tools for site tools and storage.app for collections, like Coline", async () => { const workspace = createTestWorkspace(app, { grantedPermissions: ["site.serve"] }); await expect(workspace.invokeSiteTool("guestbook.sign", { text: "hi" })).rejects.toMatchObject({ code: "permission_not_granted", }); expect(() => workspace.asVisitor().collection("entries")).toThrow(/storage\.app/); }); it("lets a site tool write team-only collections and attributes records to the visitor", async () => { const workspace = createTestWorkspace(app); const { output } = await workspace.invokeSiteTool("guestbook.sign", { text: "hello" }, { visitorId: "vis_a" }); const id = (output as { id: string }).id; const mine = workspace.asVisitor({ visitorId: "vis_a" }).collection("entries"); expect((await mine.get(id))?.createdBy).toMatchObject({ kind: "visitor", visitorId: "vis_a" }); await expect(mine.update(id, { text: "edited" })).resolves.toMatchObject({ data: { text: "edited" } }); const signups = workspace.asVisitor({ visitorId: "vis_a" }).collection("signups"); await expect(signups.query()).resolves.toEqual({ records: [], nextCursor: null }); }); it("never shows another visitor's name or email on a public record", async () => { const workspace = createTestWorkspace(app); const author = workspace .asVisitor({ visitorId: "vis_a", signedIn: true, name: "Ada", email: "ada@example.test" }) .collection("entries"); await author.insert({ text: "hi" }); const [record] = (await workspace.asVisitor({ visitorId: "vis_b" }).collection("entries").query()).records; expect(record?.createdBy).toEqual({ kind: "visitor", visitorId: "vis_a", signedIn: false, name: null, email: null }); }); it("enforces owner rules between visitors", async () => { const workspace = createTestWorkspace(app); const a = workspace.asVisitor({ visitorId: "vis_a" }).collection("entries"); const b = workspace.asVisitor({ visitorId: "vis_b" }).collection("entries"); const entry = await a.insert({ text: "from a" }); expect((await b.query()).records).toHaveLength(1); await expect(b.update(entry.id, { text: "hijack" })).rejects.toMatchObject({ code: "record_forbidden" }); await expect(b.delete(entry.id)).rejects.toMatchObject({ code: "record_forbidden" }); await expect(a.delete(entry.id)).resolves.toBeUndefined(); }); it("keeps undeclared collections and bulk operations away from visitors", async () => { const workspace = createTestWorkspace(app); const visitor = workspace.asVisitor(); await expect(visitor.collection("signups").insert({ who: "x" })).rejects.toMatchObject({ code: "record_forbidden", }); await expect(visitor.collection("entries").deleteMany({ where: {} })).rejects.toMatchObject({ code: "unavailable_to_visitors", }); await expect(visitor.collection("entries").insert({ text: "x" }, { access: { workspace: "none", readers: [], writers: [] } })).rejects.toMatchObject({ code: "unavailable_to_visitors", }); }); it("runs a destructive site tool at the write ceiling", async () => { const workspace = createTestWorkspace(app); await workspace.invokeSiteTool("guestbook.sign", { text: "keep me" }); await expect(workspace.invokeSiteTool("guestbook.wipe", {})).rejects.toMatchObject({ code: "effect_ceiling_exceeded", }); }); it("refuses tools the site does not list", async () => { const unlisted = defineApp({ key: "guestbook", name: "Guestbook", permissions: ["site.serve", "storage.app", "ai.tools"], hosting: { default: "coline" }, surfaces: { site: { entry: "site/main.tsx" } }, tools: [sign, wipe], }); const workspace = createTestWorkspace(unlisted); await expect(workspace.invokeSiteTool("guestbook.sign", { text: "x" })).rejects.toMatchObject({ code: "site_tool_not_allowed", }); }); });