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 |
| 5 | function manifest(overrides: Record<string, unknown> = {}): ColineAppManifestV2 { |
| 6 | return colineAppManifestV2Schema.parse({ |
| 7 | key: "crm", |
| 8 | name: "CRM", |
| 9 | permissions: ["files.read", "files.write", "ai.tools"], |
| 10 | surfaces: { home: { tier: "tree" } }, |
| 11 | files: [], |
| 12 | tools: [ |
| 13 | { |
| 14 | name: "crm.create_contact", |
| 15 | description: "Create a contact.", |
| 16 | inputSchema: { type: "object" }, |
| 17 | effect: "write", |
| 18 | }, |
| 19 | ], |
| 20 | ...overrides, |
| 21 | }); |
| 22 | } |
| 23 |
| 24 | describe("compareAppSecurityContract", () => { |
| 25 | it("identical manifests do not require reconsent", () => { |
| 26 | const m = manifest(); |
| 27 | const delta = compareAppSecurityContract(m, m); |
| 28 | expect(delta.requiresReconsent).toBe(false); |
| 29 | expect(delta.permissionsAdded).toEqual([]); |
| 30 | expect(delta.reconsentReasons).toEqual([]); |
| 31 | }); |
| 32 |
| 33 | it("first version always requires consent", () => { |
| 34 | const delta = compareAppSecurityContract(null, manifest()); |
| 35 | expect(delta.requiresReconsent).toBe(true); |
| 36 | expect(delta.permissionsAdded).toContain("files.write"); |
| 37 | }); |
| 38 |
| 39 | it("new permissions require reconsent; removals never do", () => { |
| 40 | const grow = compareAppSecurityContract( |
| 41 | manifest(), |
| 42 | manifest({ permissions: ["files.read", "files.write", "ai.tools", "network.external"] }), |
| 43 | ); |
| 44 | expect(grow.requiresReconsent).toBe(true); |
| 45 | expect(grow.permissionsAdded).toEqual(["network.external"]); |
| 46 |
| 47 | const shrink = compareAppSecurityContract( |
| 48 | manifest(), |
| 49 | manifest({ permissions: ["files.read", "ai.tools"] }), |
| 50 | ); |
| 51 | expect(shrink.requiresReconsent).toBe(false); |
| 52 | expect(shrink.permissionsRemoved).toEqual(["files.write"]); |
| 53 | }); |
| 54 |
| 55 | it("allowlist growth with unchanged permissions still requires reconsent", () => { |
| 56 | const previous = manifest({ |
| 57 | permissions: ["files.read", "ai.tools", "network.external"], |
| 58 | network: { allowedHosts: ["api.notion.com"] }, |
| 59 | }); |
| 60 | const next = manifest({ |
| 61 | permissions: ["files.read", "ai.tools", "network.external"], |
| 62 | network: { allowedHosts: ["api.notion.com", "evil.example.com"] }, |
| 63 | }); |
| 64 | const delta = compareAppSecurityContract(previous, next); |
| 65 | expect(delta.requiresReconsent).toBe(true); |
| 66 | expect(delta.networkHostsAdded).toEqual(["evil.example.com"]); |
| 67 | }); |
| 68 |
| 69 | it("tool effect escalation requires reconsent", () => { |
| 70 | const next = manifest({ |
| 71 | tools: [ |
| 72 | { |
| 73 | name: "crm.create_contact", |
| 74 | description: "Create a contact.", |
| 75 | inputSchema: { type: "object" }, |
| 76 | effect: "destructive", |
| 77 | }, |
| 78 | ], |
| 79 | }); |
| 80 | const delta = compareAppSecurityContract(manifest(), next); |
| 81 | expect(delta.requiresReconsent).toBe(true); |
| 82 | expect(delta.toolEffectsChanged).toEqual([ |
| 83 | { name: "crm.create_contact", previousEffect: "write", nextEffect: "destructive" }, |
| 84 | ]); |
| 85 | }); |
| 86 |
| 87 | it("a new tool above the previous ceiling requires reconsent; below does not", () => { |
| 88 | const withDestructive = manifest({ |
| 89 | tools: [ |
| 90 | { |
| 91 | name: "crm.create_contact", |
| 92 | description: "Create a contact.", |
| 93 | inputSchema: { type: "object" }, |
| 94 | effect: "write", |
| 95 | }, |
| 96 | { |
| 97 | name: "crm.purge_contacts", |
| 98 | description: "Delete everything.", |
| 99 | inputSchema: { type: "object" }, |
| 100 | effect: "destructive", |
| 101 | }, |
| 102 | ], |
| 103 | }); |
| 104 | expect(compareAppSecurityContract(manifest(), withDestructive).requiresReconsent).toBe(true); |
| 105 | expect(compareAppSecurityContract(manifest(), withDestructive).riskFlags).toContain( |
| 106 | "gains_destructive_tools", |
| 107 | ); |
| 108 |
| 109 | const withRead = manifest({ |
| 110 | tools: [ |
| 111 | { |
| 112 | name: "crm.create_contact", |
| 113 | description: "Create a contact.", |
| 114 | inputSchema: { type: "object" }, |
| 115 | effect: "write", |
| 116 | }, |
| 117 | { |
| 118 | name: "crm.list_contacts", |
| 119 | description: "List contacts.", |
| 120 | inputSchema: { type: "object" }, |
| 121 | effect: "read", |
| 122 | }, |
| 123 | ], |
| 124 | }); |
| 125 | expect(compareAppSecurityContract(manifest(), withRead).requiresReconsent).toBe(false); |
| 126 | }); |
| 127 |
| 128 | it("gaining a web surface requires reconsent; tree→react does not", () => { |
| 129 | const toReact = manifest({ surfaces: { home: { tier: "react", entry: "main.tsx" } } }); |
| 130 | expect(compareAppSecurityContract(manifest(), toReact).requiresReconsent).toBe(false); |
| 131 | expect(compareAppSecurityContract(manifest(), toReact).surfacesChanged).toHaveLength(1); |
| 132 |
| 133 | const toWeb = manifest({ |
| 134 | surfaces: { home: { tier: "web", path: "/embed" } }, |
| 135 | hosting: { default: "coline", overrides: {}, external: { baseUrl: "https://x.example" } }, |
| 136 | }); |
| 137 | const delta = compareAppSecurityContract(manifest(), toWeb); |
| 138 | expect(delta.requiresReconsent).toBe(true); |
| 139 | expect(delta.riskFlags).toContain("gains_web_surface"); |
| 140 | }); |
| 141 |
| 142 | it("hosting: gaining external and moving baseUrl require reconsent", () => { |
| 143 | const external = manifest({ |
| 144 | hosting: { |
| 145 | default: "coline", |
| 146 | overrides: { "crm.create_contact": "external" }, |
| 147 | external: { baseUrl: "https://crm.example.com" }, |
| 148 | }, |
| 149 | }); |
| 150 | const gains = compareAppSecurityContract(manifest(), external); |
| 151 | expect(gains.requiresReconsent).toBe(true); |
| 152 | expect(gains.hostingChanged?.overridesNowExternal).toEqual(["crm.create_contact"]); |
| 153 | expect(gains.riskFlags).toContain("gains_external_hosting"); |
| 154 |
| 155 | const moved = manifest({ |
| 156 | hosting: { |
| 157 | default: "coline", |
| 158 | overrides: { "crm.create_contact": "external" }, |
| 159 | external: { baseUrl: "https://elsewhere.example.com" }, |
| 160 | }, |
| 161 | }); |
| 162 | const move = compareAppSecurityContract(external, moved); |
| 163 | expect(move.requiresReconsent).toBe(true); |
| 164 | expect(move.hostingChanged?.externalBaseUrlChanged).toBe(true); |
| 165 | expect(move.riskFlags).toContain("external_base_url_changed"); |
| 166 | }); |
| 167 |
| 168 | it("schedule and webhook changes are recorded; new webhooks require reconsent", () => { |
| 169 | const next = manifest({ |
| 170 | permissions: ["files.read", "files.write", "ai.tools", "webhooks.subscribe"], |
| 171 | hosting: { |
| 172 | default: "coline", |
| 173 | overrides: {}, |
| 174 | external: { baseUrl: "https://crm.example.com" }, |
| 175 | }, |
| 176 | triggers: { |
| 177 | webhooks: [{ event: "file.created" }], |
| 178 | schedules: [{ id: "digest", cron: "0 9 * * 1-5" }], |
| 179 | }, |
| 180 | }); |
| 181 | const delta = compareAppSecurityContract(manifest(), next); |
| 182 | expect(delta.schedulesChanged).toEqual([ |
| 183 | { id: "digest", previousCron: null, nextCron: "0 9 * * 1-5" }, |
| 184 | ]); |
| 185 | expect(delta.webhookEventsAdded).toEqual(["file.created"]); |
| 186 | expect(delta.requiresReconsent).toBe(true); |
| 187 | }); |
| 188 |
| 189 | it("flags wildcard hosts", () => { |
| 190 | const next = manifest({ |
| 191 | permissions: ["files.read", "ai.tools", "network.external"], |
| 192 | network: { allowedHosts: ["*.example.com"] }, |
| 193 | }); |
| 194 | const delta = compareAppSecurityContract(null, next); |
| 195 | expect(delta.riskFlags).toContain("network_host_wildcard"); |
| 196 | }); |
| 197 | }); |
| 198 |