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.
| 1 | import { z } from "zod/v4"; |
| 2 | import { |
| 3 | COLINE_DELIVERY_HEADER, |
| 4 | COLINE_SIGNATURE_HEADER, |
| 5 | COLINE_TIMESTAMP_HEADER, |
| 6 | hmacSha256Hex, |
| 7 | timingSafeEqualHex, |
| 8 | } from "./signing"; |
| 9 | |
| 10 | /** |
| 11 | * Protocol v2: the signed envelope for every Coline ↔ app-backend exchange |
| 12 | * (plan §5.3, §10.3). |
| 13 | * |
| 14 | * Differences from the v1 signed request: |
| 15 | * - The protocol version and delivery id are bound INTO the signature |
| 16 | * (v1 signed only `timestamp.body`), preventing downgrade and |
| 17 | * cross-delivery replay. |
| 18 | * - One envelope shape for every request kind (`tool.invoke`, |
| 19 | * `render.home`, …) instead of per-endpoint schemas. |
| 20 | * - Unknown protocol majors fail closed with an explicit error — no |
| 21 | * silent coercion (hard-cut policy applies to the wire). |
| 22 | */ |
| 23 | |
| 24 | export const COLINE_PROTOCOL_HEADER = "x-coline-protocol"; |
| 25 | export const COLINE_PROTOCOL_VERSION = 2; |
| 26 | |
| 27 | const DEFAULT_MAX_AGE_MS = 5 * 60 * 1000; |
| 28 | |
| 29 | export const colineProtocolActorSchema = z.discriminatedUnion("kind", [ |
| 30 | z.object({ kind: z.literal("user"), userId: z.string().trim().min(1).max(200) }), |
| 31 | z.object({ kind: z.literal("app") }), |
| 32 | z.object({ |
| 33 | kind: z.literal("visitor"), |
| 34 | visitorId: z.string().trim().min(1).max(200), |
| 35 | signedIn: z.boolean(), |
| 36 | name: z.string().max(240).nullable(), |
| 37 | email: z.string().max(320).nullable(), |
| 38 | }), |
| 39 | ]); |
| 40 | export type ColineProtocolActor = z.infer<typeof colineProtocolActorSchema>; |
| 41 | |
| 42 | export const colineProtocolEnvelopeSchema = z.strictObject({ |
| 43 | protocol: z.literal(COLINE_PROTOCOL_VERSION), |
| 44 | deliveryId: z.string().trim().min(8).max(200), |
| 45 | sentAt: z.iso.datetime(), |
| 46 | workspace: z.strictObject({ |
| 47 | workspaceId: z.string().trim().min(1).max(200), |
| 48 | workspaceSlug: z.string().trim().min(1).max(200), |
| 49 | }), |
| 50 | app: z.strictObject({ |
| 51 | appKey: z.string().trim().min(1).max(200), |
| 52 | installId: z.string().trim().min(1).max(200), |
| 53 | }), |
| 54 | actor: colineProtocolActorSchema, |
| 55 | /** Request kind, e.g. "tool.invoke", "render.home", "trigger.schedule". */ |
| 56 | kind: z |
| 57 | .string() |
| 58 | .trim() |
| 59 | .regex(/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/) |
| 60 | .max(120), |
| 61 | payload: z.unknown(), |
| 62 | }); |
| 63 | export type ColineProtocolEnvelope = z.infer<typeof colineProtocolEnvelopeSchema>; |
| 64 | |
| 65 | export type ColineProtocolErrorCode = |
| 66 | | "unsupported_protocol" |
| 67 | | "missing_headers" |
| 68 | | "stale_timestamp" |
| 69 | | "invalid_signature" |
| 70 | | "invalid_envelope" |
| 71 | | "invalid_payload" |
| 72 | | "replayed_delivery"; |
| 73 | |
| 74 | export class ColineProtocolError extends Error { |
| 75 | readonly code: ColineProtocolErrorCode; |
| 76 | |
| 77 | constructor(code: ColineProtocolErrorCode, message: string) { |
| 78 | super(message); |
| 79 | this.name = "ColineProtocolError"; |
| 80 | this.code = code; |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | export function buildProtocolSigningPayload(params: { |
| 85 | deliveryId: string; |
| 86 | timestamp: string; |
| 87 | body: string; |
| 88 | }): string { |
| 89 | return `${COLINE_PROTOCOL_VERSION}.${params.deliveryId}.${params.timestamp}.${params.body}`; |
| 90 | } |
| 91 | |
| 92 | export interface SignedProtocolRequest { |
| 93 | body: string; |
| 94 | headers: Record<string, string>; |
| 95 | } |
| 96 | |
| 97 | export async function buildSignedProtocolRequest(params: { |
| 98 | secret: string; |
| 99 | envelope: ColineProtocolEnvelope; |
| 100 | timestamp?: string | undefined; |
| 101 | }): Promise<SignedProtocolRequest> { |
| 102 | const envelope = colineProtocolEnvelopeSchema.parse(params.envelope); |
| 103 | const body = JSON.stringify(envelope); |
| 104 | const timestamp = params.timestamp ?? new Date().toISOString(); |
| 105 | const signature = await hmacSha256Hex( |
| 106 | params.secret, |
| 107 | buildProtocolSigningPayload({ |
| 108 | deliveryId: envelope.deliveryId, |
| 109 | timestamp, |
| 110 | body, |
| 111 | }), |
| 112 | ); |
| 113 | |
| 114 | return { |
| 115 | body, |
| 116 | headers: { |
| 117 | "content-type": "application/json", |
| 118 | [COLINE_PROTOCOL_HEADER]: String(COLINE_PROTOCOL_VERSION), |
| 119 | [COLINE_SIGNATURE_HEADER]: signature, |
| 120 | [COLINE_TIMESTAMP_HEADER]: timestamp, |
| 121 | [COLINE_DELIVERY_HEADER]: envelope.deliveryId, |
| 122 | }, |
| 123 | }; |
| 124 | } |
| 125 | |
| 126 | export interface ParsedProtocolRequest<TPayload = unknown> { |
| 127 | envelope: ColineProtocolEnvelope; |
| 128 | payload: TPayload; |
| 129 | deliveryId: string; |
| 130 | } |
| 131 | |
| 132 | interface RawSignedRequest { |
| 133 | body: string; |
| 134 | headers: Pick<Headers, "get">; |
| 135 | } |
| 136 | |
| 137 | async function toRawRequest(source: Request | RawSignedRequest): Promise<RawSignedRequest> { |
| 138 | if (source instanceof Request) { |
| 139 | return { body: await source.text(), headers: source.headers }; |
| 140 | } |
| 141 | return source; |
| 142 | } |
| 143 | |
| 144 | export async function parseSignedProtocolRequest<TPayload = unknown>(params: { |
| 145 | request: Request | RawSignedRequest; |
| 146 | secret: string; |
| 147 | payloadSchema?: z.ZodType<TPayload>; |
| 148 | maxAgeMs?: number; |
| 149 | now?: () => number; |
| 150 | }): Promise<ParsedProtocolRequest<TPayload>> { |
| 151 | const { body, headers } = await toRawRequest(params.request); |
| 152 | |
| 153 | const protocolHeader = headers.get(COLINE_PROTOCOL_HEADER); |
| 154 | if (protocolHeader === null) { |
| 155 | throw new ColineProtocolError( |
| 156 | "unsupported_protocol", |
| 157 | `Missing ${COLINE_PROTOCOL_HEADER} header. This endpoint speaks Coline protocol ${COLINE_PROTOCOL_VERSION}.`, |
| 158 | ); |
| 159 | } |
| 160 | if (protocolHeader !== String(COLINE_PROTOCOL_VERSION)) { |
| 161 | throw new ColineProtocolError( |
| 162 | "unsupported_protocol", |
| 163 | `Unsupported Coline protocol "${protocolHeader}". This SDK speaks protocol ${COLINE_PROTOCOL_VERSION}; re-register the app or upgrade the SDK.`, |
| 164 | ); |
| 165 | } |
| 166 | |
| 167 | const signature = headers.get(COLINE_SIGNATURE_HEADER); |
| 168 | const timestamp = headers.get(COLINE_TIMESTAMP_HEADER); |
| 169 | const deliveryId = headers.get(COLINE_DELIVERY_HEADER); |
| 170 | if (!signature || !timestamp || !deliveryId) { |
| 171 | throw new ColineProtocolError( |
| 172 | "missing_headers", |
| 173 | "Missing signature, timestamp, or delivery id headers.", |
| 174 | ); |
| 175 | } |
| 176 | |
| 177 | const timestampMs = Date.parse(timestamp); |
| 178 | if (Number.isNaN(timestampMs)) { |
| 179 | throw new ColineProtocolError("stale_timestamp", "Timestamp header is not a valid date."); |
| 180 | } |
| 181 | const nowMs = params.now ? params.now() : Date.now(); |
| 182 | const maxAgeMs = params.maxAgeMs ?? DEFAULT_MAX_AGE_MS; |
| 183 | if (Math.abs(nowMs - timestampMs) > maxAgeMs) { |
| 184 | throw new ColineProtocolError( |
| 185 | "stale_timestamp", |
| 186 | `Request timestamp is outside the ${Math.floor(maxAgeMs / 1000)}s acceptance window.`, |
| 187 | ); |
| 188 | } |
| 189 | |
| 190 | const expected = await hmacSha256Hex( |
| 191 | params.secret, |
| 192 | buildProtocolSigningPayload({ deliveryId, timestamp, body }), |
| 193 | ); |
| 194 | if (!timingSafeEqualHex(expected, signature)) { |
| 195 | throw new ColineProtocolError("invalid_signature", "Request signature is invalid."); |
| 196 | } |
| 197 | |
| 198 | let parsedBody: unknown; |
| 199 | try { |
| 200 | parsedBody = JSON.parse(body); |
| 201 | } catch { |
| 202 | throw new ColineProtocolError("invalid_envelope", "Request body is not valid JSON."); |
| 203 | } |
| 204 | |
| 205 | const envelope = colineProtocolEnvelopeSchema.safeParse(parsedBody); |
| 206 | if (!envelope.success) { |
| 207 | throw new ColineProtocolError( |
| 208 | "invalid_envelope", |
| 209 | `Request envelope is invalid: ${envelope.error.issues |
| 210 | .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) |
| 211 | .join("; ")}`, |
| 212 | ); |
| 213 | } |
| 214 | if (envelope.data.deliveryId !== deliveryId) { |
| 215 | throw new ColineProtocolError( |
| 216 | "invalid_envelope", |
| 217 | "Envelope delivery id does not match the signed delivery header.", |
| 218 | ); |
| 219 | } |
| 220 | |
| 221 | if (!params.payloadSchema) { |
| 222 | return { |
| 223 | envelope: envelope.data, |
| 224 | payload: envelope.data.payload as TPayload, |
| 225 | deliveryId, |
| 226 | }; |
| 227 | } |
| 228 | |
| 229 | const payload = params.payloadSchema.safeParse(envelope.data.payload); |
| 230 | if (!payload.success) { |
| 231 | throw new ColineProtocolError( |
| 232 | "invalid_payload", |
| 233 | `Request payload is invalid: ${payload.error.issues |
| 234 | .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) |
| 235 | .join("; ")}`, |
| 236 | ); |
| 237 | } |
| 238 | |
| 239 | return { envelope: envelope.data, payload: payload.data, deliveryId }; |
| 240 | } |
| 241 |