The Coline App SDK: apps, tools, file types and surfaces that run inside Coline workspaces, and public websites with Coline Sites. TypeScript, with an in-memory test workspace. MIT.
| 4 | import { colineAppManifestV2Schema } from "../manifest/manifest"; |
| 5 | import { compareAppSecurityContract } from "../manifest/compare"; |
| 6 | import { createTestWorkspace } from "./test-workspace"; |
| 7 |
| 8 | type Entry = { text: string }; |
| 9 |
| 10 | const sign = defineTool({ |
| 11 | name: "guestbook.sign", |
| 12 | description: "Sign the guestbook.", |
| 13 | input: z.object({ text: z.string().min(1).max(500) }), |
| 14 | effect: "write", |
| 15 | execute: async ({ text }, { coline, actor }) => { |
| 16 | const entry = await coline.storage.collection<Entry>("entries").insert({ text }); |
| 17 | await coline.storage.collection<{ who: string }>("signups").insert({ |
| 18 | who: actor.kind === "visitor" ? actor.visitorId : "member", |
| 19 | }); |
| 20 | return { output: { id: entry.id } }; |
| 21 | }, |
| 22 | }); |
| 23 |
| 24 | const wipe = defineTool({ |
| 25 | name: "guestbook.wipe", |
| 26 | description: "Delete every entry.", |
| 27 | input: z.object({}), |
| 28 | effect: "destructive", |
| 29 | execute: async (_input, { coline }) => { |
| 30 | const { count } = await coline.storage.collection<Entry>("entries").deleteMany({ where: {} }); |
| 31 | return { output: { count } }; |
| 32 | }, |
| 33 | }); |
| 34 |
| 35 | const app = defineApp({ |
| 36 | key: "guestbook", |
| 37 | name: "Guestbook", |
| 38 | permissions: ["site.serve", "storage.app", "ai.tools"], |
| 39 | hosting: { default: "coline" }, |
| 40 | surfaces: { site: { entry: "site/main.tsx", tools: ["guestbook.sign", "guestbook.wipe"] } }, |
| 41 | collections: { |
| 42 | entries: { access: { read: "public", create: "visitor", update: "owner", delete: "owner" } }, |
| 43 | signups: {}, |
| 44 | }, |
| 45 | tools: [sign, wipe], |
| 46 | }); |
| 47 |
| 48 | describe("site manifest", () => { |
| 49 | it("defaults routes, sign-in and lang", () => { |
| 50 | expect(app.manifest.surfaces.site?.routes).toEqual([{ path: "/" }]); |
| 51 | expect(app.manifest.surfaces.site?.visitors.signIn).toBe(false); |
| 52 | expect(app.manifest.surfaces.site?.lang).toBe("en"); |
| 53 | }); |
| 54 |
| 55 | it("requires site.serve for a site", () => { |
| 56 | const result = colineAppManifestV2Schema.safeParse({ |
| 57 | key: "nosite", |
| 58 | name: "No site", |
| 59 | surfaces: { site: { entry: "main.tsx" } }, |
| 60 | }); |
| 61 | expect(result.success).toBe(false); |
| 62 | expect(JSON.stringify(result.error?.issues)).toContain("site.serve"); |
| 63 | }); |
| 64 |
| 65 | it("rejects collections open to visitors without a site", () => { |
| 66 | const result = colineAppManifestV2Schema.safeParse({ |
| 67 | key: "open", |
| 68 | name: "Open", |
| 69 | permissions: ["storage.app"], |
| 70 | collections: { notes: { access: { read: "public" } } }, |
| 71 | }); |
| 72 | expect(result.success).toBe(false); |
| 73 | expect(JSON.stringify(result.error?.issues)).toContain("need a site surface"); |
| 74 | }); |
| 75 |
| 76 | it("rejects owner as a create rule", () => { |
| 77 | const result = colineAppManifestV2Schema.safeParse({ |
| 78 | key: "owner-create", |
| 79 | name: "Owner create", |
| 80 | permissions: ["site.serve", "storage.app"], |
| 81 | surfaces: { site: { entry: "main.tsx" } }, |
| 82 | collections: { notes: { access: { create: "owner" } } }, |
| 83 | }); |
| 84 | expect(result.success).toBe(false); |
| 85 | }); |
| 86 |
| 87 | it("rejects malformed route paths", () => { |
| 88 | const result = colineAppManifestV2Schema.safeParse({ |
| 89 | key: "routes", |
| 90 | name: "Routes", |
| 91 | permissions: ["site.serve"], |
| 92 | surfaces: { site: { entry: "main.tsx", routes: [{ path: "no-slash" }] } }, |
| 93 | }); |
| 94 | expect(result.success).toBe(false); |
| 95 | }); |
| 96 |
| 97 | it("asks for reconsent when a site or visitor access is added", () => { |
| 98 | const before = colineAppManifestV2Schema.parse({ key: "guestbook", name: "Guestbook" }); |
| 99 | const delta = compareAppSecurityContract(before, app.manifest); |
| 100 | expect(delta.requiresReconsent).toBe(true); |
| 101 | expect(delta.riskFlags).toContain("gains_site_surface"); |
| 102 | expect(delta.siteAccessOpened).toEqual(["entries.create", "entries.delete", "entries.read", "entries.update"]); |
| 103 | }); |
| 104 |
| 105 | it("asks for reconsent when a rule widens, not only when it leaves team", () => { |
| 106 | const withRead = (read: "owner" | "signedIn" | "public") => |
| 107 | colineAppManifestV2Schema.parse({ |
| 108 | ...app.manifest, |
| 109 | collections: { ...app.manifest.collections, entries: { access: { ...app.manifest.collections.entries!.access, read } } }, |
| 110 | }); |
| 111 | const widened = compareAppSecurityContract(withRead("owner"), withRead("public")); |
| 112 | expect(widened.requiresReconsent).toBe(true); |
| 113 | expect(widened.siteAccessOpened).toEqual(["entries.read"]); |
| 114 | expect(compareAppSecurityContract(withRead("public"), withRead("signedIn")).siteAccessOpened).toEqual([]); |
| 115 | // Owners include guests, so this lets guests in. |
| 116 | expect(compareAppSecurityContract(withRead("signedIn"), withRead("owner")).siteAccessOpened).toEqual(["entries.read"]); |
| 117 | }); |
| 118 |
| 119 | it("still reads versions stored with 0.6 public pages", () => { |
| 120 | const parsed = colineAppManifestV2Schema.safeParse({ |
| 121 | key: "legacy", |
| 122 | name: "Legacy", |
| 123 | permissions: ["public.pages", "storage.app"], |
| 124 | surfaces: { public: { entry: "public.tsx" } }, |
| 125 | hosting: { default: "coline", overrides: { public: "coline" } }, |
| 126 | }); |
| 127 | expect(parsed.success).toBe(true); |
| 128 | expect(parsed.data?.permissions).toEqual(["storage.app"]); |
| 129 | expect(parsed.data?.surfaces).not.toHaveProperty("public"); |
| 130 | }); |
| 131 | }); |
| 132 |
| 133 | describe("site visitors", () => { |
| 134 | it("needs ai.tools for site tools and storage.app for collections, like Coline", async () => { |
| 135 | const workspace = createTestWorkspace(app, { grantedPermissions: ["site.serve"] }); |
| 136 | await expect(workspace.invokeSiteTool("guestbook.sign", { text: "hi" })).rejects.toMatchObject({ |
| 137 | code: "permission_not_granted", |
| 138 | }); |
| 139 | expect(() => workspace.asVisitor().collection("entries")).toThrow(/storage\.app/); |
| 140 | }); |
| 141 |
| 142 | it("lets a site tool write team-only collections and attributes records to the visitor", async () => { |
| 143 | const workspace = createTestWorkspace(app); |
| 144 | const { output } = await workspace.invokeSiteTool("guestbook.sign", { text: "hello" }, { visitorId: "vis_a" }); |
| 145 | const id = (output as { id: string }).id; |
| 146 |
| 147 | const mine = workspace.asVisitor({ visitorId: "vis_a" }).collection<Entry>("entries"); |
| 148 | expect((await mine.get(id))?.createdBy).toMatchObject({ kind: "visitor", visitorId: "vis_a" }); |
| 149 | await expect(mine.update(id, { text: "edited" })).resolves.toMatchObject({ data: { text: "edited" } }); |
| 150 |
| 151 | const signups = workspace.asVisitor({ visitorId: "vis_a" }).collection("signups"); |
| 152 | await expect(signups.query()).resolves.toEqual({ records: [], nextCursor: null }); |
| 153 | }); |
| 154 |
| 155 | it("enforces owner rules between visitors", async () => { |
| 156 | const workspace = createTestWorkspace(app); |
| 157 | const a = workspace.asVisitor({ visitorId: "vis_a" }).collection<Entry>("entries"); |
| 158 | const b = workspace.asVisitor({ visitorId: "vis_b" }).collection<Entry>("entries"); |
| 159 | const entry = await a.insert({ text: "from a" }); |
| 160 |
| 161 | expect((await b.query()).records).toHaveLength(1); |
| 162 | await expect(b.update(entry.id, { text: "hijack" })).rejects.toMatchObject({ code: "record_forbidden" }); |
| 163 | await expect(b.delete(entry.id)).rejects.toMatchObject({ code: "record_forbidden" }); |
| 164 | await expect(a.delete(entry.id)).resolves.toBeUndefined(); |
| 165 | }); |
| 166 |
| 167 | it("keeps undeclared collections and bulk operations away from visitors", async () => { |
| 168 | const workspace = createTestWorkspace(app); |
| 169 | const visitor = workspace.asVisitor(); |
| 170 | await expect(visitor.collection("signups").insert({ who: "x" })).rejects.toMatchObject({ |
| 171 | code: "record_forbidden", |
| 172 | }); |
| 173 | await expect(visitor.collection("entries").deleteMany({ where: {} })).rejects.toMatchObject({ |
| 174 | code: "unavailable_to_visitors", |
| 175 | }); |
| 176 | await expect(visitor.collection("entries").insert({ text: "x" }, { access: { workspace: "none", readers: [], writers: [] } })).rejects.toMatchObject({ |
| 177 | code: "unavailable_to_visitors", |
| 178 | }); |
| 179 | }); |
| 180 |
| 181 | it("runs a destructive site tool at the write ceiling", async () => { |
| 182 | const workspace = createTestWorkspace(app); |
| 183 | await workspace.invokeSiteTool("guestbook.sign", { text: "keep me" }); |
| 184 | await expect(workspace.invokeSiteTool("guestbook.wipe", {})).rejects.toMatchObject({ |
| 185 | code: "effect_ceiling_exceeded", |
| 186 | }); |
| 187 | }); |
| 188 |
| 189 | it("refuses tools the site does not list", async () => { |
| 190 | const unlisted = defineApp({ |
| 191 | key: "guestbook", |
| 192 | name: "Guestbook", |
| 193 | permissions: ["site.serve", "storage.app", "ai.tools"], |
| 194 | hosting: { default: "coline" }, |
| 195 | surfaces: { site: { entry: "site/main.tsx" } }, |
| 196 | tools: [sign, wipe], |
| 197 | }); |
| 198 | const workspace = createTestWorkspace(unlisted); |
| 199 | await expect(workspace.invokeSiteTool("guestbook.sign", { text: "x" })).rejects.toMatchObject({ |
| 200 | code: "site_tool_not_allowed", |
| 201 | }); |
| 202 | }); |
| 203 | }); |
| 204 |