Вебхуки
Всё остальное в этой библиотеке зовёт Jira. Вебхук — это Jira зовёт вас: вы регистрируете URL, на сайте что-то происходит, и на ваш сервер приходит POST. Звать тут нечего и клиент строить не нужно — не хватало только формы того, что приходит, и jira.js/webhooks — это она.
import type { WebhookHeaders, WebhookPayload } from 'jira.js/webhooks';
app.post('/jira', (request, response) => {
const headers = request.headers as WebhookHeaders;
const payload = request.body as WebhookPayload;
switch (payload.webhookEvent) {
case 'jira:issue_created':
console.log(payload.issue.key, 'создал', payload.user?.displayName);
break;
case 'sprint_started':
console.log(payload.sprint?.name, 'начался');
break;
}
response.sendStatus(200);
});Подпуть содержит только типы. Он компилируется в export {}, ничего не добавляет к бандлу и одинаково работает с Express, Fastify, Hono, обработчиком Lambda и голым node:http.
Тело
WebhookPayload — объединение, размеченное по webhookEvent, так что switch сужает каждую ветку ровно до одного тела. Разберите все случаи, и default сузится до never — так необработанное событие становится ошибкой компиляции:
default: {
const unhandled: never = payload;
throw new Error(`необработанное событие вебхука: ${JSON.stringify(unhandled)}`);
}Пятьдесят семь событий в шестнадцати группах: задача, свойство задачи, ворклог, комментарий, вложение, связь между задачами, тип задачи, проект, версия, фильтр, пользователь, семь общесайтовых переключателей option_*, спринт, доска, два отказа приложению в доступе и несработавшее выражение Jira. У каждой группы есть свой экспортируемый тип — IssueWebhookPayload, SprintWebhookPayload и так далее, — если нужно назвать его напрямую.
В любом теле есть три поля:
timestamp | когда Jira подняла событие, в миллисекундах от эпохи |
webhookEvent | само событие, и поле, по которому идёт switch |
matchedWebhookIds | каким регистрациям ответила эта доставка — только у вебхуков, зарегистрированных через REST |
Что задокументировано, а что нет
Стоит знать, прежде чем доверять полю: Atlassian публикует одно полное тело — для событий задачи. Про остальные сказано лишь, что колбэк несёт «информацию о сущности, связанной с событием».
Поэтому тело события задачи здесь написано по этому примеру и по захвату настоящей доставки. Это единственная группа с обязательной сущностью:
case 'jira:issue_updated':
payload.issue; // Issue — есть всегда
payload.issue_event_type_name; // 'issue_updated', 'issue_commented', 'issue_generic'…
payload.changelog; // что изменилось
payload.comment; // заполнено, когда правкой был комментарий
break;issue_event_type_name подробнее, чем webhookEvent: правка, комментарий и переход по статусу приходят одинаково — как jira:issue_updated — и различаются только там. Типизировано обычной строкой намеренно: администратор сайта может добавить свои события задачи, так что множество не закрыто.
Все остальные группы называют свою сущность необязательным полем, по имени сущности, к которой относится событие, а не по спецификации: sprint у события спринта, board у события доски, worklog, attachment, project, version, filter. Сверить их с документацией Atlassian было не с чем, поэтому тип заставляет вас проверить, и в объявлении это написано прямо — видно при наведении.
Заголовки
В нижнем регистре, потому что они так и приходят: Node приводит имя каждого входящего заголовка к нижнему регистру, и любой фреймворк поверх него — тоже. Все значения строковые, включая счётчик повторов: чисел в HTTP-заголовке не бывает.
| заголовок | |
|---|---|
x-atlassian-webhook-identifier | уникален для доставки в пределах сайта и не меняется при повторах — записывайте его, чтобы узнавать уже обработанный вебхук |
x-atlassian-webhook-flow | Primary — само событие, тридцать секунд; Secondary — последствия массовой или каскадной операции, пятнадцать минут |
x-atlassian-webhook-retry | сколько было повторов; при первой попытке отсутствует |
x-atlassian-webhook-trace | то, что Connect-приложение приложило к запросу, вызвавшему событие |
x-hub-signature | sha256=…, только у вебхука, зарегистрированного с секретом — передайте в verifyWebhookSignature |
Удаление задачи лучше всего показывает смысл заголовка потока: jira:issue_deleted уходит как Primary, а все зависимые comment_deleted, attachment_deleted и issuelink_deleted — следом как Secondary, возможно через несколько минут.
Разбора здесь нет
Приведения типов выше — и есть интерфейс, намеренно. Тело вебхука определяется сайтом, который его послал: пользовательские поля под сгенерированными ключами в issue.fields, что угодно от установленного приложения, релиз Data Center, отличающийся от Cloud. Схема, достаточно строгая, чтобы её стоило иметь, отвергала бы тела, совершенно правильные где-то ещё. В остальной библиотеке ответ валидируется, потому что API его описывает; здесь описывать нечем.
Проверка подписи
x-hub-signature — единственное, что доказывает, что запрос пришёл от Jira, а не от того, кто нашёл ваш URL. Вебхук, зарегистрированный с секретом, несёт её как sha256=<hex> — HMAC-SHA256 по точным байтам тела.
import express from 'express';
import { verifyWebhookSignature, type WebhookPayload } from 'jira.js/webhooks';
app.post('/jira', express.raw({ type: 'application/json' }), async (request, response) => {
const trusted = await verifyWebhookSignature({
body: request.body,
secret: process.env.JIRA_WEBHOOK_SECRET!,
signature: request.get('x-hub-signature'),
});
if (!trusted) return response.sendStatus(401);
const payload = JSON.parse(request.body.toString()) as WebhookPayload;
response.sendStatus(200);
});Телом должны быть пришедшие байты. Именно здесь проверка обычно и ломается: express.json() и всё ему подобное отдают разобранный объект, а JSON.stringify от него — уже другая последовательность байтов для тех же данных: порядок ключей, пробелы и запись чисел не сохраняются, и подпись не совпадёт никогда. Возьмите то, что ваш фреймворк называет сырым телом.
Ответ — false на любой способ доставке оказаться недостоверной: заголовка нет, алгоритм не sha256, дайджест не шестнадцатеричный, дайджест нужной формы и неверного значения. Ваша реакция на все четыре одинакова, а различать их означало бы различать их и для того, кто прощупывает эндпоинт. Исключение бросается только на пустой секрет — это ошибка ваша, а не провалившаяся проверка.
Сравнение идёт за постоянное время, и ради всего этого ничего не импортируется: crypto.subtle — глобальный объект и в Node, и в браузере, так что сабпать по-прежнему ничего не добавляет к браузерной сборке.
Как зарегистрировать
Два способа, и ведут они себя по-разному:
- Страница администратора,
https://your-domain.atlassian.net/plugins/servlet/webhooks. То, что обычно и называют вебхуком в Jira. Регистрирует человек, живёт, пока кто-нибудь не удалит. - REST API,
POST /rest/api/3/webhook— только для Connect- и OAuth 2.0-приложений, и регистрация протухает через тридцать дней, если её не продлить черезrefreshWebhooks. Именно эти доставки несутmatchedWebhookIds, и библиотека покрывает нужные эндпоинты:jira.webhooks.registerDynamicWebhooks,getDynamicWebhooksForApp,refreshWebhooks,deleteWebhookById.
Справочник самого Atlassian — Webhooks на платформе Jira Cloud.