import { z } from "zod/v4"; import { COLINE_DELIVERY_HEADER, COLINE_SIGNATURE_HEADER, COLINE_TIMESTAMP_HEADER, hmacSha256Hex, timingSafeEqualHex, } from "./signing"; /** * Protocol v2: the signed envelope for every Coline ↔ app-backend exchange * (plan §5.3, §10.3). * * Differences from the v1 signed request: * - The protocol version and delivery id are bound INTO the signature * (v1 signed only `timestamp.body`), preventing downgrade and * cross-delivery replay. * - One envelope shape for every request kind (`tool.invoke`, * `render.home`, …) instead of per-endpoint schemas. * - Unknown protocol majors fail closed with an explicit error — no * silent coercion (hard-cut policy applies to the wire). */ export const COLINE_PROTOCOL_HEADER = "x-coline-protocol"; export const COLINE_PROTOCOL_VERSION = 2; const DEFAULT_MAX_AGE_MS = 5 * 60 * 1000; export const colineProtocolActorSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("user"), userId: z.string().trim().min(1).max(200) }), z.object({ kind: z.literal("app") }), ]); export type ColineProtocolActor = z.infer; export const colineProtocolEnvelopeSchema = z.strictObject({ protocol: z.literal(COLINE_PROTOCOL_VERSION), deliveryId: z.string().trim().min(8).max(200), sentAt: z.iso.datetime(), workspace: z.strictObject({ workspaceId: z.string().trim().min(1).max(200), workspaceSlug: z.string().trim().min(1).max(200), }), app: z.strictObject({ appKey: z.string().trim().min(1).max(200), installId: z.string().trim().min(1).max(200), }), actor: colineProtocolActorSchema, /** Request kind, e.g. "tool.invoke", "render.home", "trigger.schedule". */ kind: z .string() .trim() .regex(/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/) .max(120), payload: z.unknown(), }); export type ColineProtocolEnvelope = z.infer; export type ColineProtocolErrorCode = | "unsupported_protocol" | "missing_headers" | "stale_timestamp" | "invalid_signature" | "invalid_envelope" | "invalid_payload" | "replayed_delivery"; export class ColineProtocolError extends Error { readonly code: ColineProtocolErrorCode; constructor(code: ColineProtocolErrorCode, message: string) { super(message); this.name = "ColineProtocolError"; this.code = code; } } export function buildProtocolSigningPayload(params: { deliveryId: string; timestamp: string; body: string; }): string { return `${COLINE_PROTOCOL_VERSION}.${params.deliveryId}.${params.timestamp}.${params.body}`; } export interface SignedProtocolRequest { body: string; headers: Record; } export async function buildSignedProtocolRequest(params: { secret: string; envelope: ColineProtocolEnvelope; timestamp?: string | undefined; }): Promise { const envelope = colineProtocolEnvelopeSchema.parse(params.envelope); const body = JSON.stringify(envelope); const timestamp = params.timestamp ?? new Date().toISOString(); const signature = await hmacSha256Hex( params.secret, buildProtocolSigningPayload({ deliveryId: envelope.deliveryId, timestamp, body, }), ); return { body, headers: { "content-type": "application/json", [COLINE_PROTOCOL_HEADER]: String(COLINE_PROTOCOL_VERSION), [COLINE_SIGNATURE_HEADER]: signature, [COLINE_TIMESTAMP_HEADER]: timestamp, [COLINE_DELIVERY_HEADER]: envelope.deliveryId, }, }; } export interface ParsedProtocolRequest { envelope: ColineProtocolEnvelope; payload: TPayload; deliveryId: string; } interface RawSignedRequest { body: string; headers: Pick; } async function toRawRequest(source: Request | RawSignedRequest): Promise { if (source instanceof Request) { return { body: await source.text(), headers: source.headers }; } return source; } export async function parseSignedProtocolRequest(params: { request: Request | RawSignedRequest; secret: string; payloadSchema?: z.ZodType; maxAgeMs?: number; now?: () => number; }): Promise> { const { body, headers } = await toRawRequest(params.request); const protocolHeader = headers.get(COLINE_PROTOCOL_HEADER); if (protocolHeader === null) { throw new ColineProtocolError( "unsupported_protocol", `Missing ${COLINE_PROTOCOL_HEADER} header. This endpoint speaks Coline protocol ${COLINE_PROTOCOL_VERSION}.`, ); } if (protocolHeader !== String(COLINE_PROTOCOL_VERSION)) { throw new ColineProtocolError( "unsupported_protocol", `Unsupported Coline protocol "${protocolHeader}". This SDK speaks protocol ${COLINE_PROTOCOL_VERSION}; re-register the app or upgrade the SDK.`, ); } const signature = headers.get(COLINE_SIGNATURE_HEADER); const timestamp = headers.get(COLINE_TIMESTAMP_HEADER); const deliveryId = headers.get(COLINE_DELIVERY_HEADER); if (!signature || !timestamp || !deliveryId) { throw new ColineProtocolError( "missing_headers", "Missing signature, timestamp, or delivery id headers.", ); } const timestampMs = Date.parse(timestamp); if (Number.isNaN(timestampMs)) { throw new ColineProtocolError("stale_timestamp", "Timestamp header is not a valid date."); } const nowMs = params.now ? params.now() : Date.now(); const maxAgeMs = params.maxAgeMs ?? DEFAULT_MAX_AGE_MS; if (Math.abs(nowMs - timestampMs) > maxAgeMs) { throw new ColineProtocolError( "stale_timestamp", `Request timestamp is outside the ${Math.floor(maxAgeMs / 1000)}s acceptance window.`, ); } const expected = await hmacSha256Hex( params.secret, buildProtocolSigningPayload({ deliveryId, timestamp, body }), ); if (!timingSafeEqualHex(expected, signature)) { throw new ColineProtocolError("invalid_signature", "Request signature is invalid."); } let parsedBody: unknown; try { parsedBody = JSON.parse(body); } catch { throw new ColineProtocolError("invalid_envelope", "Request body is not valid JSON."); } const envelope = colineProtocolEnvelopeSchema.safeParse(parsedBody); if (!envelope.success) { throw new ColineProtocolError( "invalid_envelope", `Request envelope is invalid: ${envelope.error.issues .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) .join("; ")}`, ); } if (envelope.data.deliveryId !== deliveryId) { throw new ColineProtocolError( "invalid_envelope", "Envelope delivery id does not match the signed delivery header.", ); } if (!params.payloadSchema) { return { envelope: envelope.data, payload: envelope.data.payload as TPayload, deliveryId, }; } const payload = params.payloadSchema.safeParse(envelope.data.payload); if (!payload.success) { throw new ColineProtocolError( "invalid_payload", `Request payload is invalid: ${payload.error.issues .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) .join("; ")}`, ); } return { envelope: envelope.data, payload: payload.data, deliveryId }; }