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 `` 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 ? `\]]/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 = ``;
+
+// 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 = ``;
+
+// 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 = ``;
+
+/** 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("");
+ expect(c?.body).toContain("");
+ 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("| `/` | — | ;
+ });
+
+ 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 {