Для AI-агентов: markdown этой страницы — /docs-content/infra/jobs/signature.md индекс документации — /llms.txt
Проверка подписи
Каждый запуск — POST на указанный path, с Content-Type: application/json, X-Vibe-Caller-Kind: schedule и Vibe-Signature. Даже ручной запуск использует caller schedule; поле trigger отличает manual от schedule. Принимайте запрос только через доверенный платформенный вход: прямой публичный обход, где клиент может сам задать caller, должен быть закрыт или проверять тот же контракт. Caller сам по себе не заменяет подпись.
Байты и секрет
Секрет создаётся из 32 случайных байт и выдаётся как vjs_ плюс 43 символа base64url без padding, всего 47 символов. Для HMAC используйте UTF-8 всей строки с префиксом, не декодируйте суффикс. Храните её только в защищённой конфигурации сервера. Получить секрет можно один раз при создании и замене, в списке и истории его нет.
Подписано ASCII(t) + b'.' + rawBody, алгоритм HMAC-SHA256, результат — 64 lowercase hex. t — Unix-секунды отправки; новый на каждой попытке. UTF-8 JSON без BOM и без завершающего перевода строки содержит ключи в указанном порядке: type, jobId, runId, scheduledFor, attempt, late, trigger. scheduledFor — UTC ISO-8601 с миллисекундами; attempt 1–4; late — сохранённая оценка первой попытки (позже срока более чем на 60 секунд); trigger — schedule или manual.
Проверяйте точную форму единственного физического заголовка, свежесть abs(now-t) <= 300 секунд и сравнивайте HMAC в постоянное время. Повторные t/v1, лишние поля, uppercase hex, небезопасное целое и дубликат заголовка отвергаются. Читайте rawBody до JSON.parse; повторная сериализация меняет подписанные байты. Ограниченный reader ниже принимает не больше 64 KiB.
Проверяющий код
import { createHmac, timingSafeEqual } from 'node:crypto'
export interface VerifyJobRequestInput {
callerKind: unknown
singleSignatureHeader: unknown
rawBody: Buffer
secret: string
nowSeconds: number
}
export function verifyJobRequest(x: VerifyJobRequestInput): boolean {
if(x.callerKind!=='schedule'||typeof x.singleSignatureHeader!=='string') return false
const m=/^t=(0|[1-9][0-9]{0,15}),v1=([0-9a-f]{64})$/.exec(x.singleSignatureHeader)
if(!m||m[0]!==x.singleSignatureHeader) return false
const t=Number(m[1])
if(!Number.isSafeInteger(t)||!Number.isFinite(x.nowSeconds)||Math.abs(x.nowSeconds-t)>300) return false
const actual=Buffer.from(m[2],'hex')
const expected=createHmac('sha256',Buffer.from(x.secret,'utf8'))
.update(m[1],'ascii').update('.','ascii').update(x.rawBody).digest()
return actual.length===expected.length&&timingSafeEqual(actual,expected)
}
Чтение HTTP без преобразования тела
Не используйте уже склеенные headers: считайте физические заголовки в rawHeaders. Регистр имени HTTP-заголовка несущественен.
import type { IncomingMessage } from 'node:http'
export async function readRawBody(req: IncomingMessage): Promise<Buffer> {
const chunks:Buffer[]=[]; let length=0
for await(const chunk of req){
const b=Buffer.isBuffer(chunk)?chunk:Buffer.from(chunk)
length+=b.length
if(length>65536) throw new Error('BODY_TOO_LARGE')
chunks.push(b)
}
return Buffer.concat(chunks,length)
}
export function singleHeader(req: IncomingMessage,name:string): string|undefined {
const values:string[]=[]
for(let i=0;i<req.rawHeaders.length;i+=2)
if(req.rawHeaders[i].toLowerCase()===name.toLowerCase()) values.push(req.rawHeaders[i+1])
return values.length===1?values[0]:undefined
}
Свяжите функции до разбора JSON; req — IncomingMessage. Серверные часы должны быть синхронизированы. Превышение размера отклоните с 413, неверную подпись/caller — с 401, невалидное тело — с 400. Не логируйте секрет, подпись или тело.
const rawBody = await readRawBody(req);
const valid = verifyJobRequest({
callerKind: singleHeader(req, 'X-Vibe-Caller-Kind'),
singleSignatureHeader: singleHeader(req, 'Vibe-Signature'),
rawBody,
secret: process.env.JOB_SIGNING_SECRET!,
nowSeconds: Math.floor(Date.now() / 1000),
});
if (!valid) { res.writeHead(401).end(); return; }
const payload = JSON.parse(rawBody.toString('utf8'));
// Validate the payload schema and expected jobId before durable acceptance.
Эталонный вектор
Публичные тестовые значения ниже не являются рабочим секретом. Тело — ровно 158 байт без перевода строки; тестовые часы — 1791536400.
secret = vjs_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8
Vibe-Signature: t=1791536400,v1=47f5954d1810b796a982c0eb461a948f4e80d531212cac1a4668321d24ea3008
{"type":"scheduled_job.run","jobId":"job_vector","runId":"run_vector","scheduledFor":"2026-10-09T09:00:00.000Z","attempt":1,"late":false,"trigger":"schedule"}
Замена секрета
Каждая новая попытка подписывается текущим секретом. Уже отправленная попытка может иметь старую подпись: приложение может сохранить предыдущий ключ на короткое 5-минутное окно и проверить оба ключа либо принять отказ. Ротация не отменяет RUNNING, не переподписывает отправленный запрос и не восстанавливает постоянные app-401/403. Предыдущий ключ и срок его удаления приложение хранит самостоятельно.
Долговечная дедупликация и outbox
После проверки подписи атомарно примите runId и поставьте работу в устойчивую очередь. Все реплики обработчика используют одну БД. Map/Set в памяти и схема «проверить, затем вставить» допускают повторную бизнес-операцию. PostgreSQL-пример ниже возвращает 202 только после commit.
CREATE TABLE accepted_runs (
run_id text PRIMARY KEY,
accepted_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
response_status integer NOT NULL CHECK (response_status BETWEEN 200 AND 299),
response_body jsonb NOT NULL
);
CREATE TABLE job_outbox (
run_id text PRIMARY KEY REFERENCES accepted_runs(run_id),
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);
$1 — проверенный runId, $2 — JSON проверенного тела. Передавайте параметры драйверу, не подставляйте строки в SQL. Выполните все команды на одном соединении; при ошибке — ROLLBACK, не возвращайте 2xx. В отдельном SELECT после INSERT читается сохранённый ответ, в том числе после конкурентного конфликта.
BEGIN;
WITH accepted AS (
INSERT INTO accepted_runs(run_id, response_status, response_body)
VALUES ($1, 202, '{"accepted":true}'::jsonb)
ON CONFLICT (run_id) DO NOTHING
RETURNING run_id
)
INSERT INTO job_outbox(run_id, payload)
SELECT run_id, $2::jsonb FROM accepted;
-- A separate statement sees a concurrent winner's committed receipt.
SELECT response_status, response_body FROM accepted_runs WHERE run_id = $1;
COMMIT;
Верните сохранённые response_status/response_body после успешного commit; повтор принятого runId не создаёт второй outbox и возвращает тот же 2xx. Если процесс упал после commit до ответа, повтор безопасен. Работник outbox забирает работу с блокировкой/арендой и помечает processed_at после выполнения. Для внешнего эффекта используйте runId как idempotency key у получателя: outbox сам по себе не гарантирует ровно один внешний эффект после сбоя.
Держите квитанции принятия не меньше срока хранения журнала платформы: по умолчанию 90 дней, минимум 7 дней, что больше жизни доставки 96 часов. Настройку хранения согласуйте с платформой; не удаляйте запись принятия или outbox для незавершённой работы. Очищайте только обработанные строки после этого срока, сначала outbox, затем accepted_runs. Свежесть подписи ограничивает старый подготовленный запрос пятью минутами, но новые попытки того же runId могут приходить до deliveryDeadlineAt.
Пример дайджеста связывает проверку, постановку работы и наблюдение за окончанием.