/**
* @module routes/trigger
* @description CI/CD webhook trigger routes (ENH-011). Mounted at `/api/v1` (INF-005)
* without `requireAuth` — this router handles its own token-based authentication so
* CI pipelines can call it with a per-project Bearer token rather than a user JWT.
*
* ### Endpoints
* | Method | Path | Auth | Description |
* |----------|---------------------------------------------|-------------------|------------------------------------|
* | `POST` | `/api/v1/projects/:id/trigger` | Bearer token | Start a CI/CD test run |
* | `GET` | `/api/v1/projects/:id/trigger-tokens` | JWT (requireAuth) | List tokens — see runs.js |
* | `POST` | `/api/v1/projects/:id/trigger-tokens` | JWT (requireAuth) | Create token — see runs.js |
* | `DELETE` | `/api/v1/projects/:id/trigger-tokens/:tid` | JWT (requireAuth) | Revoke token — see runs.js |
*
* Token management endpoints (list/create/delete) live in `runs.js` and are
* protected by `requireAuth`. Only `POST /trigger` is here, unprotected.
*/
import { Router } from "express";
import crypto from "node:crypto";
import * as runRepo from "../database/repositories/runRepo.js";
import * as environmentRepo from "../database/repositories/environmentRepo.js";
import * as testRepo from "../database/repositories/testRepo.js";
import * as webhookTokenRepo from "../database/repositories/webhookTokenRepo.js";
import * as githubCheckSettingsRepo from "../database/repositories/githubCheckSettingsRepo.js";
import { generateRunId } from "../utils/idGenerator.js";
import { logActivity } from "../utils/activityLogger.js";
import { runWithAbort } from "../utils/runWithAbort.js";
import { resolveDialsConfig, resolveDialsPrompt } from "../testDials.js";
import { runTests, partitionTestIdsForShards } from "../testRunner.js";
import { runQueue, isQueueAvailable } from "../queue.js"; // CAP-002 Phase 2 — BullMQ fan-out for sharded CI/CD runs.
import { actor } from "../utils/actor.js"; // CAP-002 Phase 2 — actor info threaded into shard job data for activity logs.
import { crawlAndGenerateTests } from "../crawler.js";
import { classifyError } from "../utils/errorClassifier.js";
import { expensiveOpLimiter, signRunArtifacts } from "../middleware/appSetup.js";
import { requireTrigger } from "../middleware/authenticate.js";
import { fireNotifications } from "../utils/notifications.js";
import { validateUrl, safeFetch } from "../utils/ssrfGuard.js";
import { orderTestsByRisk, applyBudgetToQueue, normalizeBudgetMinutes } from "../pipeline/riskScorer.js";
import { computeImpactedTests } from "../pipeline/impactAnalysis.js";
import { createPending, markInProgress, conclude, buildRunUrl, getChangedFilesForPr } from "../integrations/githubChecks.js";
import { findGreenBaseRun, renderGithubCheckSummary, conclusionForRun } from "../utils/runResultFormatters.js";
import { formatLogLine } from "../utils/logFormatter.js";
import { envScopedProject as buildEnvScopedProject } from "../utils/envScope.js"; // DIF-012 — shared helper, see module doc.
import { normalizeShardConfig } from "../utils/shardConfig.js"; // CAP-002 — shared shards + parallelWorkers clamp.
// ─── SSRF protection for callbackUrl ──────────────────────────────────────────
// Two-layer defence provided by utils/ssrfGuard.js:
// 1. validateUrl() — synchronous string checks + async DNS resolution
// to block domains that resolve to private/reserved IPs.
// 2. safeFetch() — fires the actual request with `redirect: "error"` to
// prevent open-redirect bypasses (302 → http://169.254.169.254/…),
// and re-resolves DNS to mitigate DNS rebinding.
/**
* Thin wrapper around safeFetch for the callbackUrl POST.
* Best-effort: errors are silently caught so a failing callback never
* affects the run outcome.
*
* @param {string} url - The validated callbackUrl.
* @param {string} payload - JSON string body.
*/
async function safeFetchCallback(url, payload) {
await safeFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload,
signal: AbortSignal.timeout(10_000),
});
}
const router = Router();
// Trigger-token authentication is handled by `requireTrigger` from
// middleware/authenticate.js — the centralised strategy-pattern middleware.
// It sets req.triggerToken and req.triggerProject on success, with
// detailed error messages (401/403/404) on failure.
// ─── Canonical run-object builders ────────────────────────────────────────────
// Two paths in this file create a `crawl`-type run (POST /trigger with
// `triggerCrawl: true`, and the Vercel/Netlify webhook handlers via
// `launchPreviewCrawl`). Two paths used to construct that same conceptual
// object with different field sets — both worked because `runRepo.create`
// binds missing INSERT_COLS as NULL and `rowToRun` defaults them on read,
// but the drift made it easy to introduce subtle behaviour differences
// (e.g. `tests: []` was missing from one path until a TypeError caught it,
// see PR #12 history). One builder per run type, used by every caller.
//
// Shapes mirror the canonical entries in `routes/runs.js`:
// - buildCrawlRun → matches runs.js:71-83 (POST /projects/:id/crawl)
// - buildTestRun → matches runs.js:161-179 (POST /projects/:id/run)
/**
* Build a `type: "crawl"` run object aligned with `routes/runs.js:71-83`.
*
* @param {object} args
* @param {string} args.runId
* @param {object} args.project - must carry `id` and (optionally) `workspaceId`.
* @param {object} [args.dialsConfig] - validated dials (output of
* `resolveDialsConfig(...)`). When present, persisted on the run record
* as `generateInput: { dialsConfig }` so the RunDetail page can show
* which dials drove the crawl and the MNT-010 re-run feature can
* replicate the same configuration. Mirrors `runs.js:81`.
* @param {string|null} [args.environmentId] - DIF-012: persisted on the run
* record so trigger-initiated crawl runs surface in the dashboard's
* per-environment aggregation alongside their UI-initiated counterparts.
* Mirrors `runs.js:126` on the canonical POST /crawl path.
* @returns {object} the run record ready for `runRepo.create()`.
*/
function buildCrawlRun({ runId, project, dialsConfig, environmentId = null }) {
return {
id: runId,
projectId: project.id,
type: "crawl",
status: "running",
startedAt: new Date().toISOString(),
logs: [],
// tests[] is required by persistGeneratedTests (testPersistence.js)
// which calls `run.tests.push(testId)` during the crawl pipeline.
tests: [],
pagesFound: 0,
// Mirror runs.js:81 so RunDetail + re-run can read back the dials.
// `undefined` (rather than `null`) so runRepo's INSERT_COLS filter drops
// the column entirely on no-dials calls — matches the canonical path.
generateInput: dialsConfig ? { dialsConfig } : undefined,
workspaceId: project.workspaceId || null,
environmentId,
};
}
/**
* Build a `type: "test_run"` run object aligned with `routes/runs.js:188-213`.
*
* AUTO-001: persisted `testQueue` mirrors the approved-test order (audit
* fidelity) with per-row `riskScore`; budget-skipped tests are pre-seeded
* into `results` as `skipped (over_budget)` so every approved test has an
* observable resolution. `total` reflects the approved set, not the
* post-budget dispatch slice.
*
* @param {object} args
* @param {string} args.runId
* @param {object} args.project - must carry `id` and (optionally) `workspaceId`.
* @param {object[]} args.tests - The full approved-test set (persisted order).
* @param {object[]} [args.budgetSkipped] - Tests truncated by `budgetMinutes`.
* @param {object[]} [args.impactSkipped] - Tests skipped by impact analysis.
* @param {Map<string, number>} [args.riskById] - testId → riskScore lookup.
* @param {number|null} [args.budgetMinutes] - Normalized budget actually applied.
* @param {string[]} [args.changedFiles] - Git diff files used for impact analysis.
* @param {Object|null} [args.impactAnalysis] - Resolved impact-analysis summary.
* @param {number} args.parallelWorkers
* @param {number} [args.shardCount=1] - CAP-002: explicit shard request from
* the caller (`req.body.shards`). Defaults to 1 so legacy callers that
* never pass `shards` don't surface a misleading shard badge — see
* `routes/runs.js` (BUG-0001 rationale).
* @returns {object} the run record ready for `runRepo.create()`.
*/
function buildTestRun({
runId,
project,
tests,
budgetSkipped = [],
impactSkipped = [],
riskById,
budgetMinutes = null,
parallelWorkers,
shardCount = 1,
githubCheck = null,
changedFiles = [],
impactAnalysis = null,
environmentId = null,
}) {
const lookup = riskById || new Map();
const initialResults = [
...impactSkipped.map((t) => ({
testId: t.id,
testName: t.name,
status: "skipped",
skipReason: "skipped_no_impact",
// AUTO-004: `impactSkipped` is sourced from the raw approved-tests
// array (no `riskScore` attached). Look up the score from the
// risk-ordered queue when available so persisted skip rows mirror
// the budget-skipped shape; fall back to `null` to keep the column
// present rather than `undefined` (which JSON-drops the field).
riskScore: lookup.get(t.id) ?? null,
})),
...budgetSkipped.map((t) => ({
testId: t.id,
testName: t.name,
status: "skipped",
skipReason: "over_budget",
riskScore: t.riskScore,
})),
];
return {
id: runId,
projectId: project.id,
type: "test_run",
status: "running",
startedAt: new Date().toISOString(),
logs: [],
results: initialResults,
passed: 0,
failed: 0,
total: tests.length,
parallelWorkers,
shardCount,
shardsCompleted: 0,
testQueue: tests.map((t) => ({
id: t.id, name: t.name, steps: t.steps || [],
riskScore: lookup.get(t.id) ?? null,
})),
budgetMinutes,
workspaceId: project.workspaceId || null,
githubCheck,
changedFiles,
impactAnalysis,
environmentId,
};
}
function normalizeGithubPayload(body = {}) {
const repo = typeof body.repo === "string" ? body.repo.trim()
: body.repository?.full_name ? String(body.repository.full_name).trim() : "";
// INT-002: extract `head_sha` from any of the four canonical payload shapes:
// - `pull_request.head.sha` → `pull_request.{opened,synchronize,…}` events
// - `check_suite.head_sha` → `check_suite.{requested,rerequested}` events
// - `check_run.head_sha` → `check_run.rerequested` (user clicks "Re-run this check"
// on a single check, distinct from re-running the whole suite)
// - body-level `sha` override → non-GitHub CI callers (Jenkins / GitLab / generic)
const sha = typeof body.sha === "string" ? body.sha.trim()
: body.check_run?.head_sha ? String(body.check_run.head_sha).trim()
: body.check_suite?.head_sha ? String(body.check_suite.head_sha).trim()
: body.pull_request?.head?.sha ? String(body.pull_request.head.sha).trim() : "";
// Base SHA is only available on `pull_request` events — `check_run` /
// `check_suite` payloads don't carry the merge-base, so the regressed-test
// diff falls back to "all failing" on those events (correct behaviour:
// a re-run request doesn't change the base, so the original PR delivery
// already established the green-base reference in `findGreenBaseRun`).
const baseSha = typeof body.baseSha === "string" ? body.baseSha.trim()
: body.pull_request?.base?.sha ? String(body.pull_request.base.sha).trim() : null;
// `check_run.pull_requests[0].number` carries the PR number on check-run
// rerequest events when the check belongs to a PR (vs. a branch push).
const prNumber = Number.isInteger(body.prNumber) ? body.prNumber
: Number.isInteger(body.number) ? body.number
: Number.isInteger(body.pull_request?.number) ? body.pull_request.number
: Number.isInteger(body.check_run?.pull_requests?.[0]?.number) ? body.check_run.pull_requests[0].number : null;
return { repo, sha, baseSha, prNumber };
}
/**
* Prepare a GitHub Check Run for a Sentri run, returning the metadata to
* persist on `run.githubCheck` (or `null` when no check should be created).
*
* **Idempotency contract (INT-002):**
* GitHub retries failed webhook deliveries (any non-2xx) with exponential
* backoff for up to 24h, always using the same `X-GitHub-Delivery` UUID.
* The delivery ID — not the commit SHA — is the correct idempotency key:
* - Same delivery ID → GitHub is retrying. Return existing check, do not
* create a new run, do not transition state on GitHub.
* - New delivery ID for the same SHA → distinct event (e.g. `rerequested`
* after a user clicks "Re-run"). Create a fresh Check Run.
*
* @param {Object} project
* @param {Object} body — trigger request body (already merged with normalizeGithubPayload).
* @param {string} runId
* @param {string|null} deliveryId — value of the `X-GitHub-Delivery` header, when present.
* @returns {Promise<Object|null>}
*/
async function prepareGithubCheck(project, body, runId, deliveryId = null) {
const payload = normalizeGithubPayload(body);
if (!payload.repo || !payload.sha) return null;
const settings = githubCheckSettingsRepo.getByProjectId(project.id);
if (!settings?.enabled) return null;
if (settings.repo && settings.repo !== payload.repo) return null;
if (!settings.installationId) throw new Error("GitHub installationId is not configured for this project");
// Retry-delivery idempotency: same X-GitHub-Delivery ⇒ same check, no
// state transition. The caller (handleTrigger) short-circuits when it
// detects `reused: true` so the underlying Sentri run is also skipped.
if (deliveryId) {
const existing = runRepo.findByGithubDeliveryId(project.id, deliveryId);
if (existing?.githubCheck?.checkRunId) {
return { ...existing.githubCheck, reused: true, reusedRunId: existing.id };
}
}
const checkRun = await createPending(runId, {
repo: payload.repo,
sha: payload.sha,
installationId: settings.installationId,
});
await markInProgress(checkRun.id, { repo: payload.repo, installationId: settings.installationId });
return {
checkRunId: checkRun.id,
deliveryId: deliveryId || null,
repo: payload.repo,
sha: payload.sha,
baseSha: payload.baseSha,
prNumber: payload.prNumber,
installationId: settings.installationId,
status: "in_progress",
createdAt: new Date().toISOString(),
};
}
/**
* Conclude a GitHub Check Run once the Sentri run reaches a terminal state.
*
* **Placement rationale (deviates from NEXT.md INT-002 sketch):**
* NEXT.md suggests this hook lives in `testRunner.js` `onComplete`. We keep
* it here because (a) `testRunner.js` has no internal completion hook —
* `runWithAbort.onComplete` *is* the hook, fired from this file already;
* (b) only the trigger path carries GitHub repo/sha context, so wiring it
* through the runner would require threading `run.githubCheck` plumbing
* into a module that has no other reason to know about integrations;
* (c) keeping integration side-effects at the route layer matches the
* pattern used by `fireNotifications` (FEA-001) just above this call.
* Errors are always logged + swallowed so a GitHub outage never fails the
* underlying Sentri run (INT-002 anti-pattern guard).
*
* @param {Object} finishedRun
* @param {Object} project
*/
export async function concludeGithubCheck(finishedRun, project) {
const check = finishedRun?.githubCheck;
if (!check?.checkRunId || !check.repo || !check.installationId) return;
try {
// INT-002 perf: use the lean accessor instead of `getByProjectId()` —
// the latter loads every historical run with full JSON deserialization
// (testQueue, promptAudit, qualityAnalytics, …) which becomes a real
// latency cost on projects with hundreds of runs. The base-run lookup
// only needs id/type/status/failed/githubCheck/results, bounded to the
// 25-run lookback `findGreenBaseRun` already enforces.
const projectRuns = runRepo.getRecentTestRunsForGithubBase(project.id);
const baseRun = findGreenBaseRun(projectRuns, check.baseSha, check.repo);
const summaryMd = renderGithubCheckSummary(finishedRun, { baseRun, runUrl: buildRunUrl(finishedRun.id) || "" });
await conclude(check.checkRunId, {
repo: check.repo,
installationId: check.installationId,
conclusion: conclusionForRun(finishedRun),
summaryMd,
});
runRepo.update(finishedRun.id, { githubCheck: { ...check, status: "completed", conclusion: conclusionForRun(finishedRun), completedAt: new Date().toISOString() } });
} catch (err) {
// INT-002 anti-pattern guard: a GitHub 5xx must never fail the underlying
// Sentri run — log and swallow.
console.error(formatLogLine("warn", finishedRun.id, `[github-checks] Failed to conclude check: ${err.message}`));
}
}
function normalizeChangedFiles(values) {
if (!Array.isArray(values)) return null;
return [...new Set(values.map((v) => String(v || "").trim()).filter(Boolean))];
}
async function resolveChangedFiles(project, body, runId) {
const override = normalizeChangedFiles(body?.changedFiles);
if (override) return { changedFiles: override, fallbackReason: null };
const payload = normalizeGithubPayload(body || {});
if (!payload.repo || !payload.prNumber) return { changedFiles: null, fallbackReason: "no_changed_files" };
const settings = githubCheckSettingsRepo.getByProjectId(project.id);
if (!settings?.installationId) return { changedFiles: null, fallbackReason: "no_changed_files" };
try {
return {
changedFiles: await getChangedFilesForPr({
repo: payload.repo,
prNumber: payload.prNumber,
installationId: settings.installationId,
}),
fallbackReason: null,
};
} catch (err) {
console.error(formatLogLine("warn", runId, `[impact-analysis] Failed to fetch GitHub PR files: ${err.message}`));
return { changedFiles: null, fallbackReason: "github_fetch_failed" };
}
}
/**
* POST /api/projects/:id/trigger
* Token-authenticated endpoint for CI/CD pipelines (ENH-011).
*
* ### Authentication
* Pass the project trigger token as a Bearer token:
* ```
* Authorization: Bearer <plaintext-token>
* ```
* This endpoint does NOT accept JWTs — only tokens created via
* `POST /api/projects/:id/trigger-tokens`.
*
* ### Request body (all fields optional)
* ```json
* {
* "callbackUrl": "https://ci.example.com/hooks/sentri",
* "dialsConfig": { "parallelWorkers": 2 }
* }
* ```
*
* ### Response `202 Accepted`
* ```json
* { "runId": "RUN-42", "statusUrl": "https://sentri.example.com/api/runs/RUN-42" }
* ```
* Poll `statusUrl` until `status` is no longer `"running"`.
*
* ### Error responses
* | Code | Reason |
* |------|------------------------------------------------|
* | 400 | No approved tests |
* | 401 | Missing or invalid Bearer token |
* | 403 | Token belongs to a different project |
* | 404 | Project not found |
* | 409 | Another run already in progress |
* | 429 | Rate limit exceeded (expensiveOpLimiter) |
*
* @param {Object} req - Express request
* @param {Object} res - Express response
*/
async function handleTrigger(req, res) {
const { triggerToken: tokenRow, triggerProject: project } = req;
const environmentId = req.body?.environmentId || null;
const environment = environmentId ? environmentRepo.getById(environmentId) : null;
if (environmentId && (!environment || environment.projectId !== project.id)) {
return res.status(400).json({ error: "invalid environmentId" });
}
// ── 3. Extract and validate optional config (async) ────────────────
// callbackUrl validation includes DNS resolution, so it must happen
// BEFORE the synchronous concurrent-run guard to avoid a TOCTOU race
// (an await between the guard and runRepo.create would let a second
// request slip through).
const { dialsConfig, callbackUrl, budgetMinutes } = req.body || {};
if (callbackUrl && typeof callbackUrl === "string") {
// Length cap — prevent abuse via extremely long URLs
if (callbackUrl.length > 2048) {
return res.status(400).json({ error: "callbackUrl exceeds maximum length (2048 characters)." });
}
const urlErr = await validateUrl(callbackUrl);
if (urlErr) return res.status(400).json({ error: urlErr });
}
// SSRF protection for previewUrl — same DNS-resolving validation used for
// callbackUrl. Without this, a valid trigger token could redirect the
// browser crawl at an internal address (e.g. cloud metadata, RFC1918).
if (req.body?.previewUrl && typeof req.body.previewUrl === "string") {
if (req.body.previewUrl.length > 2048) {
return res.status(400).json({ error: "previewUrl exceeds maximum length (2048 characters)." });
}
const previewErr = await validateUrl(req.body.previewUrl);
if (previewErr) return res.status(400).json({ error: previewErr });
}
const validatedDials = resolveDialsConfig(dialsConfig);
// CAP-002: shardCount and parallelWorkers are independent — see
// `utils/shardConfig.js` for the full BUG-0001 rationale. Shared with
// `routes/runs.js` so both entry points apply identical semantics.
const { shardCount, parallelWorkers } = normalizeShardConfig(req.body?.shards, validatedDials?.parallelWorkers);
// AUTO-002 / AUTO-015: honour dialsConfig on the crawl path too — `runs.js`
// already derives these from the same `validatedDials` and forwards them to
// crawlAndGenerateTests at runs.js:108. Without this the trigger path
// silently runs every crawl with defaults regardless of caller config.
const dialsPrompt = resolveDialsPrompt(dialsConfig);
const testCount = validatedDials?.testCount || "ai_decides";
const explorerMode = validatedDials?.exploreMode || "crawl";
const explorerTuning = {
maxStates: validatedDials?.exploreMaxStates ?? 30,
maxDepth: validatedDials?.exploreMaxDepth ?? 3,
maxActions: validatedDials?.exploreMaxActions ?? 8,
actionTimeout: validatedDials?.exploreActionTimeout ?? 5000,
};
// ── 3b. GitHub delivery-retry idempotency (INT-002) ───────────────────
// GitHub retries non-2xx webhook deliveries with the same X-GitHub-Delivery
// UUID for up to 24h. If we've already started a run for this exact
// delivery, ack with the existing runId + checkRunId and DO NOT create a
// second Sentri run. This is the only correct "duplicate" — same SHA from
// a distinct delivery (e.g. `check_suite.rerequested`) is a fresh event.
const githubDeliveryId = req.githubDeliveryId || null;
if (githubDeliveryId) {
const dup = runRepo.findByGithubDeliveryId(project.id, githubDeliveryId);
if (dup?.githubCheck?.checkRunId) {
const proto = req.headers["x-forwarded-proto"] || req.protocol;
const host = req.headers["x-forwarded-host"] || req.get("host");
return res.status(202).json({
runId: dup.id,
statusUrl: `${proto}://${host}/api/v1/projects/${project.id}/trigger/runs/${dup.id}`,
githubCheck: { checkRunId: dup.githubCheck.checkRunId, reused: true },
});
}
}
// ── 3c. Resolve git-diff changed files (async) ───────────────────────
// AUTO-004: `resolveChangedFiles` may hit the GitHub PR Files API, which
// is an async network call. It MUST happen BEFORE the synchronous
// concurrent-run guard below — same TOCTOU rationale as the callbackUrl
// / previewUrl validation above. An await between the guard and
// `runRepo.create()` would yield the event loop and let a second
// concurrent request slip past the guard, creating duplicate runs.
const triggerCrawl = req.body?.triggerCrawl === true;
const previewUrl = typeof req.body?.previewUrl === "string" ? req.body.previewUrl : null;
const runId = generateRunId();
const { changedFiles, fallbackReason: changedFilesFallback } = triggerCrawl
? { changedFiles: null, fallbackReason: "crawl_run" }
: await resolveChangedFiles(project, req.body || {}, runId);
// ── 4. Guard: no concurrent run ───────────────────────────────────────
// From here to runRepo.create() the code is fully synchronous, so no
// other request can interleave and pass the same guard.
const existingRun = runRepo.findActiveByProjectId(project.id);
if (existingRun) {
return res.status(409).json({
error: `A run is already in progress (${existingRun.id}).`,
runId: existingRun.id,
});
}
const allTests = testRepo.getByProjectId(project.id);
const tests = allTests.filter((t) => t.reviewStatus === "approved");
// AUTO-001: risk-ordered + budget-capped dispatch set. `tests` (approved order)
// is preserved for the audit-trail `testIds` snapshot below; reorder is for
// DISPATCH only.
//
// History is bounded to the 20 most recent completed test runs via the lean
// accessor `getRecentCompletedWithResults` (id/type/status/startedAt/results
// only — no testQueue/promptAudit/qualityAnalytics blobs). The scorer caps
// its per-test window at the 10 newest results anyway (`riskScorer.js`
// `rows.slice(0, 10)` — newest-first), so 20 runs gives ample headroom
// while keeping memory bounded on projects with hundreds of historical runs.
const RISK_HISTORY_RUN_LIMIT = 20;
const recentRuns = runRepo.getRecentCompletedWithResults(project.id, RISK_HISTORY_RUN_LIMIT);
const history = recentRuns.flatMap((r) => Array.isArray(r.results) ? r.results : []);
// Lean lookup — single-row SQL with `LIMIT 1`, no full-table scan.
const latestCrawl = runRepo.getLatestCrawlWithChangedPages(project.id);
const changedPages = (latestCrawl?.changedPages || []).map((p) => p?.url || p).filter(Boolean);
const safeBudget = normalizeBudgetMinutes(budgetMinutes);
const routeMap = req.body?.routeMap && typeof req.body.routeMap === "object" && !Array.isArray(req.body.routeMap)
? req.body.routeMap
: {};
const impact = computeImpactedTests({ tests, changedFiles, changedPages, routeMap });
if (changedFilesFallback === "github_fetch_failed") impact.fallbackReason = "github_fetch_failed";
const impactedIdSet = new Set(impact.impactedTestIds);
const impactScopedTests = impact.fallbackReason === "no_changed_files" || impact.fallbackReason === "github_fetch_failed"
? tests
: tests.filter((t) => impactedIdSet.has(t.id));
const riskOrderedTests = orderTestsByRisk(impactScopedTests, history, { changedPages, changedFiles: changedFiles || [], routeMap });
const { kept: selectedTests, skipped: budgetSkipped } = applyBudgetToQueue(riskOrderedTests, safeBudget);
const riskById = new Map(riskOrderedTests.map((t) => [t.id, t.riskScore]));
// AUTO-004: seed `skipped_no_impact` markers for every approved test that
// wasn't dispatched. This covers BOTH the full no-impact case
// (`fallbackReason === "no_impact"` — zero matches) AND the partial-match
// case (`fallbackReason === null` — some matches, some not). Without the
// partial-match branch, non-impacted tests would be silently dropped from
// the run (filtered out at `impactScopedTests` above but never recorded
// as a resolution), violating the AGENT.md "every approved test gets a
// resolution" rule and breaking the pass-rate denominator. Skipped only
// when the run is full-suite (`no_changed_files` / `github_fetch_failed`
// / `crawl_run`) where every test is dispatched.
const impactSkipped = !triggerCrawl
&& (impact.fallbackReason === "no_impact" || impact.fallbackReason === null)
? tests.filter((t) => !impactedIdSet.has(t.id))
: [];
if (!triggerCrawl) {
if (!allTests.length) return res.status(400).json({ error: "No tests found — crawl first." });
if (!tests.length) return res.status(400).json({ error: "No approved tests — review generated tests before triggering." });
}
// ── 6. Create and start the run ──────────────────────────────────────
// One canonical builder per run type. Both shapes match the equivalents
// in `routes/runs.js` so test_run / crawl runs created via this endpoint
// are byte-identical to the ones POST /run and POST /crawl produce.
const run = triggerCrawl
? buildCrawlRun({ runId, project, dialsConfig: validatedDials, environmentId: environment?.id || null })
: buildTestRun({
runId,
project,
tests,
budgetSkipped,
impactSkipped,
riskById,
budgetMinutes: safeBudget,
parallelWorkers,
shardCount,
changedFiles: changedFiles || [],
impactAnalysis: impact,
environmentId: environment?.id || null,
});
runRepo.create(run);
if (!triggerCrawl) {
// INT-002: GitHub Check Run setup is best-effort. A GitHub outage / 5xx
// / misconfiguration must never block the underlying Sentri run — the
// run itself is the source of truth; the PR check is a notification
// surface. Failures here are logged and swallowed, mirroring the
// `concludeGithubCheck` contract on the completion side.
try {
run.githubCheck = await prepareGithubCheck(project, req.body || {}, runId, githubDeliveryId);
if (run.githubCheck) runRepo.update(runId, { githubCheck: run.githubCheck });
} catch (err) {
console.error(formatLogLine("warn", runId, `[github-checks] Failed to create pending check: ${err.message}`));
}
}
// Record that this token was used (updates lastUsedAt)
webhookTokenRepo.touch(tokenRow.id);
// AUTO-002 / AUTO-015: when `triggerCrawl` is true we dispatch through
// `crawlAndGenerateTests`, so the activity rows must use the `crawl.*`
// type family (matching `runs.js:85-88`). Otherwise dashboard analytics
// that group by activity type miscount crawls as test runs and the
// detail text ("0 tests") is misleading on a fresh-project crawl.
logActivity({
type: triggerCrawl ? "crawl.start" : "test_run.start",
projectId: project.id,
projectName: project.name,
workspaceId: project.workspaceId,
detail: triggerCrawl
? `CI/CD triggered crawl${previewUrl ? ` — ${previewUrl}` : ""}`
: `CI/CD triggered test run — ${selectedTests.length} of ${tests.length} test${tests.length !== 1 ? "s" : ""}${impactSkipped.length ? ` (${impactSkipped.length} skipped no impact)` : ""}${budgetSkipped.length ? ` (${budgetSkipped.length} skipped over budget)` : ""}${parallelWorkers > 1 ? ` (${parallelWorkers}x parallel)` : ""}`,
status: "running",
});
// AUTO-015b: if this is a deployment-preview crawl (`triggerCrawl: true` +
// `previewUrl`), also emit the `crawl.start.deployment` marker so the
// "Last deployment run" badge on the project header surfaces CI-pipeline-
// triggered preview crawls — not just provider-webhook ones. The badge
// query (`GET /projects/:id/last-deployment-run`) filters on this exact
// type and reads `meta.runId` to cross-reference the run record.
if (triggerCrawl && previewUrl) {
logActivity({
type: "crawl.start.deployment",
projectId: project.id,
projectName: project.name,
workspaceId: project.workspaceId,
detail: `CI/CD deployment — ${previewUrl}`,
status: "running",
meta: { provider: "ci", previewUrl, runId },
});
}
// CAP-002 Phase 2 — Sharded test-run fan-out for CI/CD.
// When the caller requested `shards: N > 1` AND a BullMQ queue is
// available, enqueue N shard jobs sharing the parent `runId` (same
// pattern as `routes/runs.js:281-330`). Each shard worker calls
// `runTests({ shardIndex })` against its pre-partitioned slice; the
// boundary-crossing shard (whose `incrementShardsCompleted` UPDATE
// returns 1 AND lands the counter at `shardCount`) runs
// `finalizeShardedRun` which fires the feedback loop, status
// transition, `done` SSE, activity log, telemetry, notifications,
// AND `concludeGithubCheck` — so the GitHub PR Check still completes
// even though we bypassed the `runWithAbort.onComplete` hook above.
//
// The single-shard / no-Redis path keeps the existing `runWithAbort`
// flow so `onComplete` → `concludeGithubCheck` + `safeFetchCallback`
// fires for legacy callers. On the sharded BullMQ path, `callbackUrl`
// rides in every shard's job data and `finalizeShardedRun` POSTs it
// exactly once (only the boundary-crossing shard reaches the
// finalizer — guaranteed by the SQL row-lock predicate on
// `incrementShardsCompleted`).
if (!triggerCrawl && shardCount > 1 && isQueueAvailable()) {
try {
const dispatchedIds = selectedTests.map((t) => t.id);
const slices = partitionTestIdsForShards(dispatchedIds, shardCount);
const scopedProject = buildEnvScopedProject(project, environment);
// CAP-002 Phase 2 — `callbackUrl` is included on EVERY shard job's
// options for durability (any shard could be the boundary-crossing
// finalizer), but `finalizeShardedRun` only fires it for the single
// shard whose `incrementShardsCompleted` UPDATE crosses the cap.
// The SQL row-lock predicate guarantees exactly one finalizer, so
// the callback POSTs exactly once even though N shards carry the
// URL in job data. Validated upstream — see callbackUrl checks at
// `backend/src/routes/trigger.js:435-442`.
const normalizedCallbackUrl = typeof callbackUrl === "string" && callbackUrl.length > 0
? callbackUrl
: null;
await Promise.all(slices.map((testIds, shardIndex) =>
runQueue.add("test_run_shard", {
runId,
projectId: scopedProject.id,
type: "test_run_shard",
shardIndex,
shardCount,
options: {
parallelWorkers,
// DIF-002: trigger path doesn't yet expose `browser` in its
// request body; leave null and let `runTests` resolve to
// chromium. When the trigger body grows a `browser` field
// (mirroring runs.js), thread it through here.
browser: null,
device: null,
locale: null,
timezoneId: null,
geolocation: null,
networkCondition: "fast",
testIds,
actorInfo: actor(req),
callbackUrl: normalizedCallbackUrl,
},
}, { jobId: `${runId}:s${shardIndex}` })
));
// Respond 202 immediately — same shape as the runWithAbort path
// produces. CI consumers polling `statusUrl` see the run
// transition through `running` → terminal state driven by the
// BullMQ workers + finalizer.
const proto = req.headers["x-forwarded-proto"] || req.protocol;
const host = req.headers["x-forwarded-host"] || req.get("host");
const statusUrl = `${proto}://${host}/api/v1/projects/${project.id}/trigger/runs/${runId}`;
const response = { runId, statusUrl };
if (run.githubCheck?.checkRunId) {
response.githubCheck = { checkRunId: run.githubCheck.checkRunId, reused: false };
}
return res.status(202).json(response);
} catch (enqueueErr) {
// Redis went away between `isQueueAvailable()` and the enqueue
// round-trip. Mark the run failed (the GitHub Check stays
// in_progress; an operator can re-trigger) and surface 503 so the
// CI pipeline knows to retry. Mirrors `routes/runs.js`.
runRepo.update(runId, {
status: "failed",
error: "Failed to enqueue shards",
finishedAt: new Date().toISOString(),
});
return res.status(503).json({ error: "Job queue unavailable. Please try again." });
}
}
runWithAbort(runId, run,
(signal) => triggerCrawl
// AUTO-002 / AUTO-015: when crawling a preview URL we overwrite
// `project.url` with `previewUrl`, but we MUST preserve the original
// production URL as `canonicalUrl` so the diff-aware baseline guard
// in crawler.js can detect this is a preview crawl and skip
// replacing the production baselines. Without this, the sameOrigin
// check sees preview === preview (both sides equal because project.url
// was already overridden) and silently destroys the real fingerprints.
? crawlAndGenerateTests(
// Shared helper's signature is `(project, environment, { previewUrl })`
// — `previewUrl` always wins over `environment.baseUrl`.
buildEnvScopedProject(project, environment, { previewUrl }),
run,
{ dialsPrompt, testCount, explorerMode, explorerTuning, signal }
)
// AUTO-001: dispatch the risk-ordered + budget-capped subset, not the
// full approved set. The persisted run (buildTestRun) still records the
// approved-test order via `tests` for audit fidelity.
// DIF-012: env override applies at execution only — project.url and
// project.credentials are preserved in the DB; only this run sees the
// env baseUrl + credentials.
: runTests(
buildEnvScopedProject(project, environment),
selectedTests,
run,
{ parallelWorkers, signal }
),
{
onSuccess: () => {
logActivity({
type: triggerCrawl ? "crawl.complete" : "test_run.complete",
projectId: project.id,
projectName: project.name,
workspaceId: project.workspaceId,
detail: triggerCrawl
? `CI/CD crawl completed — ${run.pagesFound || 0} page(s), ${run.tests?.length || 0} test(s) generated`
: `CI/CD run completed — ${run.passed || 0} passed, ${run.failed || 0} failed`,
});
},
onFailActivity: (err) => ({
type: triggerCrawl ? "crawl.fail" : "test_run.fail",
projectId: project.id,
projectName: project.name,
workspaceId: project.workspaceId,
detail: triggerCrawl
? `CI/CD crawl failed: ${classifyError(err, "crawl").message}`
: `CI/CD run failed: ${classifyError(err, "run").message}`,
}),
// Fire optional callback URL with run summary on ANY terminal state
// (completed, failed, aborted) so CI pipelines always get notified.
// Uses safeFetchCallback which re-resolves DNS (mitigates rebinding)
// and blocks redirects (mitigates open-redirect SSRF bypass).
onComplete: async (finishedRun) => {
// FEA-001: Fire failure notifications — best-effort
try { await fireNotifications(finishedRun, project); } catch { /* best-effort */ }
await concludeGithubCheck(finishedRun, project);
if (!callbackUrl || typeof callbackUrl !== "string") return;
const payload = JSON.stringify({
runId,
status: finishedRun.status,
passed: finishedRun.passed,
failed: finishedRun.failed,
total: finishedRun.total,
error: finishedRun.error || null,
gateResult: finishedRun.gateResult || null,
webVitalsResult: finishedRun.webVitalsResult || null,
});
safeFetchCallback(callbackUrl, payload)
.catch(() => { /* best-effort — never fails the run */ });
},
},
);
// ── 7. Return 202 immediately — client polls statusUrl ───────────────
const proto = req.headers["x-forwarded-proto"] || req.protocol;
const host = req.headers["x-forwarded-host"] || req.get("host");
// Point to the token-authenticated status endpoint so CI pipelines can
// poll without a JWT — they reuse the same Bearer token.
const statusUrl = `${proto}://${host}/api/v1/projects/${project.id}/trigger/runs/${runId}`;
const response = { runId, statusUrl };
if (run.githubCheck?.checkRunId) {
response.githubCheck = { checkRunId: run.githubCheck.checkRunId, reused: false };
}
res.status(202).json(response);
}
router.post("/projects/:id/trigger", expensiveOpLimiter, requireTrigger, handleTrigger);
/**
* HMAC signature verification for deployment-webhook payloads.
*
* **Per-provider algorithm choice is dictated by each provider, not by us:**
* - Vercel: HMAC-**SHA1** over the raw body (`X-Vercel-Signature` header).
* Vercel's current webhook spec still signs with SHA-1; any change would
* have to come from Vercel. HMAC-SHA1 (unlike plain SHA-1) is not known
* to be vulnerable — the keyed prefix construction defeats the collision
* attacks that retired SHA-1 for certificate signing. Pre-image resistance
* in HMAC depends on the key, not the hash, so an attacker who cannot
* guess `VERCEL_WEBHOOK_SECRET` cannot forge a valid signature.
* - Netlify: HMAC-**SHA256** over the raw body (`X-Netlify-Token` header).
*
* If you're auditing this and wondering "why SHA-1?" — the answer is
* interoperability with the provider's signing scheme. When Vercel upgrades
* their webhook signatures, bump `algo` for the `"vercel"` branch here.
*
* @param {"vercel"|"netlify"|"github"} provider
* @param {Buffer|undefined} rawBody - captured by the webhook-scoped
* express.json `verify` callback in `middleware/appSetup.js`.
* @param {string|undefined} signatureHeader - the provider's signature header
* value; tolerates both raw hex and `"<algo>=<hex>"` prefix forms.
* @returns {boolean}
*/
export function verifyWebhookSignature(provider, rawBody, signatureHeader) {
const secret = provider === "vercel"
? process.env.VERCEL_WEBHOOK_SECRET
: provider === "github"
? process.env.GITHUB_WEBHOOK_SECRET
: process.env.NETLIFY_WEBHOOK_SECRET;
if (!secret || !signatureHeader || !rawBody) return false;
const algo = provider === "vercel" ? "sha1" : "sha256";
const expected = crypto.createHmac(algo, secret).update(rawBody).digest("hex");
const provided = signatureHeader.startsWith(`${algo}=`) ? signatureHeader.slice(algo.length + 1) : signatureHeader;
const a = Buffer.from(expected);
const b = Buffer.from(provided);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
/**
* Launch a crawl run against a deployment-preview URL. Shared by the Vercel
* and Netlify webhook handlers below — kept in one place so the run-object
* shape and runWithAbort wiring stay aligned with `runs.js:71-127`
* (the canonical crawl entry point).
*
* The caller must have already (a) verified the provider's HMAC signature,
* (b) authenticated the trigger token via requireTrigger, and (c) validated
* `previewUrl` via SSRF guard.
*
* AUTO-015b: emits a dedicated `crawl.start.deployment` activity row (see
* below) alongside the standard `crawl.start`, so the "Last deployment run"
* badge on the project header can distinguish webhook-launched crawls from
* manually-triggered ones via the activity log without a schema change.
*/
async function launchPreviewCrawl({ project, previewUrl, provider, tokenRow, dialsConfig }) {
// AUTO-002 / AUTO-015: derive crawl options from optional dialsConfig in the
// webhook payload. Provider webhook bodies don't carry these today, but
// future Vercel/Netlify integrations (or Sentri-side admin overrides) can
// pass them through — keeping the signature uniform with POST /trigger
// means there's no second config-routing path to maintain.
const validatedDials = resolveDialsConfig(dialsConfig);
const dialsPrompt = resolveDialsPrompt(dialsConfig);
const testCount = validatedDials?.testCount || "ai_decides";
const explorerMode = validatedDials?.exploreMode || "crawl";
const explorerTuning = {
maxStates: validatedDials?.exploreMaxStates ?? 30,
maxDepth: validatedDials?.exploreMaxDepth ?? 3,
maxActions: validatedDials?.exploreMaxActions ?? 8,
actionTimeout: validatedDials?.exploreActionTimeout ?? 5000,
};
// Concurrent-run guard — same as POST /trigger
const existingRun = runRepo.findActiveByProjectId(project.id);
if (existingRun) {
return { status: 409, body: { error: `A run is already in progress (${existingRun.id}).`, runId: existingRun.id } };
}
const runId = generateRunId();
// Same canonical shape as POST /trigger's crawl branch and runs.js's
// POST /crawl — see buildCrawlRun JSDoc above. `validatedDials` is
// forwarded so the run record carries `generateInput: { dialsConfig }`
// exactly like manually-triggered crawls.
const run = buildCrawlRun({ runId, project, dialsConfig: validatedDials });
runRepo.create(run);
if (tokenRow) webhookTokenRepo.touch(tokenRow.id);
// AUTO-015 / AUTO-015b: log the standard `crawl.start` so dashboard
// counters treat this like any other crawl, PLUS a dedicated
// `crawl.start.deployment` marker so the "Last deployment run" badge
// on the project header (NEXT.md:69) can distinguish webhook-launched
// runs from manually-triggered ones without a schema change. The
// `meta` payload carries the provider + preview URL + runId for the
// badge query in `GET /projects/:id/last-deployment-run`.
logActivity({
type: "crawl.start",
projectId: project.id,
projectName: project.name,
workspaceId: project.workspaceId,
detail: `${provider} deployment webhook — crawl ${previewUrl}`,
status: "running",
});
logActivity({
type: "crawl.start.deployment",
projectId: project.id,
projectName: project.name,
workspaceId: project.workspaceId,
detail: `${provider} deployment — ${previewUrl}`,
status: "running",
meta: { provider, previewUrl, runId },
});
runWithAbort(runId, run,
// AUTO-015: preserve `canonicalUrl` alongside the preview-URL override —
// crawler.js's sameOrigin guard needs the original project URL to
// detect that this is a preview crawl and skip baseline replacement.
// Without this, production baselines would be overwritten with
// preview-URL fingerprints every time a deployment webhook fires.
(signal) => crawlAndGenerateTests(
// launchPreviewCrawl is the Vercel/Netlify webhook path — no env
// override here, only the deploy-preview URL.
buildEnvScopedProject(project, null, { previewUrl }),
run,
{ dialsPrompt, testCount, explorerMode, explorerTuning, signal }
),
{
onSuccess: () => logActivity({
type: "crawl.complete",
projectId: project.id,
projectName: project.name,
workspaceId: project.workspaceId,
detail: `${provider} preview crawl completed — ${run.pagesFound || 0} pages, ${run.tests?.length || 0} test(s) generated`,
}),
onFailActivity: (err) => ({
type: "crawl.fail",
projectId: project.id,
projectName: project.name,
workspaceId: project.workspaceId,
detail: `${provider} preview crawl failed: ${classifyError(err, "crawl").message}`,
}),
onComplete: async (finishedRun) => {
try { await fireNotifications(finishedRun, project); } catch { /* best-effort */ }
},
},
);
return { status: 202, body: { ok: true, provider, runId, previewUrl } };
}
/**
* Webhook handlers require BOTH:
* 1. A valid HMAC signature from the deployment provider (proves Vercel/
* Netlify sent the payload — protects against forged calls).
* 2. A project-scoped trigger token via `requireTrigger` (proves which
* project should run — without this, a single global webhook secret
* would let any signed payload trigger any project ID in the URL).
*/
// INT-002: GitHub fires webhooks for many event types — `ping` when the
// hook is first installed, plus `push`, `issues`, `issue_comment`,
// `workflow_run`, `star`, etc. depending on subscriptions. Without an
// event-type filter, ANY delivery (including the install-time ping)
// would launch a Sentri run. Mirror the Vercel/Netlify gate: only
// PR-lifecycle events relevant to QA proceed; everything else is acked
// 200 so GitHub stops retrying.
const TRIGGERING_GITHUB_EVENTS = new Map([
["pull_request", new Set(["opened", "synchronize", "reopened", "ready_for_review"])],
["check_suite", new Set(["requested", "rerequested"])],
// `check_run.rerequested` fires when a user clicks "Re-run this check" on a
// single check (distinct from `check_suite.rerequested` which re-runs every
// check in the suite). Industry-standard QA gates honour both — without this
// entry, the per-check "Re-run" button silently no-ops, which is surprising
// UX for anyone used to GitHub Actions / CircleCI behaviour.
["check_run", new Set(["rerequested"])],
]);
router.post("/projects/:id/trigger/github", expensiveOpLimiter, requireTrigger, async (req, res) => {
const sig = req.get("X-Hub-Signature-256");
if (!verifyWebhookSignature("github", req.rawBody, sig)) return res.status(401).json({ error: "invalid signature" });
const event = req.get("X-GitHub-Event") || "";
const action = typeof req.body?.action === "string" ? req.body.action : "";
const allowedActions = TRIGGERING_GITHUB_EVENTS.get(event);
// `check_suite.requested` carries no `action`-bearing PR context on some
// forks, but the canonical webhook always supplies one. Reject events
// without a matching action — including `ping`, which has no action and
// no `pull_request` / `check_suite` payload.
if (!allowedActions || !allowedActions.has(action)) {
return res.status(200).json({ ok: true, ignored: true, reason: "event not triggering", event, action });
}
const payload = normalizeGithubPayload(req.body || {});
// Only short-circuit when settings exist AND are explicitly disabled, or
// when the configured repo doesn't match the incoming payload. Projects
// that have never configured `github_check_settings` should still be able
// to trigger via this webhook — the GitHub Check Run side of things is
// already gated separately inside `prepareGithubCheck` (no settings ⇒
// no check-run created, but the Sentri test run still executes). Treating
// "no settings row" as "disabled" here would silently break every project
// that hasn't opted into PR checks yet — see review on PR #17.
const settings = githubCheckSettingsRepo.getByProjectId(req.triggerProject?.id || req.params.id);
if (settings && settings.enabled === false) {
return res.status(200).json({ ok: true, ignored: true, reason: "github checks disabled" });
}
if (settings?.repo && payload.repo && settings.repo !== payload.repo) {
return res.status(200).json({ ok: true, ignored: true, reason: "repo mismatch" });
}
// Capture the GitHub delivery UUID so handleTrigger can dedupe retries.
// GitHub guarantees this header on every webhook delivery and reuses the
// same UUID across all retry attempts of a given delivery.
req.githubDeliveryId = req.get("X-GitHub-Delivery") || null;
req.body = { ...(req.body || {}), ...payload };
return handleTrigger(req, res);
});
router.post("/projects/:id/trigger/vercel", expensiveOpLimiter, requireTrigger, async (req, res) => {
const sig = req.get("X-Vercel-Signature");
if (!verifyWebhookSignature("vercel", req.rawBody, sig)) return res.status(401).json({ error: "invalid signature" });
// AUTO-015: Vercel emits webhook events for every deployment state
// (CREATED, BUILDING, READY, ERROR, CANCELED, …). Crawling a deployment
// that isn't yet serving content captures a "building" placeholder page
// (junk tests) or fails with a navigation error. Only fire on READY —
// accept either the v1 event-type form (`type: "deployment.ready"` /
// `"deployment.succeeded"`) or the v2 readyState form
// (`deployment.readyState: "READY"`). Anything else acks 200 (so Vercel
// doesn't retry indefinitely) without launching a run.
const eventType = typeof req.body?.type === "string" ? req.body.type : "";
const readyState = typeof req.body?.deployment?.readyState === "string" ? req.body.deployment.readyState : "";
const isReady =
eventType === "deployment.ready" ||
eventType === "deployment.succeeded" ||
readyState.toUpperCase() === "READY";
if (!isReady) {
return res.status(200).json({ ok: true, ignored: true, reason: "deployment not ready", eventType, readyState });
}
const deploymentUrl = req.body?.deployment?.url;
if (!deploymentUrl) return res.status(400).json({ error: "deployment.url missing from payload" });
const previewUrl = `https://${String(deploymentUrl).replace(/^https?:\/\//, "")}`;
// SSRF guard — same DNS-resolving validation used elsewhere in this file
if (previewUrl.length > 2048) return res.status(400).json({ error: "previewUrl exceeds maximum length (2048 characters)." });
const previewErr = await validateUrl(previewUrl);
if (previewErr) return res.status(400).json({ error: previewErr });
const { triggerProject: project, triggerToken: tokenRow } = req;
const { status, body } = await launchPreviewCrawl({ project, previewUrl, provider: "vercel", tokenRow });
res.status(status).json(body);
});
router.post("/projects/:id/trigger/netlify", expensiveOpLimiter, requireTrigger, async (req, res) => {
const sig = req.get("X-Netlify-Token");
if (!verifyWebhookSignature("netlify", req.rawBody, sig)) return res.status(401).json({ error: "invalid signature" });
// AUTO-015: Netlify deploy notifications fire for every deploy state
// (`new`, `building`, `ready`, `error`, `processing`, …). Crawling a
// deploy that isn't yet serving content captures a "building" placeholder
// page (junk tests) or fails with a navigation error — and Netlify allocates
// `deploy_ssl_url` / `deploy_url` early in the lifecycle, so the URL alone
// isn't a readiness signal. Only fire on `state === "ready"`. Anything else
// acks 200 (so Netlify doesn't retry indefinitely) without launching a run.
const state = typeof req.body?.state === "string" ? req.body.state : "";
if (state.toLowerCase() !== "ready") {
return res.status(200).json({ ok: true, ignored: true, reason: "deploy not ready", state });
}
const previewUrl = req.body?.deploy_ssl_url || req.body?.deploy_url || null;
if (!previewUrl) return res.status(400).json({ error: "deploy_ssl_url / deploy_url missing from payload" });
if (previewUrl.length > 2048) return res.status(400).json({ error: "previewUrl exceeds maximum length (2048 characters)." });
const previewErr = await validateUrl(previewUrl);
if (previewErr) return res.status(400).json({ error: previewErr });
const { triggerProject: project, triggerToken: tokenRow } = req;
const { status, body } = await launchPreviewCrawl({ project, previewUrl, provider: "netlify", tokenRow });
res.status(status).json(body);
});
/**
* GET /api/projects/:id/trigger/runs/:runId
* Token-authenticated run status endpoint for CI/CD pipelines.
*
* Uses the same Bearer token auth as POST /trigger so CI pipelines can
* poll for run completion without a JWT.
*
* @param {Object} req - Express request
* @param {Object} res - Express response
*/
router.get("/projects/:id/trigger/runs/:runId", requireTrigger, (req, res) => {
const { triggerProject: project } = req;
// ── Fetch run ──────────────────────────────────────────────────────
const run = runRepo.getById(req.params.runId);
if (!run) return res.status(404).json({ error: "run not found" });
if (run.projectId !== project.id) {
return res.status(404).json({ error: "run not found" });
}
// Return a minimal status payload (no logs or heavy data)
res.json(signRunArtifacts({
id: run.id,
status: run.status,
passed: run.passed,
failed: run.failed,
total: run.total,
startedAt: run.startedAt,
finishedAt: run.finishedAt || null,
duration: run.duration || null,
error: run.error || null,
gateResult: run.gateResult || null,
webVitalsResult: run.webVitalsResult || null,
}));
});
export default router;