UNPKG

@linear/sdk

Version:

The Linear Client SDK for interacting with the Linear GraphQL API

1 lines • 15.3 kB
{"version":3,"file":"webhooks-BisgAo6N.mjs","names":["secret: string","parsedPayload: LinearWebhookPayload","chunks: Buffer[]"],"sources":["../src/webhooks/client.ts"],"sourcesContent":["import crypto from \"crypto\";\nimport type { IncomingMessage, ServerResponse } from \"http\";\nimport {\n LinearWebhookEventHandler,\n LinearWebhookEventType,\n LinearWebhookHandler,\n LinearWebhookPayload,\n} from \"./types.js\";\n\nexport const LINEAR_WEBHOOK_SIGNATURE_HEADER = \"linear-signature\";\nexport const LINEAR_WEBHOOK_TS_HEADER = \"linear-timestamp\";\nexport const LINEAR_WEBHOOK_TS_FIELD = \"webhookTimestamp\";\n\n/**\n * Internal abstraction that adapts request/response behavior across\n * Fetch API and Node.js HTTP runtimes.\n *\n * Not exported on purpose: this is an implementation detail of\n * `LinearWebhookClient` and should not be relied upon by SDK consumers.\n *\n * - `method` and `signature` expose the necessary request metadata.\n * - `readRawBody` defers reading/streaming the raw body until invoked.\n * - `send` unifies writing responses in both environments.\n */\ninterface HttpAdapter {\n method: string;\n signature: string | null;\n timestamp: string | null;\n readRawBody: () => Promise<Buffer>;\n send: (status: number, body: string) => Response | void;\n}\n\n/**\n * Client for handling Linear webhook requests with helpers.\n */\nexport class LinearWebhookClient {\n /**\n * Creates a new LinearWebhookClient instance\n * @param secret The webhook signing secret. See https://linear.app/developers/webhooks#securing-webhooks.\n */\n public constructor(private secret: string) {\n if (typeof secret !== \"string\" || secret.length === 0) {\n throw new TypeError(\"LinearWebhookClient: secret must be a non-empty string\");\n }\n }\n\n /**\n * Creates a webhook handler function that can process Linear webhook requests\n * @returns A webhook handler function with event registration capabilities.\n * Supports both Fetch API `(request: Request) => Promise<Response>` and\n * Node.js `(request: IncomingMessage, response: ServerResponse) => Promise<void>`\n */\n public createHandler(): LinearWebhookHandler {\n const eventHandlers = new Map<string, LinearWebhookEventHandler<LinearWebhookPayload>[]>();\n\n const handler = async (\n requestOrMessage: Request | IncomingMessage,\n response?: ServerResponse\n ): Promise<Response | void> => {\n const adapter = this.getHttpAdapter(requestOrMessage, response);\n\n try {\n if (adapter.method !== \"POST\") {\n return adapter.send(405, \"Method not allowed\");\n }\n\n const signature = adapter.signature;\n if (!signature) {\n return adapter.send(400, \"Missing webhook signature\");\n }\n\n const rawBody = await adapter.readRawBody();\n\n let parsedPayload: LinearWebhookPayload;\n try {\n parsedPayload = this.parseVerifiedPayload(rawBody, signature, adapter.timestamp);\n } catch {\n return adapter.send(400, \"Invalid webhook\");\n }\n\n const allHandlers = this.collectHandlers(eventHandlers, parsedPayload.type);\n // eslint-disable-next-line @typescript-eslint/await-thenable\n await Promise.all(allHandlers.map(h => h(parsedPayload)));\n return adapter.send(200, \"OK\");\n } catch {\n return adapter.send(500, \"Internal server error\");\n }\n };\n\n handler.on = function <T extends LinearWebhookEventType>(\n eventType: T,\n eventHandler: LinearWebhookEventHandler<Extract<LinearWebhookPayload, { type: T }>>\n ): void {\n const handlers = eventHandlers.get(eventType) || [];\n handlers.push(eventHandler as LinearWebhookEventHandler<LinearWebhookPayload>);\n eventHandlers.set(eventType, handlers);\n };\n\n handler.off = function <T extends LinearWebhookEventType>(\n eventType: T,\n eventHandler: LinearWebhookEventHandler<Extract<LinearWebhookPayload, { type: T }>>\n ): void {\n const handlers = eventHandlers.get(eventType);\n if (handlers) {\n const index = handlers.indexOf(eventHandler as LinearWebhookEventHandler<LinearWebhookPayload>);\n if (index > -1) {\n handlers.splice(index, 1);\n if (handlers.length === 0) {\n eventHandlers.delete(eventType);\n }\n }\n }\n };\n\n handler.removeAllListeners = function (eventType?: string): void {\n if (eventType) {\n eventHandlers.delete(eventType);\n } else {\n eventHandlers.clear();\n }\n };\n\n return handler as LinearWebhookHandler;\n }\n\n /**\n * Determines whether the provided value is a Fetch API `Request`.\n * Used as a type guard to select the appropriate runtime path.\n *\n * @param value - Unknown request-like value\n * @returns True if `value` is a Fetch API `Request`\n */\n private isFetchRequest(value: unknown): value is Request {\n return (\n typeof value === \"object\" &&\n value !== null &&\n \"arrayBuffer\" in value &&\n typeof Reflect.get(value, \"arrayBuffer\") === \"function\"\n );\n }\n\n /**\n * Creates an HTTP adapter for Fetch-based runtimes.\n * The body is not read until `readRawBody` is invoked.\n *\n * @param request - Fetch API `Request`\n * @returns Helpers to read input and send responses in a unified way\n */\n private createFetchAdapter(request: Request): HttpAdapter {\n return {\n method: request.method,\n signature: request.headers.get(LINEAR_WEBHOOK_SIGNATURE_HEADER),\n timestamp: request.headers.get(LINEAR_WEBHOOK_TS_HEADER),\n readRawBody: async () => Buffer.from(await request.arrayBuffer()),\n send: (status, body) => new Response(body, { status }),\n };\n }\n\n /**\n * Creates an HTTP adapter for Node.js HTTP runtimes.\n * The body stream is consumed when `readRawBody` is invoked.\n *\n * @param incomingMessage - Node.js `IncomingMessage`\n * @param res - Node.js `ServerResponse` used to write the response\n * @returns Helpers to read input and send responses in a unified way\n */\n private createNodeAdapter(incomingMessage: IncomingMessage, res: ServerResponse): HttpAdapter {\n const signatureHeader = incomingMessage.headers[LINEAR_WEBHOOK_SIGNATURE_HEADER];\n const signature = Array.isArray(signatureHeader)\n ? (signatureHeader[0] ?? null)\n : ((signatureHeader ?? null) as string | null);\n const timestampHeader = incomingMessage.headers[LINEAR_WEBHOOK_TS_HEADER];\n const timestamp = Array.isArray(timestampHeader)\n ? (timestampHeader[0] ?? null)\n : ((timestampHeader ?? null) as string | null);\n return {\n method: incomingMessage.method || \"\",\n signature,\n timestamp,\n readRawBody: async () => {\n const chunks: Buffer[] = [];\n for await (const chunk of incomingMessage) {\n chunks.push(Buffer.from(chunk));\n }\n return Buffer.concat(chunks);\n },\n send: (status, body) => {\n res.statusCode = status;\n res.end(body);\n },\n };\n }\n\n /**\n * Selects and constructs the appropriate HTTP adapter for the\n * provided request type (Fetch or Node.js HTTP).\n *\n * @param requestOrMessage - A Fetch `Request` or Node.js `IncomingMessage`\n * @param response - Node.js `ServerResponse` (required for Node path)\n * @returns An HTTP adapter with unified IO helpers\n */\n private getHttpAdapter(requestOrMessage: Request | IncomingMessage, response?: ServerResponse): HttpAdapter {\n return this.isFetchRequest(requestOrMessage)\n ? this.createFetchAdapter(requestOrMessage)\n : this.createNodeAdapter(requestOrMessage as IncomingMessage, response as ServerResponse);\n }\n\n /**\n * Parses the JSON body and verifies signature and optional timestamp.\n *\n * Throws if the JSON is invalid, the signature is invalid, or the timestamp check fails.\n *\n * @param rawBody - Raw request body as a Buffer\n * @param signature - The value of the `linear-signature` header\n * @param timestampHeader - The value of the `linear-timestamp` header (used only when the signed body has no timestamp)\n * @returns The verified and parsed webhook payload\n */\n private parseVerifiedPayload(\n rawBody: Buffer,\n signature: string,\n timestampHeader: string | null\n ): LinearWebhookPayload {\n const parsedBody = this.parseBodyAsWebhookPayload(rawBody);\n const timestamp = parsedBody.webhookTimestamp ?? timestampHeader;\n\n const verified = this.verify(rawBody, signature, timestamp);\n if (!verified) {\n throw new Error(\"Invalid webhook signature\");\n }\n\n return parsedBody;\n }\n\n /**\n * Parses the raw body as a webhook payload with typing.\n *\n * @param rawBody - Raw request body as a Buffer\n * @returns Parsed webhook payload object\n */\n private parseBodyAsWebhookPayload(rawBody: Buffer): LinearWebhookPayload & { webhookTimestamp?: number } {\n return JSON.parse(rawBody.toString());\n }\n\n /**\n * Returns the list of handlers to invoke for a given event type,\n * including both specific and wildcard handlers.\n *\n * @param eventHandlers - Internal registry of event handlers\n * @param eventType - The webhook `type` field from the payload\n * @returns Ordered list of handlers to be executed\n */\n private collectHandlers(\n eventHandlers: Map<string, LinearWebhookEventHandler<LinearWebhookPayload>[]>,\n eventType: string\n ): LinearWebhookEventHandler<LinearWebhookPayload>[] {\n const specificHandlers = eventHandlers.get(eventType) || [];\n const wildcardHandlers = eventHandlers.get(\"*\") || [];\n return [...specificHandlers, ...wildcardHandlers];\n }\n\n /**\n * Verify the webhook signature\n *\n * Throws an error if the signature or timestamp is invalid.\n *\n * @param rawBody The webhook request raw body\n * @param signature The signature to verify\n * @param timestamp The timestamp value - either from the `linear-timestamp` header (string)\n * or the `webhookTimestamp` field from the request parsed body (number)\n * @returns True if the signature is valid\n */\n public verify(rawBody: Buffer, signature: string, timestamp?: number | string): boolean {\n const verificationBuffer = Buffer.from(crypto.createHmac(\"sha256\", this.secret).update(rawBody).digest(\"hex\"));\n const signatureBuffer = Buffer.from(signature);\n\n if (verificationBuffer.length !== signatureBuffer.length) {\n throw new Error(\"Invalid webhook signature\");\n }\n\n if (!crypto.timingSafeEqual(verificationBuffer, signatureBuffer)) {\n throw new Error(\"Invalid webhook signature\");\n }\n\n if (timestamp) {\n const timestampMs = typeof timestamp === \"string\" ? parseInt(timestamp, 10) : timestamp;\n if (isNaN(timestampMs)) {\n throw new Error(`Invalid webhook timestamp: ${timestamp}`);\n }\n const timeDiff = Math.abs(new Date().getTime() - timestampMs);\n // Throw error if more than one minute delta between provided ts and current time\n if (timeDiff > 1000 * 60) {\n throw new Error(\"Invalid webhook timestamp\");\n }\n }\n\n return true;\n }\n\n /**\n * Parse and verify webhook data, throwing an error if the signature or given timestamp is invalid.\n *\n * @param rawBody The webhook request raw body\n * @param signature The signature to verify\n * @param timestamp The timestamp value - either from the `linear-timestamp` header (string)\n * or the `webhookTimestamp` field from the request parsed body (number)\n */\n public parseData(rawBody: Buffer, signature: string, timestamp?: number | string): LinearWebhookPayload {\n const verified = this.verify(rawBody, signature, timestamp);\n if (!verified) {\n throw new Error(\"Invalid webhook signature\");\n }\n\n return this.parseBodyAsWebhookPayload(rawBody);\n }\n}\n"],"mappings":";;;AASA,MAAa,kCAAkC;AAC/C,MAAa,2BAA2B;AACxC,MAAa,0BAA0B;;;;AAwBvC,IAAa,sBAAb,MAAiC;;;;;CAK/B,AAAO,YAAY,AAAQA,QAAgB;EAAhB;AACzB,MAAI,OAAO,WAAW,YAAY,OAAO,WAAW,EAClD,OAAM,IAAI,UAAU,yDAAyD;;;;;;;;CAUjF,AAAO,gBAAsC;EAC3C,MAAM,gCAAgB,IAAI,KAAgE;EAE1F,MAAM,UAAU,OACd,kBACA,aAC6B;GAC7B,MAAM,UAAU,KAAK,eAAe,kBAAkB,SAAS;AAE/D,OAAI;AACF,QAAI,QAAQ,WAAW,OACrB,QAAO,QAAQ,KAAK,KAAK,qBAAqB;IAGhD,MAAM,YAAY,QAAQ;AAC1B,QAAI,CAAC,UACH,QAAO,QAAQ,KAAK,KAAK,4BAA4B;IAGvD,MAAM,UAAU,MAAM,QAAQ,aAAa;IAE3C,IAAIC;AACJ,QAAI;AACF,qBAAgB,KAAK,qBAAqB,SAAS,WAAW,QAAQ,UAAU;YAC1E;AACN,YAAO,QAAQ,KAAK,KAAK,kBAAkB;;IAG7C,MAAM,cAAc,KAAK,gBAAgB,eAAe,cAAc,KAAK;AAE3E,UAAM,QAAQ,IAAI,YAAY,KAAI,MAAK,EAAE,cAAc,CAAC,CAAC;AACzD,WAAO,QAAQ,KAAK,KAAK,KAAK;WACxB;AACN,WAAO,QAAQ,KAAK,KAAK,wBAAwB;;;AAIrD,UAAQ,KAAK,SACX,WACA,cACM;GACN,MAAM,WAAW,cAAc,IAAI,UAAU,IAAI,EAAE;AACnD,YAAS,KAAK,aAAgE;AAC9E,iBAAc,IAAI,WAAW,SAAS;;AAGxC,UAAQ,MAAM,SACZ,WACA,cACM;GACN,MAAM,WAAW,cAAc,IAAI,UAAU;AAC7C,OAAI,UAAU;IACZ,MAAM,QAAQ,SAAS,QAAQ,aAAgE;AAC/F,QAAI,QAAQ,IAAI;AACd,cAAS,OAAO,OAAO,EAAE;AACzB,SAAI,SAAS,WAAW,EACtB,eAAc,OAAO,UAAU;;;;AAMvC,UAAQ,qBAAqB,SAAU,WAA0B;AAC/D,OAAI,UACF,eAAc,OAAO,UAAU;OAE/B,eAAc,OAAO;;AAIzB,SAAO;;;;;;;;;CAUT,AAAQ,eAAe,OAAkC;AACvD,SACE,OAAO,UAAU,YACjB,UAAU,QACV,iBAAiB,SACjB,OAAO,QAAQ,IAAI,OAAO,cAAc,KAAK;;;;;;;;;CAWjD,AAAQ,mBAAmB,SAA+B;AACxD,SAAO;GACL,QAAQ,QAAQ;GAChB,WAAW,QAAQ,QAAQ,IAAI,gCAAgC;GAC/D,WAAW,QAAQ,QAAQ,IAAI,yBAAyB;GACxD,aAAa,YAAY,OAAO,KAAK,MAAM,QAAQ,aAAa,CAAC;GACjE,OAAO,QAAQ,SAAS,IAAI,SAAS,MAAM,EAAE,QAAQ,CAAC;GACvD;;;;;;;;;;CAWH,AAAQ,kBAAkB,iBAAkC,KAAkC;EAC5F,MAAM,kBAAkB,gBAAgB,QAAQ;EAChD,MAAM,YAAY,MAAM,QAAQ,gBAAgB,GAC3C,gBAAgB,MAAM,OACrB,mBAAmB;EACzB,MAAM,kBAAkB,gBAAgB,QAAQ;EAChD,MAAM,YAAY,MAAM,QAAQ,gBAAgB,GAC3C,gBAAgB,MAAM,OACrB,mBAAmB;AACzB,SAAO;GACL,QAAQ,gBAAgB,UAAU;GAClC;GACA;GACA,aAAa,YAAY;IACvB,MAAMC,SAAmB,EAAE;AAC3B,eAAW,MAAM,SAAS,gBACxB,QAAO,KAAK,OAAO,KAAK,MAAM,CAAC;AAEjC,WAAO,OAAO,OAAO,OAAO;;GAE9B,OAAO,QAAQ,SAAS;AACtB,QAAI,aAAa;AACjB,QAAI,IAAI,KAAK;;GAEhB;;;;;;;;;;CAWH,AAAQ,eAAe,kBAA6C,UAAwC;AAC1G,SAAO,KAAK,eAAe,iBAAiB,GACxC,KAAK,mBAAmB,iBAAiB,GACzC,KAAK,kBAAkB,kBAAqC,SAA2B;;;;;;;;;;;;CAa7F,AAAQ,qBACN,SACA,WACA,iBACsB;EACtB,MAAM,aAAa,KAAK,0BAA0B,QAAQ;EAC1D,MAAM,YAAY,WAAW,oBAAoB;AAGjD,MAAI,CADa,KAAK,OAAO,SAAS,WAAW,UAAU,CAEzD,OAAM,IAAI,MAAM,4BAA4B;AAG9C,SAAO;;;;;;;;CAST,AAAQ,0BAA0B,SAAuE;AACvG,SAAO,KAAK,MAAM,QAAQ,UAAU,CAAC;;;;;;;;;;CAWvC,AAAQ,gBACN,eACA,WACmD;EACnD,MAAM,mBAAmB,cAAc,IAAI,UAAU,IAAI,EAAE;EAC3D,MAAM,mBAAmB,cAAc,IAAI,IAAI,IAAI,EAAE;AACrD,SAAO,CAAC,GAAG,kBAAkB,GAAG,iBAAiB;;;;;;;;;;;;;CAcnD,AAAO,OAAO,SAAiB,WAAmB,WAAsC;EACtF,MAAM,qBAAqB,OAAO,KAAK,OAAO,WAAW,UAAU,KAAK,OAAO,CAAC,OAAO,QAAQ,CAAC,OAAO,MAAM,CAAC;EAC9G,MAAM,kBAAkB,OAAO,KAAK,UAAU;AAE9C,MAAI,mBAAmB,WAAW,gBAAgB,OAChD,OAAM,IAAI,MAAM,4BAA4B;AAG9C,MAAI,CAAC,OAAO,gBAAgB,oBAAoB,gBAAgB,CAC9D,OAAM,IAAI,MAAM,4BAA4B;AAG9C,MAAI,WAAW;GACb,MAAM,cAAc,OAAO,cAAc,WAAW,SAAS,WAAW,GAAG,GAAG;AAC9E,OAAI,MAAM,YAAY,CACpB,OAAM,IAAI,MAAM,8BAA8B,YAAY;AAI5D,OAFiB,KAAK,qBAAI,IAAI,MAAM,EAAC,SAAS,GAAG,YAAY,GAE9C,MAAO,GACpB,OAAM,IAAI,MAAM,4BAA4B;;AAIhD,SAAO;;;;;;;;;;CAWT,AAAO,UAAU,SAAiB,WAAmB,WAAmD;AAEtG,MAAI,CADa,KAAK,OAAO,SAAS,WAAW,UAAU,CAEzD,OAAM,IAAI,MAAM,4BAA4B;AAG9C,SAAO,KAAK,0BAA0B,QAAQ"}