UNPKG

@linear/sdk

Version:

The Linear Client SDK for interacting with the Linear GraphQL API

206 lines (204 loc) • 8.49 kB
import crypto from "crypto"; //#region src/webhooks/client.ts const LINEAR_WEBHOOK_SIGNATURE_HEADER = "linear-signature"; const LINEAR_WEBHOOK_TS_HEADER = "linear-timestamp"; const LINEAR_WEBHOOK_TS_FIELD = "webhookTimestamp"; /** * Client for handling Linear webhook requests with helpers. */ var LinearWebhookClient = class { /** * Creates a new LinearWebhookClient instance * @param secret The webhook signing secret. See https://linear.app/developers/webhooks#securing-webhooks. */ constructor(secret) { this.secret = secret; if (typeof secret !== "string" || secret.length === 0) throw new TypeError("LinearWebhookClient: secret must be a non-empty string"); } /** * Creates a webhook handler function that can process Linear webhook requests * @returns A webhook handler function with event registration capabilities. * Supports both Fetch API `(request: Request) => Promise<Response>` and * Node.js `(request: IncomingMessage, response: ServerResponse) => Promise<void>` */ createHandler() { const eventHandlers = /* @__PURE__ */ new Map(); const handler = async (requestOrMessage, response) => { const adapter = this.getHttpAdapter(requestOrMessage, response); try { if (adapter.method !== "POST") return adapter.send(405, "Method not allowed"); const signature = adapter.signature; if (!signature) return adapter.send(400, "Missing webhook signature"); const rawBody = await adapter.readRawBody(); let parsedPayload; try { parsedPayload = this.parseVerifiedPayload(rawBody, signature, adapter.timestamp); } catch { return adapter.send(400, "Invalid webhook"); } const allHandlers = this.collectHandlers(eventHandlers, parsedPayload.type); await Promise.all(allHandlers.map((h) => h(parsedPayload))); return adapter.send(200, "OK"); } catch { return adapter.send(500, "Internal server error"); } }; handler.on = function(eventType, eventHandler) { const handlers = eventHandlers.get(eventType) || []; handlers.push(eventHandler); eventHandlers.set(eventType, handlers); }; handler.off = function(eventType, eventHandler) { const handlers = eventHandlers.get(eventType); if (handlers) { const index = handlers.indexOf(eventHandler); if (index > -1) { handlers.splice(index, 1); if (handlers.length === 0) eventHandlers.delete(eventType); } } }; handler.removeAllListeners = function(eventType) { if (eventType) eventHandlers.delete(eventType); else eventHandlers.clear(); }; return handler; } /** * Determines whether the provided value is a Fetch API `Request`. * Used as a type guard to select the appropriate runtime path. * * @param value - Unknown request-like value * @returns True if `value` is a Fetch API `Request` */ isFetchRequest(value) { return typeof value === "object" && value !== null && "arrayBuffer" in value && typeof Reflect.get(value, "arrayBuffer") === "function"; } /** * Creates an HTTP adapter for Fetch-based runtimes. * The body is not read until `readRawBody` is invoked. * * @param request - Fetch API `Request` * @returns Helpers to read input and send responses in a unified way */ createFetchAdapter(request) { return { method: request.method, signature: request.headers.get(LINEAR_WEBHOOK_SIGNATURE_HEADER), timestamp: request.headers.get(LINEAR_WEBHOOK_TS_HEADER), readRawBody: async () => Buffer.from(await request.arrayBuffer()), send: (status, body) => new Response(body, { status }) }; } /** * Creates an HTTP adapter for Node.js HTTP runtimes. * The body stream is consumed when `readRawBody` is invoked. * * @param incomingMessage - Node.js `IncomingMessage` * @param res - Node.js `ServerResponse` used to write the response * @returns Helpers to read input and send responses in a unified way */ createNodeAdapter(incomingMessage, res) { const signatureHeader = incomingMessage.headers[LINEAR_WEBHOOK_SIGNATURE_HEADER]; const signature = Array.isArray(signatureHeader) ? signatureHeader[0] ?? null : signatureHeader ?? null; const timestampHeader = incomingMessage.headers[LINEAR_WEBHOOK_TS_HEADER]; const timestamp = Array.isArray(timestampHeader) ? timestampHeader[0] ?? null : timestampHeader ?? null; return { method: incomingMessage.method || "", signature, timestamp, readRawBody: async () => { const chunks = []; for await (const chunk of incomingMessage) chunks.push(Buffer.from(chunk)); return Buffer.concat(chunks); }, send: (status, body) => { res.statusCode = status; res.end(body); } }; } /** * Selects and constructs the appropriate HTTP adapter for the * provided request type (Fetch or Node.js HTTP). * * @param requestOrMessage - A Fetch `Request` or Node.js `IncomingMessage` * @param response - Node.js `ServerResponse` (required for Node path) * @returns An HTTP adapter with unified IO helpers */ getHttpAdapter(requestOrMessage, response) { return this.isFetchRequest(requestOrMessage) ? this.createFetchAdapter(requestOrMessage) : this.createNodeAdapter(requestOrMessage, response); } /** * Parses the JSON body and verifies signature and signed body timestamp. * * Throws if the JSON is invalid, the signature is invalid, or the timestamp check fails. * * @param rawBody - Raw request body as a Buffer * @param signature - The value of the `linear-signature` header * @param timestampHeader - Legacy timestamp header; verify() only trusts the signed body timestamp. * @returns The verified and parsed webhook payload */ parseVerifiedPayload(rawBody, signature, timestampHeader) { const parsedBody = this.parseBodyAsWebhookPayload(rawBody); const timestamp = parsedBody.webhookTimestamp ?? timestampHeader; if (!this.verify(rawBody, signature, timestamp)) throw new Error("Invalid webhook signature"); return parsedBody; } /** * Parses the raw body as a webhook payload with typing. * * @param rawBody - Raw request body as a Buffer * @returns Parsed webhook payload object */ parseBodyAsWebhookPayload(rawBody) { return JSON.parse(rawBody.toString()); } /** * Returns the list of handlers to invoke for a given event type, * including both specific and wildcard handlers. * * @param eventHandlers - Internal registry of event handlers * @param eventType - The webhook `type` field from the payload * @returns Ordered list of handlers to be executed */ collectHandlers(eventHandlers, eventType) { const specificHandlers = eventHandlers.get(eventType) || []; const wildcardHandlers = eventHandlers.get("*") || []; return [...specificHandlers, ...wildcardHandlers]; } /** * Verify the webhook signature * * Throws if the signature is invalid or the signed body timestamp is missing or invalid. * * @param rawBody The webhook request raw body * @param signature The signature to verify * @param timestamp Deprecated and ignored. Only webhookTimestamp in the signed body is trusted. * @returns True if the signature is valid */ verify(rawBody, signature, timestamp) { const verificationBuffer = Buffer.from(crypto.createHmac("sha256", this.secret).update(rawBody).digest("hex")); const signatureBuffer = Buffer.from(signature); if (verificationBuffer.length !== signatureBuffer.length) throw new Error("Invalid webhook signature"); if (!crypto.timingSafeEqual(verificationBuffer, signatureBuffer)) throw new Error("Invalid webhook signature"); timestamp = this.parseBodyAsWebhookPayload(rawBody)?.webhookTimestamp; if (timestamp === void 0 || timestamp === null) throw new Error("Missing webhook timestamp"); if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) throw new Error("Invalid webhook timestamp"); if (Math.abs(Date.now() - timestamp) > 1e3 * 60) throw new Error("Invalid webhook timestamp"); return true; } /** * Parse and verify webhook data, throwing an error if the signature or given timestamp is invalid. * * @param rawBody The webhook request raw body * @param signature The signature to verify * @param timestamp Deprecated and ignored. Only webhookTimestamp in the signed body is trusted. */ parseData(rawBody, signature, timestamp) { if (!this.verify(rawBody, signature, timestamp)) throw new Error("Invalid webhook signature"); return this.parseBodyAsWebhookPayload(rawBody); } }; //#endregion export { LinearWebhookClient as i, LINEAR_WEBHOOK_TS_FIELD as n, LINEAR_WEBHOOK_TS_HEADER as r, LINEAR_WEBHOOK_SIGNATURE_HEADER as t };