@linear/sdk
Version:
The Linear Client SDK for interacting with the Linear GraphQL API
206 lines (204 loc) • 8.49 kB
JavaScript
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 };