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.
| COLINE_PROTOCOL_HEADER, |
| 5 | ColineProtocolError, |
| 6 | buildSignedProtocolRequest, |
| 7 | parseSignedProtocolRequest, |
| 8 | type ColineProtocolEnvelope, |
| 9 | } from "./protocol"; |
| 10 | import { COLINE_SIGNATURE_HEADER, COLINE_TIMESTAMP_HEADER } from "./signing"; |
| 11 |
| 12 | const SECRET = "test-secret-of-sufficient-length"; |
| 13 |
| 14 | const envelope: ColineProtocolEnvelope = { |
| 15 | protocol: 2, |
| 16 | deliveryId: "dlv_0123456789", |
| 17 | sentAt: "2026-08-10T12:00:00.000Z", |
| 18 | workspace: { workspaceId: "ws_1", workspaceSlug: "acme" }, |
| 19 | app: { appKey: "crm", installId: "inst_1" }, |
| 20 | actor: { kind: "user", userId: "user_1" }, |
| 21 | kind: "tool.invoke", |
| 22 | payload: { tool: "crm.create_contact", input: { name: "Sarah" } }, |
| 23 | }; |
| 24 |
| 25 | function toHeaders(record: Record<string, string>): Headers { |
| 26 | return new Headers(record); |
| 27 | } |
| 28 |
| 29 | describe("protocol v2 envelope", () => { |
| 30 | it("round-trips a signed request", async () => { |
| 31 | const signed = await buildSignedProtocolRequest({ secret: SECRET, envelope }); |
| 32 | const parsed = await parseSignedProtocolRequest({ |
| 33 | request: { body: signed.body, headers: toHeaders(signed.headers) }, |
| 34 | secret: SECRET, |
| 35 | now: () => Date.parse(signed.headers[COLINE_TIMESTAMP_HEADER] ?? ""), |
| 36 | payloadSchema: z.object({ |
| 37 | tool: z.string(), |
| 38 | input: z.object({ name: z.string() }), |
| 39 | }), |
| 40 | }); |
| 41 |
| 42 | expect(parsed.envelope.app.appKey).toBe("crm"); |
| 43 | expect(parsed.envelope.actor).toEqual({ kind: "user", userId: "user_1" }); |
| 44 | expect(parsed.payload.input.name).toBe("Sarah"); |
| 45 | expect(parsed.deliveryId).toBe("dlv_0123456789"); |
| 46 | }); |
| 47 |
| 48 | it("fails closed on a missing protocol header", async () => { |
| 49 | const signed = await buildSignedProtocolRequest({ secret: SECRET, envelope }); |
| 50 | const headers = toHeaders(signed.headers); |
| 51 | headers.delete(COLINE_PROTOCOL_HEADER); |
| 52 | await expect( |
| 53 | parseSignedProtocolRequest({ |
| 54 | request: { body: signed.body, headers }, |
| 55 | secret: SECRET, |
| 56 | }), |
| 57 | ).rejects.toMatchObject({ code: "unsupported_protocol" }); |
| 58 | }); |
| 59 |
| 60 | it("fails closed on an unknown protocol version — no silent coercion", async () => { |
| 61 | const signed = await buildSignedProtocolRequest({ secret: SECRET, envelope }); |
| 62 | const headers = toHeaders(signed.headers); |
| 63 | headers.set(COLINE_PROTOCOL_HEADER, "3"); |
| 64 | await expect( |
| 65 | parseSignedProtocolRequest({ |
| 66 | request: { body: signed.body, headers }, |
| 67 | secret: SECRET, |
| 68 | }), |
| 69 | ).rejects.toMatchObject({ code: "unsupported_protocol" }); |
| 70 | }); |
| 71 |
| 72 | it("rejects a tampered body", async () => { |
| 73 | const signed = await buildSignedProtocolRequest({ secret: SECRET, envelope }); |
| 74 | const tampered = signed.body.replace("Sarah", "Mallory"); |
| 75 | await expect( |
| 76 | parseSignedProtocolRequest({ |
| 77 | request: { body: tampered, headers: toHeaders(signed.headers) }, |
| 78 | secret: SECRET, |
| 79 | now: () => Date.parse(signed.headers[COLINE_TIMESTAMP_HEADER] ?? ""), |
| 80 | }), |
| 81 | ).rejects.toMatchObject({ code: "invalid_signature" }); |
| 82 | }); |
| 83 |
| 84 | it("rejects a wrong secret", async () => { |
| 85 | const signed = await buildSignedProtocolRequest({ secret: SECRET, envelope }); |
| 86 | await expect( |
| 87 | parseSignedProtocolRequest({ |
| 88 | request: { body: signed.body, headers: toHeaders(signed.headers) }, |
| 89 | secret: "another-secret-of-sufficient-length", |
| 90 | now: () => Date.parse(signed.headers[COLINE_TIMESTAMP_HEADER] ?? ""), |
| 91 | }), |
| 92 | ).rejects.toMatchObject({ code: "invalid_signature" }); |
| 93 | }); |
| 94 |
| 95 | it("rejects stale timestamps", async () => { |
| 96 | const signed = await buildSignedProtocolRequest({ secret: SECRET, envelope }); |
| 97 | await expect( |
| 98 | parseSignedProtocolRequest({ |
| 99 | request: { body: signed.body, headers: toHeaders(signed.headers) }, |
| 100 | secret: SECRET, |
| 101 | now: () => |
| 102 | Date.parse(signed.headers[COLINE_TIMESTAMP_HEADER] ?? "") + 6 * 60 * 1000, |
| 103 | }), |
| 104 | ).rejects.toMatchObject({ code: "stale_timestamp" }); |
| 105 | }); |
| 106 |
| 107 | it("binds the delivery id into the signature (no cross-delivery replay)", async () => { |
| 108 | const signed = await buildSignedProtocolRequest({ secret: SECRET, envelope }); |
| 109 | const otherSigned = await buildSignedProtocolRequest({ |
| 110 | secret: SECRET, |
| 111 | envelope: { ...envelope, deliveryId: "dlv_9876543210" }, |
| 112 | timestamp: signed.headers[COLINE_TIMESTAMP_HEADER], |
| 113 | }); |
| 114 | // Replay the first body under the second delivery's headers. |
| 115 | await expect( |
| 116 | parseSignedProtocolRequest({ |
| 117 | request: { body: signed.body, headers: toHeaders(otherSigned.headers) }, |
| 118 | secret: SECRET, |
| 119 | now: () => Date.parse(signed.headers[COLINE_TIMESTAMP_HEADER] ?? ""), |
| 120 | }), |
| 121 | ).rejects.toMatchObject({ code: "invalid_signature" }); |
| 122 | }); |
| 123 |
| 124 | it("rejects an envelope whose deliveryId disagrees with the signed header", async () => { |
| 125 | const mismatched = { ...envelope, deliveryId: "dlv_inner_differs" }; |
| 126 | const body = JSON.stringify(mismatched); |
| 127 | const signed = await buildSignedProtocolRequest({ secret: SECRET, envelope }); |
| 128 | // Sign the mismatched body properly under the outer header's delivery id. |
| 129 | const { hmacSha256Hex } = await import("./signing"); |
| 130 | const timestamp = signed.headers[COLINE_TIMESTAMP_HEADER] ?? ""; |
| 131 | const signature = await hmacSha256Hex( |
| 132 | SECRET, |
| 133 | `2.${envelope.deliveryId}.${timestamp}.${body}`, |
| 134 | ); |
| 135 | const headers = toHeaders({ ...signed.headers, [COLINE_SIGNATURE_HEADER]: signature }); |
| 136 | await expect( |
| 137 | parseSignedProtocolRequest({ |
| 138 | request: { body, headers }, |
| 139 | secret: SECRET, |
| 140 | now: () => Date.parse(timestamp), |
| 141 | }), |
| 142 | ).rejects.toMatchObject({ code: "invalid_envelope" }); |
| 143 | }); |
| 144 |
| 145 | it("validates payloads when a schema is provided", async () => { |
| 146 | const signed = await buildSignedProtocolRequest({ secret: SECRET, envelope }); |
| 147 | await expect( |
| 148 | parseSignedProtocolRequest({ |
| 149 | request: { body: signed.body, headers: toHeaders(signed.headers) }, |
| 150 | secret: SECRET, |
| 151 | now: () => Date.parse(signed.headers[COLINE_TIMESTAMP_HEADER] ?? ""), |
| 152 | payloadSchema: z.object({ somethingElse: z.string() }), |
| 153 | }), |
| 154 | ).rejects.toMatchObject({ code: "invalid_payload" }); |
| 155 | }); |
| 156 |
| 157 | it("exposes a typed error class", async () => { |
| 158 | const signed = await buildSignedProtocolRequest({ secret: SECRET, envelope }); |
| 159 | const headers = toHeaders(signed.headers); |
| 160 | headers.set(COLINE_PROTOCOL_HEADER, "999"); |
| 161 | try { |
| 162 | await parseSignedProtocolRequest({ |
| 163 | request: { body: signed.body, headers }, |
| 164 | secret: SECRET, |
| 165 | }); |
| 166 | expect.unreachable(); |
| 167 | } catch (error) { |
| 168 | expect(error).toBeInstanceOf(ColineProtocolError); |
| 169 | } |
| 170 | }); |
| 171 | }); |
| 172 |