## Проверка подписи

Каждый запуск — 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 **всей строки с префиксом**, не декодируйте суффикс. Храните её только в защищённой конфигурации сервера. Получить секрет можно один раз при [создании](./create.md) и [замене](./rotate-secret.md), в списке и истории его нет.

Подписано `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.

### Проверяющий код

<!-- scheduled-job-verifier:start -->
```ts
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)
}
```
<!-- scheduled-job-verifier:end -->

### Чтение HTTP без преобразования тела

Не используйте уже склеенные headers: считайте физические заголовки в rawHeaders. Регистр имени HTTP-заголовка несущественен.

<!-- scheduled-job-http-adapter:start -->
```ts
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
}
```
<!-- scheduled-job-http-adapter:end -->

Свяжите функции до разбора JSON; req — IncomingMessage. Серверные часы должны быть синхронизированы. Превышение размера отклоните с 413, неверную подпись/caller — с 401, невалидное тело — с 400. Не логируйте секрет, подпись или тело.

```ts
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.

```text
secret = vjs_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8
Vibe-Signature: t=1791536400,v1=47f5954d1810b796a982c0eb461a948f4e80d531212cac1a4668321d24ea3008
```

```json
{"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.

```sql
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 читается сохранённый ответ, в том числе после конкурентного конфликта.

```sql
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.

[Пример дайджеста](./daily-digest.md) связывает проверку, постановку работы и наблюдение за окончанием.
