From f8efd68bb138b1dda43de78744b0d0bc68991b41 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:22:24 -0700 Subject: [PATCH 1/2] feat(review): visual before/after screenshot capture (web-visible PRs, flag-gated OFF) Port reviewbot's visual capture into the gittensory worker, behind a new GITTENSORY_REVIEW_SCREENSHOTS flag (default OFF) ANDed with the per-repo cutover allowlist (GITTENSORY_REVIEW_REPOS). Screenshots fire ONLY for web-visible changes (frontend pages / public OG images) via isVisualPath; backend .ts/.md/.json/.py PRs never trigger capture. - src/review/visual/paths.ts: isVisualPath ported verbatim (3 VISUAL_PATTERNS) - src/review/visual/shot.ts: handleShot/captureShot/renderScreenshot + SVG placeholders; env.BROWSER + env.REVIEW_AUDIT; SSRF guard (isSafeHttpUrl) + host allowlist + r2-prefix/'..' key validation preserved - src/review/visual/preview-url.ts: Deployments API -> checks -> cloudflare-bot PR comment fallback + getPreviewBuildState + deployment_status mapping - src/review/visual/capture.ts: route mapping + before/after render orchestration - src/review/visual-wire.ts: isScreenshotsEnabled + screenshotsAllowed (AND per-repo cutover gate) - unified-comment-bridge: optional beforeAfter -> 'Visual preview' collapsible (markdown image table, public-safe) - routes.ts: PUBLIC GET /gittensory/shot OUTSIDE /v1/ (camo-proxy fetchable) - env.d.ts + wrangler.jsonc: GITTENSORY_REVIEW_SCREENSHOTS flag (default OFF) - processors.ts: gated, try/catch-wrapped capture at the unified-comment site - tests: isVisualPath (web-visible-only), screenshots flag/gate, collapsible Requires installing @cloudflare/puppeteer@^1.1.0 (declared in package.json; absent from the shared node_modules). --- package.json | 1 + src/api/routes.ts | 15 ++ src/env.d.ts | 11 + src/queue/processors.ts | 29 ++- src/review/unified-comment-bridge.ts | 44 +++- src/review/visual-wire.ts | 29 +++ src/review/visual/capture.ts | 203 +++++++++++++++++++ src/review/visual/paths.ts | 21 ++ src/review/visual/preview-url.ts | 292 +++++++++++++++++++++++++++ src/review/visual/shot.ts | 189 +++++++++++++++++ test/unit/visual-collapsible.test.ts | 98 +++++++++ test/unit/visual-paths.test.ts | 62 ++++++ test/unit/visual-wire.test.ts | 43 ++++ wrangler.jsonc | 4 + 14 files changed, 1039 insertions(+), 2 deletions(-) create mode 100644 src/review/visual-wire.ts create mode 100644 src/review/visual/capture.ts create mode 100644 src/review/visual/paths.ts create mode 100644 src/review/visual/preview-url.ts create mode 100644 src/review/visual/shot.ts create mode 100644 test/unit/visual-collapsible.test.ts create mode 100644 test/unit/visual-paths.test.ts create mode 100644 test/unit/visual-wire.test.ts diff --git a/package.json b/package.json index d62aaa5382..197c51d60e 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ }, "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", + "@cloudflare/puppeteer": "^1.1.0", "@modelcontextprotocol/sdk": "1.29.0", "@octokit/core": "^7.0.6", "agents": "^0.16.2", diff --git a/src/api/routes.ts b/src/api/routes.ts index 507a9b4323..ce01ff5b4b 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { analyzePRQueue, type AuthorRole, type ChecksStatus } from "../queue-intelligence"; import { completeGitHubWebOAuth, createSessionFromGitHubToken, pollGitHubDeviceFlow, startGitHubDeviceFlow, startGitHubWebOAuth } from "../auth/github-oauth"; import { enforceRateLimit, routeClassForPath } from "../auth/rate-limit"; +import { handleShot } from "../review/visual/shot"; import { BROWSER_SESSION_COOKIE, GITHUB_OAUTH_STATE_COOKIE, @@ -854,6 +855,20 @@ export function createApp() { return c.json(buildShieldsBadge(quality, 600)); }); + // Visual before/after screenshot endpoint (visual-capture port). PUBLIC + UNAUTHENTICATED by design: it + // lives OUTSIDE the /v1/ prefix, so requiresApiToken (which only gates path.startsWith('/v1/')) never + // touches it — GitHub's camo image proxy must fetch it without a bearer token. The handler itself enforces + // every security choke-point: ?key= validates the R2 prefix + rejects '..'; ?url= keeps the host allowlist + // (*.workers.dev / *.pages.dev / PUBLIC_SITE_ORIGIN) AND the isSafeHttpUrl SSRF guard. Inert flag-OFF: with + // GITTENSORY_REVIEW_SCREENSHOTS off nothing ever writes shots to R2, so ?key= 404s and ?url= still requires + // an allowlisted public host. The route's own Cache-Control headers (per mode) are set inside handleShot; + // the rate-limit middleware classifies it as 'normal' (a sane public class) via routeClassForPath. + app.get("/gittensory/shot", (c) => + handleShot(c.req.raw, c.env, { + ...(c.env.PUBLIC_SITE_ORIGIN ? { productionUrl: c.env.PUBLIC_SITE_ORIGIN } : {}), + }), + ); + app.get("/v1/auth/github/start", async (c) => { try { const start = await startGitHubWebOAuth(c.env, c.req.url, c.req.query("returnTo")); diff --git a/src/env.d.ts b/src/env.d.ts index 2b32af5b6e..f9b7b8c5e1 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -74,6 +74,17 @@ declare global { * the PR diff is scanned for leaked secrets, surfacing a `secret_leak` blocker. Default OFF — * unset/false keeps the review path byte-identical (no new branch is taken). */ GITTENSORY_REVIEW_SAFETY?: string; + /** Convergence (visual capture): when truthy, the review path captures a before/after screenshot for + * PRs that touch WEB-VISIBLE files (frontend pages / public OG images — see review/visual/paths.ts + * isVisualPath). "before" = production (PUBLIC_SITE_ORIGIN); "after" = the PR's preview deploy. Each shot + * is rendered via the BROWSER (Browser Rendering) binding, stored in the REVIEW_AUDIT R2 bucket, and + * embedded in the unified PR comment as a "Visual preview" table served from the PUBLIC /gittensory/shot + * route. Needs the BROWSER + REVIEW_AUDIT bindings; degrades gracefully (placeholders / dashes) without + * them. Backend .ts/.md/.json/.py PRs NEVER trigger capture. Capture runs for a repo ONLY IF this flag is + * ON *AND* the repo is in GITTENSORY_REVIEW_REPOS (the per-repo cutover allowlist) — see + * review/visual-wire.ts screenshotsAllowed. Default OFF — unset/false captures nothing (no render, no R2 + * write, no comment change) so the review path is byte-identical to today. */ + GITTENSORY_REVIEW_SCREENSHOTS?: string; /** Convergence (grounding): when truthy, the AI reviewer prompt is GROUNDED — the PR's finished CI status * + the FULL post-change content of the changed files are appended so a non-frontier model verifies its * claims against reality instead of predicting CI / flagging symbols defined just outside the hunk. diff --git a/src/queue/processors.ts b/src/queue/processors.ts index fe1908f2dd..4d206d0bca 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -68,7 +68,7 @@ import { refreshPullRequestDetails, } from "../github/backfill"; import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot, fetchOfficialGittensorMiner, type GittensorContributorSnapshot, type OfficialGittensorMinerDetection } from "../gittensor/api"; -import { createOrUpdateCheckRun, createOrUpdateErroredGateCheckRun, createOrUpdateGateCheckRun, createOrUpdateOverriddenGateCheckRun, createOrUpdatePendingGateCheckRun, createOrUpdateSkippedGateCheckRun, getInstallationId, getRepositoryCollaboratorPermission } from "../github/app"; +import { createInstallationToken, createOrUpdateCheckRun, createOrUpdateErroredGateCheckRun, createOrUpdateGateCheckRun, createOrUpdateOverriddenGateCheckRun, createOrUpdatePendingGateCheckRun, createOrUpdateSkippedGateCheckRun, getInstallationId, getRepositoryCollaboratorPermission } from "../github/app"; import { AGENT_COMMAND_COMMENT_MARKER, createOrUpdateAgentCommandComment, createOrUpdatePrIntelligenceComment, PR_PANEL_COMMENT_MARKER } from "../github/comments"; import { gittensoryFooter, gittensorRepoEarnUrl } from "../github/footer"; import { @@ -148,6 +148,9 @@ import { type ContributorProfile, } from "../signals/engine"; import { buildClosedUnifiedCommentBody, buildUnifiedCommentBody, isUnifiedReviewCommentEnabled } from "../review/unified-comment-bridge"; +import { screenshotsAllowed } from "../review/visual-wire"; +import { isVisualPath } from "../review/visual/paths"; +import { buildCapture, type CaptureRoute } from "../review/visual/capture"; import type { MergeReadiness } from "../review/unified-comment"; import { buildIssueSlopAssessment, buildSlopAssessment, type SlopBand } from "../signals/slop"; import { runGittensoryAiSlopAdvisory } from "../services/ai-slop"; @@ -1823,6 +1826,29 @@ async function maybePublishPrPublicSurface( ...(pr.mergeableState ? { mergeStateLabel: pr.mergeableState } : {}), ...(failedChecks.length > 0 ? { failingChecks: failedChecks.map((check) => check.name) } : {}), }; + // Visual before/after capture (visual-capture port). Fires ONLY when (1) the global flag + per-repo + // cutover gate both allow it (screenshotsAllowed) AND (2) the PR touches WEB-VISIBLE files (isVisualPath + // — frontend pages / public OG images; backend .ts/.md/.json PRs never qualify). Fully wrapped in + // try/catch + defaults to [] so a capture failure (render timeout, missing binding, GitHub hiccup) can + // NEVER sink the review — it just omits the "Visual preview" section. Flag-OFF (default) ⇒ this block is + // skipped entirely and the unified comment is byte-identical. + let beforeAfter: CaptureRoute[] = []; + const visualFiles = unifiedFiles.map((file) => file.path).filter(isVisualPath); + if (screenshotsAllowed(env, repoFullName) && visualFiles.length > 0) { + try { + const token = await createInstallationToken(env, installationId); + const capture = await buildCapture(env, token, { + repoFullName, + prNumber: pr.number, + ...(pr.headSha ? { headSha: pr.headSha } : {}), + ...(pr.headRef ? { headRef: pr.headRef } : {}), + previewFromChecks: true, + }, visualFiles); + beforeAfter = capture.routes; + } catch (error) { + console.log(JSON.stringify({ ev: "visual_capture_error", repoFullName, pull: pr.number, message: errorMessage(error).slice(0, 200) })); + } + } deterministicBody = buildUnifiedCommentBody({ gate: gateEvaluation, ...(aiReview !== undefined ? { aiReview } : {}), @@ -1850,6 +1876,7 @@ async function maybePublishPrPublicSurface( ...(reviewConfig?.footerText ? { customText: reviewConfig.footerText } : {}), }), reRunLabel: `${PR_PANEL_RETRIGGER_MARKER} Re-run Gittensory review`, + ...(beforeAfter.length > 0 ? { beforeAfter } : {}), }); } else { deterministicBody = buildPublicPrIntelligenceComment(commentArgs); diff --git a/src/review/unified-comment-bridge.ts b/src/review/unified-comment-bridge.ts index a59064378c..3f00604612 100644 --- a/src/review/unified-comment-bridge.ts +++ b/src/review/unified-comment-bridge.ts @@ -21,6 +21,7 @@ import type { AdvisoryFinding } from "../types"; import type { GateCheckConclusion, GateCheckEvaluation } from "../rules/advisory"; import type { PublicPrPanelSignalRow } from "../signals/engine"; +import type { CaptureRoute } from "./visual/capture"; // Single-source the panel marker from its canonical home (the upsert reads it there); re-export so existing // importers of `PR_PANEL_COMMENT_MARKER` from this module keep working. The unified body MUST prepend this // verbatim or `createOrUpdatePrIntelligenceComment` posts a DUPLICATE instead of updating in place. @@ -195,8 +196,43 @@ export type UnifiedCommentBridgeArgs = { extraCollapsibles?: UnifiedCollapsible[] | undefined; /** Headline brand (default "Gittensory review"). */ brand?: string | undefined; + /** Visual before/after capture routes (visual-capture port). When present + non-empty, a "Visual preview" + * collapsible (a markdown table of tags pointing at the public /gittensory/shot URLs) is appended. + * Public-safe: only URLs + route paths — no private terms. Default OFF (the processor passes this only + * when screenshotsAllowed + the PR touches web-visible files). */ + beforeAfter?: CaptureRoute[] | undefined; }; +/** + * Build the "Visual preview" collapsible from the before/after capture routes — a markdown table of image + * cells pointing at the public /gittensory/shot URLs. Uses GitHub markdown image syntax `![](url)` rather + * than raw `` tags ON PURPOSE: the unified renderer's `details()` HTML-escapes a collapsible body (a + * security control so caller text can't inject structure-changing HTML), which would turn a literal `` + * into inert `<img>` text — markdown image syntax has no angle brackets, so it survives the escape and + * still renders as an image. Public-safe by construction: every cell is a route path or a shot URL (no + * private rubric/scoring terms). Returns null when nothing is renderable (no route has any shot URL), so the + * section is omitted entirely rather than showing an empty table. + */ +export function buildBeforeAfterCollapsible(routes: CaptureRoute[]): UnifiedCollapsible | null { + const rows = routes + .filter((route) => route.beforeUrl || route.afterUrl || route.beforeUrlMobile || route.afterUrlMobile) + .map((route) => { + // Escape `(`/`)`/`]` in the URL so a crafted shot URL can't break out of the markdown image token; the + // URLs are first-party (we mint them), but this keeps the cell robust regardless. + const cell = (url: string | undefined): string => (url ? `![preview](${url.replace(/[()\]]/g, encodeURIComponent)})` : "—"); + return `| \`${route.path.replace(/\|/g, "\\|")}\` | ${cell(route.beforeUrl)} | ${cell(route.afterUrl)} |`; + }); + if (rows.length === 0) return null; + const body = [ + "| Route | Before (production) | After (this PR's preview) |", + "| --- | --- | --- |", + ...rows, + "", + "_Before = production · After = this PR's preview deploy._", + ].join("\n"); + return { title: "Visual preview", body }; +} + /** * Build the unified PR-review comment body from gittensory's live data. Returns a string that STARTS with * the panel marker (so the existing upsert updates in place) followed by the rendered unified comment. @@ -228,13 +264,19 @@ export function buildUnifiedCommentBody(args: UnifiedCommentBridgeArgs): string const visibleRows = args.panelRows.filter((row) => args.reviewFields?.[row.key] !== false); const signals = panelRowsToSignalRows(visibleRows); + // Visual-capture port: when before/after routes are present, append a "Visual preview" collapsible to the + // extra sections. Flag-OFF (the processor passes no beforeAfter) ⇒ extraCollapsibles is unchanged. + const visualCollapsible = args.beforeAfter && args.beforeAfter.length > 0 ? buildBeforeAfterCollapsible(args.beforeAfter) : null; + const extraCollapsibles = + visualCollapsible !== null ? [...(args.extraCollapsibles ?? []), visualCollapsible] : args.extraCollapsibles; + const body = renderUnifiedReviewComment(input, { brand: args.brand ?? "Gittensory review", readinessScore: args.readinessTotal, signals, footerMarkdown: args.footerMarkdown, ...(args.reRunLabel !== undefined ? { reRunLabel: args.reRunLabel } : {}), - ...(args.extraCollapsibles !== undefined ? { extraCollapsibles: args.extraCollapsibles } : {}), + ...(extraCollapsibles !== undefined ? { extraCollapsibles } : {}), }); // Prepend the marker verbatim (matching the legacy body, which leads with the marker then a blank line) diff --git a/src/review/visual-wire.ts b/src/review/visual-wire.ts new file mode 100644 index 0000000000..469a85e942 --- /dev/null +++ b/src/review/visual-wire.ts @@ -0,0 +1,29 @@ +// Convergence (visual capture) feature flag + per-repo gate wiring. +// +// Single env switch: GITTENSORY_REVIEW_SCREENSHOTS. Default OFF (unset/"false") — when OFF the processor +// never calls buildCapture, so the review path is byte-identical to today. Truthy follows the codebase +// convention (`/^(1|true|yes|on)$/i`, same as isSafetyEnabled / isUnifiedReviewCommentEnabled). +// +// As with every other per-PR converged feature, capture runs on a given PR's repo ONLY IF the global flag is +// ON *AND* the repo is in the per-repo cutover allowlist (GITTENSORY_REVIEW_REPOS). The AND with +// isConvergenceRepoAllowed is MANDATORY — it lets the cutover roll forward/back one repo at a time and keeps +// a globally-on-but-not-listed deploy dormant. + +import { isConvergenceRepoAllowed } from "./cutover-gate"; + +/** True when the visual-capture global flag is enabled. Flag-OFF (default) → no capture is attempted. */ +export function isScreenshotsEnabled(env: { GITTENSORY_REVIEW_SCREENSHOTS?: string | undefined }): boolean { + return /^(1|true|yes|on)$/i.test(env.GITTENSORY_REVIEW_SCREENSHOTS ?? ""); +} + +/** + * True when visual capture is allowed for `repoFullName`: the global flag is ON *AND* the repo is in the + * per-repo cutover allowlist. Both must hold — a globally-on flag alone never activates capture for an + * unlisted repo (the dormant default). + */ +export function screenshotsAllowed( + env: { GITTENSORY_REVIEW_SCREENSHOTS?: string | undefined; GITTENSORY_REVIEW_REPOS?: string | undefined }, + repoFullName: string, +): boolean { + return isScreenshotsEnabled(env) && isConvergenceRepoAllowed(env, repoFullName); +} diff --git a/src/review/visual/capture.ts b/src/review/visual/capture.ts new file mode 100644 index 0000000000..bc90c40b2e --- /dev/null +++ b/src/review/visual/capture.ts @@ -0,0 +1,203 @@ +// Realtime visual capture (reviewbot→gittensory convergence — visual port). taopedia-style before/after. +// +// before = production (PUBLIC_SITE_ORIGIN); after = the PR's preview-deploy URL, discovered the +// provider-agnostic way (Deployments API → commit checks → cloudflare-bot PR comment). Each page is +// rendered once here (in the queue consumer, which has the time budget), stored as a PNG in R2 +// (env.REVIEW_AUDIT), and embedded as /gittensory/shot?key= so GitHub's image +// proxy fetches a fast static object instead of waiting on a live browser render. +// +// PORTED from reviewbot's src/agents/gittensory/capture.ts (mapFilesToRoutes / routeForFile / capturePage / +// buildCapture), adapted to gittensory bindings + origins. The agent-config-driven route rules, authed-route +// preview session, and explicit-route override are intentionally dropped here — gittensory's UI uses the +// default TanStack route convention; those hooks can return if a per-repo visual config is added. +import { sha256Hex } from "../../utils/crypto"; +import { + findPreviewUrlFromChecks, + findPreviewUrlFromPrComments, + getLatestDeploymentStatus, + getPreviewBuildState, + parseRepo, +} from "./preview-url"; +import { captureShot, DESKTOP_VIEWPORT, MOBILE_VIEWPORT, type Viewport } from "./shot"; + +const NAMESPACE = "gittensory"; +const DEFAULT_ROUTES = ["/"]; +const DEFAULT_ROUTE_FILE = /apps\/gittensory-ui\/src\/routes\/(.+?)\.(?:tsx|jsx)$/i; +// Each route renders desktop + mobile for before + after (up to 4 PNGs). Cap routes to bound browser-render +// wall-clock — Browser Rendering is the costliest binding. +const MAX_ROUTES = 2; + +/** A single captured route's before/after shot URLs (desktop + mobile). undefined slot ⇒ a dash cell. */ +export interface CaptureRoute { + path: string; + beforeUrl?: string | undefined; + beforeUrlMobile?: string | undefined; + afterUrl?: string | undefined; + afterUrlMobile?: string | undefined; +} + +/** The capture pipeline's result: the rendered routes, plus whether a preview build is still pending. */ +export interface CaptureResult { + routes: CaptureRoute[]; + previewPending: boolean; +} + +/** Inputs the capture pipeline needs about the PR under review (resolved by the caller from gittensory data). */ +export interface CaptureTarget { + repoFullName: string; + prNumber: number; + headSha?: string | undefined; + headRef?: string | undefined; + /** Preview URL carried from a deployment_status webhook (no API call needed when present). */ + previewUrl?: string | undefined; + /** True when a deployment_status webhook reported the preview deploy FAILED. */ + previewFailed?: boolean | undefined; + /** Whether to scan commit checks / the cloudflare-bot PR comment for the preview URL (Workers Builds). */ + previewFromChecks?: boolean | undefined; +} + +function joinUrl(base: string, path: string): string { + return `${base.replace(/\/+$/, "")}${path.startsWith("/") ? path : `/${path}`}`; +} + +/** + * Map changed UI files to navigable routes, honoring TanStack Router's file conventions (flat routing uses + * `.` as the path separator; folders use `/`): + * __root.tsx / index.tsx -> "/" · app.index.tsx -> "/app" · app.analytics.tsx -> "/app/analytics" + * _authed.app.tsx -> "/app" (pathless `_` layout) · (marketing).about.tsx -> "/about" (route group) + * posts.$id.tsx -> "/" (dynamic param has no concrete value to render) + * Anything we can't resolve to a concrete path falls back to "/" so we never screenshot a 404. + */ +export function mapFilesToRoutes(files: string[], pattern: RegExp = DEFAULT_ROUTE_FILE): string[] { + const routes = new Set(); + for (const file of files) { + const match = file.match(pattern); + if (match) routes.add(routeForFile(match[1] as string)); + } + if (routes.size === 0) for (const route of DEFAULT_ROUTES) routes.add(route); + return [...routes].slice(0, MAX_ROUTES); +} + +/** Resolve one TanStack route-file name (extension already stripped) to a navigable path. */ +function routeForFile(raw: string): string { + if (/(^|[./])__/.test(raw)) return "/"; // root layout / "__"-prefixed framework file — not navigable + const segments: string[] = []; + for (const seg of raw.split(/[./]/)) { + if (!seg) continue; + if (/^(?:index|route|layout)$/i.test(seg)) continue; // index/layout markers add no path segment + if (/^\(.*\)$/.test(seg)) continue; // route groups: (marketing) + if (seg.startsWith("_")) continue; // pathless layout segments: _authed + if (seg.startsWith("$")) return "/"; // dynamic param — no concrete value to render + segments.push(seg); + } + return `/${segments.join("/")}`.replace(/\/+/g, "/").replace(/\/$/, "") || "/"; +} + +/** + * Render `page`, store the PNG in R2, and return its /gittensory/shot?key= URL. Falls back to an on-demand + * ?url= link if R2 or the render is unavailable; returns {} when there is no page (no preview deploy yet) so + * the cell shows a dash. Reuses an identical cached fingerprint (a deployment_status re-run filling "after" + * cells would otherwise re-render the same screenshot — Browser Rendering is the costliest binding). + */ +async function capturePage( + env: Env, + target: CaptureTarget, + page: string, + slot: "before" | "after", + viewportName: "desktop" | "mobile", + viewport: Viewport, +): Promise<{ url?: string | undefined }> { + if (!page) return {}; + const shotBase = env.PUBLIC_API_ORIGIN; // this worker's public origin (serves /gittensory/shot) + const onDemand = shotBase ? `${shotBase}/${NAMESPACE}/shot?url=${encodeURIComponent(page)}&w=${viewport.width}&h=${viewport.height}` : page; + + if (env.REVIEW_AUDIT) { + // Key includes the viewport so desktop + mobile of the same page don't collide in R2. + const fingerprint = await sha256Hex(`${target.headSha ?? target.prNumber}:${slot}:${viewportName}:${page}`); + const key = `${NAMESPACE}/shots/${fingerprint.slice(0, 40)}.png`; + const url = shotBase ? `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}` : onDemand; + const cached = await env.REVIEW_AUDIT.get(key).catch(() => null); + if (cached) return { url }; + const { png, authWalled } = await captureShot(env, page, viewport).catch(() => ({ png: null, authWalled: false })); + // A protected route that redirected to a sign-in wall: show an honest "requires authentication" + // placeholder rather than caching/serving a screenshot of the login screen. + if (authWalled) { + return { url: shotBase ? `${shotBase}/${NAMESPACE}/shot?placeholder=auth` : onDemand }; + } + if (png) { + await env.REVIEW_AUDIT.put(key, png, { httpMetadata: { contentType: "image/png" } }).catch(() => undefined); + return { url }; + } + } + return { url: onDemand }; +} + +/** + * Build the before/after capture for a PR: resolve the preview URL, derive routes from the changed UI files, + * render desktop + mobile before/after for each route, and return the route URL set (for the visual-preview + * collapsible). Fully fail-safe — a missing preview / failed render degrades to placeholders or dashes; this + * NEVER throws (the caller also wraps it in try/catch so a capture failure can't sink a review). + */ +export async function buildCapture(env: Env, token: string, target: CaptureTarget, visualFiles: string[]): Promise { + const repo = parseRepo(target.repoFullName); + const apiVersion = "2022-11-28"; + // before = production (PUBLIC_SITE_ORIGIN, e.g. https://gittensory.aethereal.dev). + const prodBase = env.PUBLIC_SITE_ORIGIN ?? ""; + + // after = the PR's preview deploy. Prefer the URL carried on the target (a deployment_status webhook set + // it — no extra API call); otherwise look it up from Deployments, then commit checks, then the + // cloudflare-bot PR comment. The lookups also tell us when the latest deploy FAILED (vs is still building) + // so we can show a terminal "deploy failed" card instead of a spinner. + let previewBase = typeof target.previewUrl === "string" ? target.previewUrl : ""; + let previewFailed = target.previewFailed === true; + let previewPending = false; + if (!previewBase && !previewFailed) { + try { + const status = await getLatestDeploymentStatus({ token, repo, sha: target.headSha, ref: target.headRef, apiVersion }); + previewBase = status.url ?? ""; + previewFailed = status.failed; + } catch { + previewBase = ""; + } + if (!previewBase && !previewFailed && target.previewFromChecks && target.headSha) { + previewBase = (await findPreviewUrlFromChecks({ token, repo, sha: target.headSha, apiVersion })) ?? ""; + if (!previewBase && target.prNumber) { + previewBase = (await findPreviewUrlFromPrComments({ token, repo, prNumber: target.prNumber, apiVersion })) ?? ""; + } + if (!previewBase && target.headSha) { + const buildState = await getPreviewBuildState({ token, repo, sha: target.headSha, apiVersion }); + if (buildState === "failed") previewFailed = true; + else if (buildState === "building" || buildState === "succeeded") previewPending = true; + } + } + } + + // With no real "after" shot, the cell shows a placeholder (same aspect ratio as a real shot): a spinner + // while the preview is still building, or a static "deploy failed" card once it won't come. + const shotBase = env.PUBLIC_API_ORIGIN; + const loadingPlaceholder = shotBase ? `${shotBase}/${NAMESPACE}/shot?placeholder=loading` : undefined; + const failedPlaceholder = shotBase ? `${shotBase}/${NAMESPACE}/shot?placeholder=failed` : undefined; + const afterPlaceholder = previewFailed ? failedPlaceholder : loadingPlaceholder; + + const routes = mapFilesToRoutes(visualFiles); + const captureRoutes: CaptureRoute[] = []; + for (const path of routes) { + const beforePage = prodBase ? joinUrl(prodBase, path) : ""; + const afterPage = previewBase ? joinUrl(previewBase, path) : ""; + // Render desktop + mobile for each slot in parallel (4 PNGs/route) to bound wall-clock. + const [beforeShot, beforeMobileShot, afterShot, afterMobileShot] = await Promise.all([ + capturePage(env, target, beforePage, "before", "desktop", DESKTOP_VIEWPORT), + capturePage(env, target, beforePage, "before", "mobile", MOBILE_VIEWPORT), + afterPage ? capturePage(env, target, afterPage, "after", "desktop", DESKTOP_VIEWPORT) : Promise.resolve<{ url?: string | undefined }>({ url: afterPlaceholder }), + afterPage ? capturePage(env, target, afterPage, "after", "mobile", MOBILE_VIEWPORT) : Promise.resolve<{ url?: string | undefined }>({ url: afterPlaceholder }), + ]); + captureRoutes.push({ + path, + beforeUrl: beforeShot.url, + beforeUrlMobile: beforeMobileShot.url, + afterUrl: afterShot.url, + afterUrlMobile: afterMobileShot.url, + }); + } + return { routes: captureRoutes, previewPending }; +} diff --git a/src/review/visual/paths.ts b/src/review/visual/paths.ts new file mode 100644 index 0000000000..287e7cff3f --- /dev/null +++ b/src/review/visual/paths.ts @@ -0,0 +1,21 @@ +// Visual-path classifier (reviewbot→gittensory convergence — visual capture port). +// +// PORTED VERBATIM from reviewbot's src/agents/gittensory/capabilities.ts `isVisualPath` (the three +// VISUAL_PATTERNS). This is the EMPHATIC gate: screenshots fire ONLY for WEB-VISIBLE changes — a +// frontend page (apps/gittensory-ui/**), a public asset (public/**, e.g. an OG image), or a +// front-of-house source extension (.tsx/.jsx/.css/.scss/.sass/.less/.html/.svg/.astro/.vue/.svelte/.mdx). +// A backend change (.ts/.md/.json/.py/...) matches NONE of these, so capture never triggers for it. +// +// PURE — no imports, no I/O. Callers MUST filter changed files through this before any capture. + +const VISUAL_PATTERNS: RegExp[] = [ + /^apps\/gittensory-ui\//i, + /(^|\/)public\//i, + /\.(tsx|jsx|css|scss|sass|less|html|svg|astro|vue|svelte|mdx)$/i, +]; + +/** True when `path` is a web-visible change worth screenshotting (frontend page / public OG asset / front-end + * source file). Backend .ts/.md/.json/.py paths return false → capture must NOT trigger for them. */ +export function isVisualPath(path: string): boolean { + return VISUAL_PATTERNS.some((pattern) => pattern.test(path)); +} diff --git a/src/review/visual/preview-url.ts b/src/review/visual/preview-url.ts new file mode 100644 index 0000000000..8b5f80a6e2 --- /dev/null +++ b/src/review/visual/preview-url.ts @@ -0,0 +1,292 @@ +// Preview-URL discovery (reviewbot→gittensory convergence — visual capture port). +// +// PORTED from reviewbot's src/core/github.ts (getLatestDeploymentStatus, extractPreviewUrl, +// findPreviewUrlFromChecks, findPreviewUrlFromPrComments, getPreviewBuildState) + the +// deployment_status → preview mapping from capabilities.ts `deploymentStatusTarget`. +// +// "after" = the PR's preview deploy. We discover its URL the provider-agnostic way: +// 1. the GitHub Deployments API (environment_url for the head SHA), then +// 2. a scan of the head SHA's commit statuses + check-runs for a *.workers.dev / *.pages.dev link, then +// 3. the Cloudflare Workers Builds bot's PR comment (where 2026-era Cloudflare publishes the link). +// getPreviewBuildState distinguishes "still building" (keep polling) from "failed" / "no build". +// +// gittensory has no fetch-based GitHub JSON helper of its own (its src/github layer uses Octokit), so +// this module carries a small fetch helper mirroring reviewbot's. Callers pass an installation token +// (resolved via createInstallationToken). Every helper degrades to null/absent on failure — preview +// discovery must NEVER sink a review. + +const DEFAULT_GITHUB_TIMEOUT_MS = 20_000; + +export type GitHubRepo = { owner: string; repo: string }; + +export function parseRepo(value: string): GitHubRepo { + const parts = value.trim().split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error("Expected owner/repo repository name."); + } + return { owner: parts[0], repo: parts[1] }; +} + +class PreviewGitHubError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.name = "PreviewGitHubError"; + this.status = status; + } +} + +/** Minimal fetch→JSON helper (mirrors reviewbot's core/github.ts githubJson). Throws PreviewGitHubError on a + * non-2xx so callers can distinguish a 404 ("no deployments") from a transient outage. */ +async function githubJson(url: string, init: { token?: string | undefined; apiVersion?: string | undefined } = {}): Promise { + const headers = new Headers(); + headers.set("accept", "application/vnd.github+json"); + headers.set("user-agent", "gittensory/0.1"); + headers.set("x-github-api-version", init.apiVersion || "2022-11-28"); + if (init.token) headers.set("authorization", `Bearer ${init.token}`); + const response = await fetch(url, { headers, signal: AbortSignal.timeout(DEFAULT_GITHUB_TIMEOUT_MS) }); + const text = await response.text(); + let payload: unknown = null; + if (text) { + try { + payload = JSON.parse(text); + } catch { + payload = null; + } + } + if (!response.ok) { + const message = typeof (payload as { message?: string })?.message === "string" ? (payload as { message: string }).message : `GitHub ${response.status}`; + throw new PreviewGitHubError(response.status, message); + } + return payload as T; +} + +export type DeploymentLookup = { url: string | null; failed: boolean; error?: boolean }; + +/** + * Resolve a PR's preview-deploy state via the GitHub Deployments API: walk the latest deployments for the + * head SHA (or ref) and their statuses, returning the `environment_url` of the first usable + * (success/in_progress) status; otherwise report `failed` when an attempt errored and none is still in + * flight, or `error` on a non-404 read failure (so the caller keeps the loading state instead of mistaking + * an outage for "no deploy"). Needs the app's deployments:read. + */ +export async function getLatestDeploymentStatus(params: { + token: string; + repo: GitHubRepo; + sha?: string | undefined; + ref?: string | undefined; + apiVersion?: string | undefined; +}): Promise { + const base = `https://api.github.com/repos/${params.repo.owner}/${params.repo.repo}`; + const selector = params.sha + ? `sha=${encodeURIComponent(params.sha)}` + : params.ref + ? `ref=${encodeURIComponent(params.ref)}` + : ""; + if (!selector) return { url: null, failed: false }; + let deployments: Array<{ id?: number }>; + try { + deployments = await githubJson>(`${base}/deployments?${selector}&per_page=10`, { + token: params.token, + apiVersion: params.apiVersion, + }); + } catch (error) { + // 404 → the ref genuinely has no deployments. Any other failure (403 missing scope, rate limit, 5xx) is + // NOT "no preview"; report `error` so the caller keeps polling rather than showing a false terminal state. + if (error instanceof PreviewGitHubError && error.status === 404) return { url: null, failed: false }; + console.log(JSON.stringify({ ev: "deployment_lookup_error", repo: `${params.repo.owner}/${params.repo.repo}`, selector, message: String(error).slice(0, 200) })); + return { url: null, failed: false, error: true }; + } + const ids = deployments.map((d) => d.id).filter((id): id is number => id != null); + const statusLists = await Promise.all( + ids.map((id) => + githubJson>(`${base}/deployments/${id}/statuses?per_page=10`, { + token: params.token, + apiVersion: params.apiVersion, + }).catch((error) => { + console.log(JSON.stringify({ ev: "deployment_status_error", deployment: id, message: String(error).slice(0, 200) })); + return [] as Array<{ state?: string; environment_url?: string }>; + }), + ), + ); + let sawFailure = false; + let sawPending = false; + for (const statuses of statusLists) { + for (const status of statuses) { + const ok = status.state === "success" || status.state === "in_progress"; + if (ok && status.environment_url) return { url: status.environment_url, failed: false }; + } + const latest = statuses[0]?.state; + if (latest === "failure" || latest === "error") sawFailure = true; + else if (latest === "in_progress" || latest === "queued" || latest === "pending") sawPending = true; + } + return { url: null, failed: sawFailure && !sawPending }; +} + +// A Cloudflare Workers/Pages preview always lives on one of these hosts. Restricting the status/check scan to +// them is what makes it safe: the scan can NEVER mistake an unrelated check's link for the preview. +const PREVIEW_HOST_SUFFIXES = [".workers.dev", ".pages.dev"]; + +/** Pull the first Cloudflare-preview (`*.workers.dev` / `*.pages.dev`) origin out of an arbitrary string (a + * status target_url, a check details_url, or a check-run output that embeds the link). */ +export function extractPreviewUrl(text: string | undefined | null): string | null { + if (!text) return null; + const matches = String(text).match(/https?:\/\/[^\s"'`<>()]+/gi); + if (!matches) return null; + for (const raw of matches) { + try { + const url = new URL(raw); + const host = url.hostname.toLowerCase(); + if (PREVIEW_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) { + return `${url.protocol}//${url.host}`; // base origin — the route path is appended by capture + } + } catch { + /* not a parseable URL — skip */ + } + } + return null; +} + +/** + * Resolve a per-PR preview URL the way Cloudflare Workers Builds surfaces it when it ISN'T a GitHub + * Deployment: scan the head SHA's commit statuses and check-runs for a `*.workers.dev` / `*.pages.dev` + * link (target_url, the check's details_url, or a URL embedded in the check-run output). Returns null on any + * failure so the caller degrades to "no preview yet". + */ +export async function findPreviewUrlFromChecks(params: { + token: string; + repo: GitHubRepo; + sha: string; + apiVersion?: string | undefined; +}): Promise { + const base = `https://api.github.com/repos/${params.repo.owner}/${params.repo.repo}`; + const opts = { token: params.token, apiVersion: params.apiVersion }; + try { + const combined = await githubJson<{ statuses?: Array<{ state?: string; target_url?: string }> }>( + `${base}/commits/${encodeURIComponent(params.sha)}/status`, + opts, + ).catch(() => null); + for (const status of combined?.statuses ?? []) { + if (status.state && status.state !== "success") continue; + const url = extractPreviewUrl(status.target_url); + if (url) return url; + } + const checks = await githubJson<{ check_runs?: Array<{ status?: string; conclusion?: string; details_url?: string; output?: { summary?: string; text?: string } }> }>( + `${base}/commits/${encodeURIComponent(params.sha)}/check-runs?per_page=100`, + opts, + ).catch(() => null); + for (const run of checks?.check_runs ?? []) { + if (run.status === "completed" && run.conclusion && run.conclusion !== "success") continue; + const url = extractPreviewUrl(run.details_url) ?? extractPreviewUrl(run.output?.summary) ?? extractPreviewUrl(run.output?.text); + if (url) return url; + } + } catch (error) { + console.log(JSON.stringify({ ev: "preview_from_checks_error", repo: `${params.repo.owner}/${params.repo.repo}`, message: String(error).slice(0, 200) })); + } + return null; +} + +/** + * Final preview-URL fallback: scan the PR's issue comments for the Cloudflare Workers Builds bot's comment, + * which carries the per-PR `*.workers.dev` preview link. Restricted to the EXACT cloudflare bot login — the + * `[bot]` suffix is reserved by GitHub for installed Apps and is unspoofable, so a malicious commenter can't + * inject an attacker-controlled `*.workers.dev` URL that we'd then render server-side. Returns null on any + * failure. + */ +export async function findPreviewUrlFromPrComments(params: { + token: string; + repo: GitHubRepo; + prNumber: number; + apiVersion?: string | undefined; +}): Promise { + const base = `https://api.github.com/repos/${params.repo.owner}/${params.repo.repo}`; + try { + const comments = await githubJson>( + `${base}/issues/${params.prNumber}/comments?per_page=100`, + { token: params.token, apiVersion: params.apiVersion }, + ).catch(() => null); + if (!Array.isArray(comments)) return null; + // Newest first (the bot edits one comment in place). + for (const c of [...comments].reverse()) { + if ((c.user?.login ?? "").toLowerCase() !== "cloudflare-workers-and-pages[bot]") continue; + const url = extractPreviewUrl(c.body); + if (url) return url; + } + } catch (error) { + console.log(JSON.stringify({ ev: "preview_from_comments_error", repo: `${params.repo.owner}/${params.repo.repo}`, message: String(error).slice(0, 200) })); + } + return null; +} + +/** + * State of the per-PR preview BUILD (Cloudflare Workers Builds check-run) for a head SHA, so capture can tell + * "still building / its URL-comment is just lagging" (keep polling) apart from "failed" (show the terminal + * failed card) and "no preview build at all" (don't poll). Returns 'absent' on any read failure (fail-safe: + * never an infinite poll on a transient error). + */ +export async function getPreviewBuildState(params: { + token: string; + repo: GitHubRepo; + sha: string; + apiVersion?: string | undefined; +}): Promise<"building" | "succeeded" | "failed" | "absent"> { + const base = `https://api.github.com/repos/${params.repo.owner}/${params.repo.repo}`; + try { + const checks = await githubJson<{ check_runs?: Array<{ name?: string; status?: string; conclusion?: string }> }>( + `${base}/commits/${encodeURIComponent(params.sha)}/check-runs?per_page=100`, + { token: params.token, apiVersion: params.apiVersion }, + ).catch(() => null); + const build = (checks?.check_runs ?? []).find((r) => /workers builds|cloudflare/i.test(r.name ?? "")); + if (!build) return "absent"; + if (build.status !== "completed") return "building"; // queued / in_progress → the preview is coming + return build.conclusion === "success" ? "succeeded" : "failed"; + } catch { + return "absent"; + } +} + +/** A deployment_status webhook payload, narrowed to the fields the preview mapping reads. */ +export type DeploymentStatusPayload = { + deployment_status?: { state?: string; environment_url?: string } | undefined; + deployment?: { sha?: string; ref?: string; payload?: string | { pr?: number } | null } | undefined; +}; + +/** The preview signal carried by a successful/failed deployment_status webhook, mapped without any API call. */ +export type DeploymentPreview = { prNumber: number; headSha?: string; headRef?: string; previewUrl?: string; previewFailed?: boolean }; + +/** + * Map a `deployment_status` webhook payload back to its PR + preview URL (PORTED from capabilities.ts + * `deploymentStatusTarget`). The PR number is carried in the deployment payload (set by the ui-preview + * workflow), so no token/lookup is needed. Returns null for an in-flight status (queued/in_progress/pending) + * or a payload missing the PR number — neither carries new preview signal. A failed deploy returns + * `previewFailed` with no URL so the caller can render the terminal "deploy failed" card. + */ +export function deploymentStatusToPreview(payload: DeploymentStatusPayload): DeploymentPreview | null { + const status = payload.deployment_status; + const deployment = payload.deployment; + if (!status || !deployment) return null; + const succeeded = status.state === "success" && !!status.environment_url; + const failed = status.state === "failure" || status.state === "error"; + if (!succeeded && !failed) return null; + + let prNumber: number | undefined; + const raw = deployment.payload; + if (typeof raw === "string") { + try { + prNumber = (JSON.parse(raw) as { pr?: number }).pr; + } catch { + prNumber = undefined; + } + } else if (raw && typeof raw === "object") { + prNumber = (raw as { pr?: number }).pr; + } + if (!prNumber) return null; + + return { + prNumber, + ...(deployment.sha ? { headSha: deployment.sha } : {}), + ...(deployment.ref ? { headRef: deployment.ref } : {}), + ...(succeeded ? { previewUrl: status.environment_url } : {}), + ...(failed ? { previewFailed: true } : {}), + }; +} diff --git a/src/review/visual/shot.ts b/src/review/visual/shot.ts new file mode 100644 index 0000000000..0b636706a0 --- /dev/null +++ b/src/review/visual/shot.ts @@ -0,0 +1,189 @@ +// Screenshot endpoint for the realtime before/after capture (reviewbot→gittensory convergence — visual port). +// +// PORTED from reviewbot's src/agents/gittensory/shot.ts. CHANGES for gittensory: +// • puppeteer import unchanged (@cloudflare/puppeteer), SSRF guard now isSafeHttpUrl from ../content-lane/safe-url +// • bindings: env.BROWSER (Browser Rendering) + env.REVIEW_AUDIT (R2) — gittensory's R2 binding is +// REVIEW_AUDIT, NOT reviewbot's env.AUDIT. +// • r2 key prefix default 'gittensory/shots/'; on-demand render allowlist's production host = PUBLIC_SITE_ORIGIN. +// • no reviewbot REVIEWBOT_* secrets / REST fallback — gittensory renders via the BROWSER binding only. +// +// Two modes: +// GET /gittensory/shot?key= -> stream a pre-rendered PNG from R2 (fast; GitHub's image proxy +// fetches this static object instead of waiting on a live render). +// GET /gittensory/shot?url= -> render on demand and return a PNG (host-allowlisted + +// SSRF-guarded). A fallback / manual-check path. +// GET /gittensory/shot?placeholder=loading|failed|auth -> a static SVG card (no render). +// +// Rendering uses the Cloudflare Browser Rendering *binding* (env.BROWSER) via @cloudflare/puppeteer — no +// account API token. Returns null on any failure so callers degrade gracefully (the cell becomes a dash). +import puppeteer from "@cloudflare/puppeteer"; +import { isSafeHttpUrl } from "../content-lane/safe-url"; + +export type Viewport = { width: number; height: number }; +export const DESKTOP_VIEWPORT: Viewport = { width: 1440, height: 900 }; +export const MOBILE_VIEWPORT: Viewport = { width: 390, height: 844 }; // iPhone-class portrait +const VIEWPORT = DESKTOP_VIEWPORT; + +/** Per-call shot-route options: the R2 namespace (key prefix) + the production host for the on-demand render + * allowlist. Defaults to gittensory so the /gittensory/shot route works with no options. */ +export interface ShotOptions { + namespace?: string; + productionUrl?: string; +} + +// A loading placeholder for the "after" cell while the preview deploy renders. Same 1440×900 aspect ratio as +// a real screenshot so the table cell reserves space and never resizes when the image swaps in. +const LOADING_SVG = ` + + + + + + + + Rendering preview… +`; + +// A STATIC placeholder for an "after" cell whose preview deploy FAILED (vs is still building). The spinner +// would lie here — it promises a render that is never coming — so this reads as a terminal state. +const FAILED_SVG = ` + + + + + + + Preview deploy failed — review manually +`; + +// A placeholder for a route that redirected to a sign-in wall — an authenticated route we could not (and +// should not) screenshot as a misleading login screen. A padlock + an honest label. +const AUTH_SVG = ` + + + + + + + Route requires authentication — preview unavailable +`; + +/** True when `url`'s path looks like a sign-in / auth wall. Used to avoid presenting a screenshot of the + * login screen as the route's preview. */ +export function isAuthWallUrl(url: string | undefined): boolean { + if (!url) return false; + try { + const p = new URL(url).pathname.toLowerCase(); + return /(^|\/)(login|signin|sign-in|sign_in|auth|oauth|authenticate)(\/|$)/.test(p); + } catch { + return false; + } +} + +function hostOf(url: string | undefined): string | null { + if (!url) return null; + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return null; + } +} + +/** Host allowlist for the on-demand `?url=` render: only Cloudflare preview hosts (*.workers.dev / + * *.pages.dev) and the configured production host (PUBLIC_SITE_ORIGIN, or a per-call productionUrl). */ +function isAllowedHost(targetUrl: string, env: Env, productionUrl?: string): boolean { + const host = hostOf(targetUrl); + if (!host) return false; + if (host.endsWith(".workers.dev") || host.endsWith(".pages.dev")) return true; + if (host === hostOf(productionUrl)) return true; + if (host === hostOf(env.PUBLIC_SITE_ORIGIN)) return true; + return false; +} + +/** + * Render a page to a PNG via the Browser Rendering binding, also reporting whether the route redirected to a + * sign-in wall. `authWalled` is true when the FINAL url looks like a login page that the REQUESTED url was + * not — the caller then shows an honest "requires authentication" placeholder instead of a screenshot of the + * login screen. `png` is null on any render failure (callers degrade gracefully). + */ +export async function captureShot(env: Env, url: string, viewport: Viewport = VIEWPORT): Promise<{ png: Uint8Array | null; authWalled: boolean }> { + // SSRF defense-in-depth: NEVER navigate the headless browser to a non-public host (loopback / link-local / + // private / cloud-metadata 169.254.169.254 / etc.). Callers may resolve `url` from a deployment_status + // webhook or a PR-comment preview link, so guard at this choke point regardless of how the URL was obtained. + if (!url || !isSafeHttpUrl(url)) { + console.log(JSON.stringify({ ev: "render_screenshot_blocked", url: String(url).slice(0, 120) })); + return { png: null, authWalled: false }; + } + if (!env.BROWSER) return { png: null, authWalled: false }; + let browser: Awaited> | null = null; + try { + browser = await puppeteer.launch(env.BROWSER as unknown as Parameters[0]); + const page = await browser.newPage(); + await page.setViewport(viewport); + await page.goto(url, { waitUntil: "networkidle0", timeout: 20000 }); + // A protected route that redirected to a login page: don't return a screenshot of the sign-in screen — + // flag it so the caller renders an honest auth placeholder. (The requested URL not itself being a login + // page guards a PR that legitimately changes the login screen.) + if (isAuthWallUrl(page.url()) && !isAuthWallUrl(url)) { + console.log(JSON.stringify({ ev: "render_screenshot_auth_walled", url, final: page.url().slice(0, 200) })); + return { png: null, authWalled: true }; + } + const shot = (await page.screenshot({ type: "png", fullPage: false })) as Uint8Array; + return { png: shot, authWalled: false }; + } catch (error) { + // Log before degrading to null — otherwise a networkidle0 timeout, a binding quota error, or a render + // crash is indistinguishable from "no page" and the cell silently blanks. + console.log(JSON.stringify({ ev: "render_screenshot_error", mode: "binding", url, message: String(error).slice(0, 200) })); + return { png: null, authWalled: false }; + } finally { + if (browser) await browser.close().catch(() => undefined); + } +} + +/** Back-compat thin wrapper: render a page to a PNG (or null on failure / auth wall). The on-demand + * `/shot?url=` route uses this; the capture pipeline uses `captureShot` to also learn `authWalled`. */ +export async function renderScreenshot(env: Env, url: string, viewport: Viewport = VIEWPORT): Promise { + return (await captureShot(env, url, viewport)).png; +} + +export async function handleShot(request: Request, env: Env, opts: ShotOptions = {}): Promise { + const params = new URL(request.url).searchParams; + const r2Prefix = `${opts.namespace ?? "gittensory"}/shots/`; + + // Mode 0: a placeholder for an "after" cell with no real screenshot yet — the animated spinner (preview + // still building), the static "deploy failed" card (preview won't come), or the auth-wall card. + const placeholder = params.get("placeholder"); + if (placeholder === "loading" || placeholder === "failed" || placeholder === "auth") { + const svg = placeholder === "failed" ? FAILED_SVG : placeholder === "auth" ? AUTH_SVG : LOADING_SVG; + return new Response(svg, { + headers: { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "public, max-age=60" }, + }); + } + + // Mode A: serve a pre-rendered screenshot from R2 (fast path for the image proxy). The key MUST be inside + // our R2 prefix and MUST NOT traverse — so a crafted ?key= can never read another object. + const key = params.get("key"); + if (key) { + if (!key.startsWith(r2Prefix) || key.includes("..")) { + return new Response("bad key", { status: 400 }); + } + const object = await env.REVIEW_AUDIT?.get(key); + if (!object) return new Response("not found", { status: 404 }); + return new Response(object.body, { + headers: { "content-type": "image/png", "cache-control": "public, max-age=86400, immutable" }, + }); + } + + // Mode B: render on demand (host-allowlisted + SSRF-guarded). Optional &w=&h= selects the viewport. + const target = params.get("url"); + if (!target || !isSafeHttpUrl(target)) return new Response("bad url", { status: 400 }); + if (!isAllowedHost(target, env, opts.productionUrl)) return new Response("forbidden host", { status: 403 }); + const w = Number(params.get("w")); + const h = Number(params.get("h")); + const viewport: Viewport = Number.isFinite(w) && w > 0 && Number.isFinite(h) && h > 0 ? { width: Math.min(w, 2560), height: Math.min(h, 2560) } : DESKTOP_VIEWPORT; + const png = await renderScreenshot(env, target, viewport); + if (!png) return new Response("screenshot unavailable", { status: 502 }); + return new Response(png, { + headers: { "content-type": "image/png", "cache-control": "public, max-age=300" }, + }); +} diff --git a/test/unit/visual-collapsible.test.ts b/test/unit/visual-collapsible.test.ts new file mode 100644 index 0000000000..b4b940f496 --- /dev/null +++ b/test/unit/visual-collapsible.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { buildBeforeAfterCollapsible, buildUnifiedCommentBody } from "../../src/review/unified-comment-bridge"; +import type { GateCheckEvaluation } from "../../src/rules/advisory"; +import type { PublicPrPanelSignalRow } from "../../src/signals/engine"; +import type { CaptureRoute } from "../../src/review/visual/capture"; + +function gate(over: Partial = {}): GateCheckEvaluation { + return { + enabled: true, + conclusion: "success", + title: "Gittensory Gate passed", + summary: "No configured hard blocker was found.", + blockers: [], + warnings: [], + ...over, + }; +} + +const panelRows: PublicPrPanelSignalRow[] = [ + { key: "gateResult", cells: ["Gate result", "✅ Passing", "No configured blocker found.", "No action."] }, +]; +const footer = "💰 Earn for open-source contributions. Checked by Gittensory."; + +const routes: CaptureRoute[] = [ + { + path: "/app/analytics", + beforeUrl: "https://api.example.dev/gittensory/shot?key=gittensory/shots/abc.png", + afterUrl: "https://api.example.dev/gittensory/shot?key=gittensory/shots/def.png", + }, +]; + +describe("buildBeforeAfterCollapsible", () => { + it("renders a 'Visual preview' table of markdown image cells pointing at the public shot URLs", () => { + const c = buildBeforeAfterCollapsible(routes); + expect(c).not.toBeNull(); + expect(c?.title).toBe("Visual preview"); + expect(c?.body).toContain("| Route | Before (production) | After (this PR's preview) |"); + expect(c?.body).toContain("`/app/analytics`"); + // Markdown image syntax (not raw ) so it survives the renderer's HTML-angle escaping. + expect(c?.body).toContain("![preview](https://api.example.dev/gittensory/shot?key=gittensory/shots/abc.png)"); + expect(c?.body).toContain("![preview](https://api.example.dev/gittensory/shot?key=gittensory/shots/def.png)"); + expect(c?.body).not.toContain(" { + const c = buildBeforeAfterCollapsible([{ path: "/", afterUrl: "https://api.example.dev/gittensory/shot?key=gittensory/shots/x.png" }]); + expect(c?.body).toContain("| `/` | — | ![preview]("); + }); + + it("returns null when no route has any shot URL (no empty table)", () => { + expect(buildBeforeAfterCollapsible([])).toBeNull(); + expect(buildBeforeAfterCollapsible([{ path: "/" }])).toBeNull(); + }); + + it("escapes a pipe in the route path so it can't break the markdown table", () => { + const c = buildBeforeAfterCollapsible([{ path: "/a|b", afterUrl: "https://api.example.dev/gittensory/shot?key=gittensory/shots/x.png" }]); + expect(c?.body).toContain("`/a\\|b`"); + }); +}); + +describe("buildUnifiedCommentBody beforeAfter wiring", () => { + const base = { + gate: gate(), + panelRows, + readinessTotal: 90, + changedFiles: 3, + footerMarkdown: footer, + }; + + it("appends the Visual preview section when beforeAfter is present + non-empty", () => { + const body = buildUnifiedCommentBody({ ...base, beforeAfter: routes }); + expect(body).toContain("Visual preview"); + expect(body).toContain("`/app/analytics`"); + // The shot URL survives the renderer's escaping intact (markdown image syntax, no angle brackets). + expect(body).toContain("https://api.example.dev/gittensory/shot?key=gittensory/shots/abc.png"); + expect(body).not.toContain("<img"); + }); + + it("does NOT add a Visual preview section when beforeAfter is absent (flag-OFF parity)", () => { + const body = buildUnifiedCommentBody(base); + expect(body).not.toContain("Visual preview"); + }); + + it("does NOT add a Visual preview section when beforeAfter is empty", () => { + const body = buildUnifiedCommentBody({ ...base, beforeAfter: [] }); + expect(body).not.toContain("Visual preview"); + }); + + it("preserves pre-existing extraCollapsibles alongside the Visual preview section", () => { + const body = buildUnifiedCommentBody({ + ...base, + extraCollapsibles: [{ title: "Signal definitions", body: "what each row means" }], + beforeAfter: routes, + }); + expect(body).toContain("Signal definitions"); + expect(body).toContain("Visual preview"); + }); +}); diff --git a/test/unit/visual-paths.test.ts b/test/unit/visual-paths.test.ts new file mode 100644 index 0000000000..a9d021b54b --- /dev/null +++ b/test/unit/visual-paths.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { isVisualPath } from "../../src/review/visual/paths"; + +describe("isVisualPath (web-visible-only capture gate)", () => { + it("matches frontend app paths (apps/gittensory-ui/**)", () => { + expect(isVisualPath("apps/gittensory-ui/src/routes/index.tsx")).toBe(true); + expect(isVisualPath("apps/gittensory-ui/src/routes/app.analytics.tsx")).toBe(true); + // Even a non-source file under the UI app is web-visible scope. + expect(isVisualPath("apps/gittensory-ui/public/og.png")).toBe(true); + expect(isVisualPath("apps/gittensory-ui/README.md")).toBe(true); + }); + + it("matches public asset paths (public/** — OG images etc.) at any depth", () => { + expect(isVisualPath("public/og-image.png")).toBe(true); + expect(isVisualPath("apps/web/public/banner.jpg")).toBe(true); + expect(isVisualPath("packages/site/public/favicon.ico")).toBe(true); + }); + + it("matches front-of-house source extensions anywhere", () => { + for (const path of [ + "src/components/Button.tsx", + "src/Button.jsx", + "src/styles/main.css", + "src/styles/theme.scss", + "src/styles/legacy.sass", + "src/styles/old.less", + "site/index.html", + "assets/logo.svg", + "src/pages/home.astro", + "src/App.vue", + "src/Widget.svelte", + "docs/guide.mdx", + ]) { + expect(isVisualPath(path), path).toBe(true); + } + }); + + it("does NOT match backend / non-web files (the emphatic constraint)", () => { + for (const path of [ + "src/queue/processors.ts", + "src/review/visual/paths.ts", + "README.md", + "package.json", + "wrangler.jsonc", + "scripts/build.py", + "src/types.d.ts", + "go.mod", + "Cargo.toml", + "src/data/seed.sql", + "config.yaml", + ]) { + expect(isVisualPath(path), path).toBe(false); + } + }); + + it("is case-insensitive on extensions and the app prefix", () => { + expect(isVisualPath("APPS/GITTENSORY-UI/src/Page.TSX")).toBe(true); + expect(isVisualPath("src/Icon.SVG")).toBe(true); + // A .ts (backend) must still be false even upper-cased — it is not a web-visible extension. + expect(isVisualPath("src/Worker.TS")).toBe(false); + }); +}); diff --git a/test/unit/visual-wire.test.ts b/test/unit/visual-wire.test.ts new file mode 100644 index 0000000000..a261ef4dab --- /dev/null +++ b/test/unit/visual-wire.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { isScreenshotsEnabled, screenshotsAllowed } from "../../src/review/visual-wire"; + +describe("isScreenshotsEnabled", () => { + it("is OFF by default (unset / empty / false)", () => { + expect(isScreenshotsEnabled({})).toBe(false); + expect(isScreenshotsEnabled({ GITTENSORY_REVIEW_SCREENSHOTS: undefined })).toBe(false); + expect(isScreenshotsEnabled({ GITTENSORY_REVIEW_SCREENSHOTS: "" })).toBe(false); + expect(isScreenshotsEnabled({ GITTENSORY_REVIEW_SCREENSHOTS: "false" })).toBe(false); + expect(isScreenshotsEnabled({ GITTENSORY_REVIEW_SCREENSHOTS: "0" })).toBe(false); + expect(isScreenshotsEnabled({ GITTENSORY_REVIEW_SCREENSHOTS: "off" })).toBe(false); + }); + + it("accepts the codebase truthy vocabulary (1/true/yes/on, case-insensitive)", () => { + for (const v of ["1", "true", "TRUE", "yes", "Yes", "on", "ON"]) { + expect(isScreenshotsEnabled({ GITTENSORY_REVIEW_SCREENSHOTS: v }), v).toBe(true); + } + }); +}); + +describe("screenshotsAllowed (global flag AND per-repo cutover gate)", () => { + const repo = "JSONbored/gittensory"; + + it("requires BOTH the global flag and the repo allowlist", () => { + // Global on, repo allowlisted → allowed. + expect(screenshotsAllowed({ GITTENSORY_REVIEW_SCREENSHOTS: "true", GITTENSORY_REVIEW_REPOS: repo }, repo)).toBe(true); + }); + + it("is false when the global flag is OFF even if the repo is allowlisted", () => { + expect(screenshotsAllowed({ GITTENSORY_REVIEW_SCREENSHOTS: "false", GITTENSORY_REVIEW_REPOS: repo }, repo)).toBe(false); + expect(screenshotsAllowed({ GITTENSORY_REVIEW_REPOS: repo }, repo)).toBe(false); + }); + + it("is false when the repo is NOT allowlisted even if the global flag is ON (dormant default)", () => { + expect(screenshotsAllowed({ GITTENSORY_REVIEW_SCREENSHOTS: "true" }, repo)).toBe(false); + expect(screenshotsAllowed({ GITTENSORY_REVIEW_SCREENSHOTS: "true", GITTENSORY_REVIEW_REPOS: "" }, repo)).toBe(false); + expect(screenshotsAllowed({ GITTENSORY_REVIEW_SCREENSHOTS: "true", GITTENSORY_REVIEW_REPOS: "JSONbored/other" }, repo)).toBe(false); + }); + + it("matches the repo case-insensitively within the allowlist", () => { + expect(screenshotsAllowed({ GITTENSORY_REVIEW_SCREENSHOTS: "on", GITTENSORY_REVIEW_REPOS: "jsonbored/GITTENSORY" }, repo)).toBe(true); + }); +}); diff --git a/wrangler.jsonc b/wrangler.jsonc index 029fbdd8f3..061bd1f8bd 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -53,6 +53,10 @@ // title/body/diff before the AI reviewer sees it, and surface a secret-leak blocker from the diff. // Default OFF — flag-OFF keeps the review path byte-identical. "GITTENSORY_REVIEW_SAFETY": "true", + // Convergence (visual capture): capture a before/after screenshot for PRs touching WEB-VISIBLE files + // (frontend pages / public OG images). Needs the BROWSER + REVIEW_AUDIT bindings; runs only when this is + // ON AND the repo is in GITTENSORY_REVIEW_REPOS. DEFAULT OFF — flag-OFF captures nothing (byte-identical). + "GITTENSORY_REVIEW_SCREENSHOTS": "false", // Convergence (grounding): ground the AI reviewer prompt with the PR's finished CI status + the full // post-change content of the changed files, so a non-frontier model verifies claims instead of guessing. // Default OFF — flag-OFF keeps the reviewer prompt byte-identical and makes no extra GitHub fetch. From 4971c244e15caad4600e07c94b9988c4d2a2d186 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:28:52 -0700 Subject: [PATCH 2/2] chore(visual): install @cloudflare/puppeteer + make /gittensory/shot inert when flag-OFF Adds @cloudflare/puppeteer to the lockfile (CI npm ci now resolves the visual import). Hardens the public /gittensory/shot route: returns 404 when GITTENSORY_REVIEW_SCREENSHOTS is off, so the on-demand ?url= render surface only exists once the feature is deliberately enabled (flag-OFF = truly inert, no attack surface). --- package-lock.json | 684 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 5 +- src/api/routes.ts | 13 +- 3 files changed, 680 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 665f511421..904e886c15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ ], "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", + "@cloudflare/puppeteer": "^1.1.0", "@modelcontextprotocol/sdk": "1.29.0", "@octokit/core": "^7.0.6", "agents": "^0.16.2", @@ -947,6 +948,21 @@ "node": ">=22.0.0" } }, + "node_modules/@cloudflare/puppeteer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@cloudflare/puppeteer/-/puppeteer-1.1.0.tgz", + "integrity": "sha512-lN10En49avRDQvz8Gpv/WiIoGvjjDiP6P+v2y9bA6rfPT5WHfnlg2O6MwjHc1POm8A9LXf1sMdgrsdW8xWapOw==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.2.4", + "debug": "^4.3.5", + "devtools-protocol": "0.0.1299070", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@cloudflare/unenv-preset": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", @@ -3000,6 +3016,168 @@ "dev": true, "license": "MIT" }, + "node_modules/@puppeteer/browsers": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.2.4.tgz", + "integrity": "sha512-BdG2qiI1dn89OTUUsx2GZSpUzW+DRffR1wlMJyKxVHYrhnKoELSDxDd+2XImUkuWPEKk76H5FcM/gPFrEK1Tfw==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.3.5", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.4.0", + "semver": "^7.6.2", + "tar-fs": "^3.0.6", + "unbzip2-stream": "^1.4.3", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@puppeteer/browsers/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@puppeteer/browsers/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/@puppeteer/browsers/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@puppeteer/browsers/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@puppeteer/browsers/node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/@puppeteer/browsers/node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/@puppeteer/browsers/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", @@ -5562,6 +5740,12 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -5768,6 +5952,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.61.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", @@ -6291,7 +6485,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -6547,6 +6740,18 @@ "node": ">=12" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/ast-v8-to-istanbul": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", @@ -6577,6 +6782,20 @@ "dev": true, "license": "MIT" }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-dead-code-elimination": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", @@ -6596,6 +6815,98 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -6614,8 +6925,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/baseline-browser-mapping": { "version": "2.10.33", @@ -6629,6 +6939,15 @@ "node": ">=6.0.0" } }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -6776,12 +7095,20 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7010,7 +7337,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -7023,7 +7349,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -7375,6 +7700,15 @@ "node": ">=12" } }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -7503,6 +7837,20 @@ "dev": true, "license": "MIT" }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -7547,6 +7895,12 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devtools-protocol": { + "version": "0.0.1299070", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1299070.tgz", + "integrity": "sha512-+qtL3eX50qsJ7c+qVyagqi7AWMoQCBGNfoyJZMwm/NSXVqLYbuitrWEEIzxfUmTNy7//Xe8yhMmQ+elj3uAqSg==", + "license": "BSD-3-Clause" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -7802,7 +8156,6 @@ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", - "optional": true, "dependencies": { "once": "^1.4.0" } @@ -8007,6 +8360,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", @@ -8209,6 +8583,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -8239,7 +8626,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -8259,7 +8645,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -8286,6 +8671,15 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -8446,6 +8840,41 @@ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "license": "MIT" }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fast-content-type-parse": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", @@ -8484,6 +8913,12 @@ "node": ">=6.0.0" } }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -8593,6 +9028,15 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -8969,6 +9413,20 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/git-cliff": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/git-cliff/-/git-cliff-2.13.1.tgz", @@ -9311,7 +9769,6 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -9325,7 +9782,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -9516,6 +9972,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -10780,6 +11245,15 @@ "node": ">= 0.6" } }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/nf3": { "version": "0.3.17", "resolved": "https://registry.npmjs.org/nf3/-/nf3-0.3.17.tgz", @@ -11087,6 +11561,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/papaparse": { "version": "5.5.4", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", @@ -11242,6 +11748,12 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11658,6 +12170,15 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -11688,12 +12209,45 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "license": "MIT", - "optional": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -12089,6 +12643,15 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -12319,7 +12882,6 @@ "version": "7.8.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -12605,6 +13167,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/smol-toml": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", @@ -12617,6 +13189,34 @@ "url": "https://github.com/sponsors/cyyynthia" } }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -12631,7 +13231,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -12704,6 +13304,17 @@ "dev": true, "license": "MIT" }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -12958,6 +13569,24 @@ "node": ">=6" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -12981,6 +13610,12 @@ "node": ">=0.8" } }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -13303,6 +13938,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, "node_modules/undici": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", @@ -13922,7 +14567,6 @@ "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -14047,6 +14691,16 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 197c51d60e..b33cce39d7 100644 --- a/package.json +++ b/package.json @@ -82,8 +82,8 @@ "github-actionlint": "^1.7.12", "node-addon-api": "^8.8.0", "node-gyp": "^12.4.0", - "playwright": "^1.61.0", "pixelmatch": "^7.2.0", + "playwright": "^1.61.0", "pngjs": "^7.0.0", "tsx": "^4.22.4", "typescript": "^5.9.3", @@ -105,8 +105,7 @@ }, "vite": { "esbuild": "^0.28.1" - }, - "ws": "^8.21.0" + } }, "main": "index.js", "directories": { diff --git a/src/api/routes.ts b/src/api/routes.ts index ce01ff5b4b..ba1349e050 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -4,6 +4,7 @@ import { analyzePRQueue, type AuthorRole, type ChecksStatus } from "../queue-int import { completeGitHubWebOAuth, createSessionFromGitHubToken, pollGitHubDeviceFlow, startGitHubDeviceFlow, startGitHubWebOAuth } from "../auth/github-oauth"; import { enforceRateLimit, routeClassForPath } from "../auth/rate-limit"; import { handleShot } from "../review/visual/shot"; +import { isScreenshotsEnabled } from "../review/visual-wire"; import { BROWSER_SESSION_COOKIE, GITHUB_OAUTH_STATE_COOKIE, @@ -863,11 +864,15 @@ export function createApp() { // GITTENSORY_REVIEW_SCREENSHOTS off nothing ever writes shots to R2, so ?key= 404s and ?url= still requires // an allowlisted public host. The route's own Cache-Control headers (per mode) are set inside handleShot; // the rate-limit middleware classifies it as 'normal' (a sane public class) via routeClassForPath. - app.get("/gittensory/shot", (c) => - handleShot(c.req.raw, c.env, { + // Flag-OFF = TRULY inert: when GITTENSORY_REVIEW_SCREENSHOTS is off nothing references this route (no comment + // carries a /gittensory/shot URL), so 404 it outright — that removes the on-demand `?url=` render surface + // entirely until the feature is deliberately enabled, rather than relying on the host allowlist alone. + app.get("/gittensory/shot", (c) => { + if (!isScreenshotsEnabled(c.env)) return c.notFound(); + return handleShot(c.req.raw, c.env, { ...(c.env.PUBLIC_SITE_ORIGIN ? { productionUrl: c.env.PUBLIC_SITE_ORIGIN } : {}), - }), - ); + }); + }); app.get("/v1/auth/github/start", async (c) => { try {