diff --git a/src/api/routes.ts b/src/api/routes.ts index f38f2387b6..b2538cbc44 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -1035,9 +1035,9 @@ export function createApp() { // 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. // 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 + // carries a /loopover/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) => { + app.get("/loopover/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 } : {}), diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts index e3ee5c2668..6b4e36e268 100644 --- a/src/auth/rate-limit.ts +++ b/src/auth/rate-limit.ts @@ -123,7 +123,7 @@ export function routeClassForPath(path: string): RateLimitClass { if (path === "/v1/orb/ingest") return "strict"; if (path === "/v1/auth/session" || path === "/v1/auth/logout") return "normal"; if (path.startsWith("/v1/auth/")) return "strict"; - if (path === "/gittensory/shot") return "expensive"; + if (path === "/loopover/shot") return "expensive"; if ( path.includes("/branch-analysis") || path.includes("/v1/agent/") || diff --git a/src/env.d.ts b/src/env.d.ts index 4416a6d542..dfc1cf1efd 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -33,17 +33,20 @@ declare global { * Workers-AI-safe constant (96) when unset — this override exists for self-host operators tuning * throughput on their own hardware (e.g. GPU-accelerated Ollama), not to change the hosted default. */ AI_EMBED_BATCH?: string; - /** Optional self-host review audit + visual-capture blob store. The Node runtime injects a filesystem-backed - * store when REVIEW_AUDIT_DIR is set, or an S3-compatible-bucket-backed store (an operator's own Cloudflare - * R2 bucket, or any other S3-compatible provider) when REVIEW_AUDIT_S3_BUCKET + _ENDPOINT + - * _ACCESS_KEY_ID + _SECRET_ACCESS_KEY are all set (takes priority when both are configured); the - * Cloudflare API worker no longer binds the review R2 bucket. */ + /** Review audit + visual-capture blob store. The Cloudflare API worker binds this natively to its own R2 + * bucket (see wrangler.jsonc's r2_buckets). Self-host has no native binding, so the Node runtime injects a + * filesystem-backed store when REVIEW_AUDIT_DIR is set, or an S3-compatible-bucket-backed store (an + * operator's own Cloudflare R2 bucket, or any other S3-compatible provider) when REVIEW_AUDIT_S3_BUCKET + + * _ENDPOINT + _ACCESS_KEY_ID + _SECRET_ACCESS_KEY are all set (takes priority when both are configured). */ REVIEW_AUDIT?: R2Bucket; + /** Reserved R2 binding for a future split of public-facing screenshot storage away from the private + * review-audit bucket (see wrangler.jsonc's r2_buckets) — not yet read or written by any code path. */ + VISUAL_CAPTURE_PUBLIC?: R2Bucket; /** Public base URL for an S3-compatible REVIEW_AUDIT bucket's own public read access (an R2 `r2.dev` public * bucket URL, or a custom domain connected to the bucket) -- see src/selfhost/s3-blob-store.ts. When set, * capture.ts's resolveShotUrl links screenshots DIRECTLY at `${this}/${key}` so GitHub's image proxy (and * every other viewer) fetches straight from the bucket's own CDN, never touching this instance's - * PUBLIC_API_ORIGIN at all. Unset (default) ⇒ served through this instance's own /gittensory/shot?key= + * PUBLIC_API_ORIGIN at all. Unset (default) ⇒ served through this instance's own /loopover/shot?key= * proxy route instead, exactly as before -- the bucket still gets used for storage, just not linked to * directly. Only meaningful alongside a configured REVIEW_AUDIT_S3_* bucket; ignored otherwise. */ REVIEW_AUDIT_S3_PUBLIC_URL?: string; @@ -298,7 +301,7 @@ declare global { * isVisualPath). "before" = production (PUBLIC_SITE_ORIGIN); "after" = the PR's preview deploy. Each shot * is rendered via the optional BROWSER binding, stored through REVIEW_AUDIT when configured, and embedded * in the unified PR comment as a "Visual preview" table — served either from this instance's own PUBLIC - * /gittensory/shot route, or, when REVIEW_AUDIT_S3_PUBLIC_URL is set, directly from the operator's own + * /loopover/shot route, or, when REVIEW_AUDIT_S3_PUBLIC_URL is set, directly from the operator's own * S3-compatible bucket instead (see src/selfhost/s3-blob-store.ts). Self-host equivalents are * BROWSER_WS_ENDPOINT + (REVIEW_AUDIT_DIR or the REVIEW_AUDIT_S3_* bucket vars); degrades gracefully * (placeholders / dashes) without them. Backend .ts/.md/.json/.py PRs NEVER trigger capture. Capture runs diff --git a/src/review/unified-comment-bridge.ts b/src/review/unified-comment-bridge.ts index 54859a31c1..00b8c26836 100644 --- a/src/review/unified-comment-bridge.ts +++ b/src/review/unified-comment-bridge.ts @@ -339,7 +339,7 @@ export type UnifiedCommentBridgeArgs = { /** Headline brand (default "LoopOver 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. + * collapsible (a markdown table of tags pointing at the public /loopover/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; @@ -436,7 +436,7 @@ export function buildVisualFindingsCollapsible(findings: string[]): UnifiedColla * pixel-diff provider is available and finds a real visual difference — absent on hosted builds and any * unchanged/no-diff cell, which render as a dash like every other missing shot. Emitted as TRUSTED raw HTML * (`rawHtml: true`) so the `/` survive — public-safe by construction: every value is a first-party - * minted /gittensory/shot URL or a route path (no private rubric / scoring terms), and a stray `"` in a URL + * minted /loopover/shot URL or a route path (no private rubric / scoring terms), and a stray `"` in a URL * is neutralized so it can't break out of the attribute. Returns null when nothing is renderable (no route * has any shot URL), so the section is omitted rather than shown empty. */ diff --git a/src/review/visual/actions-fallback.ts b/src/review/visual/actions-fallback.ts index 84ab57d5b0..bab735db83 100644 --- a/src/review/visual/actions-fallback.ts +++ b/src/review/visual/actions-fallback.ts @@ -148,7 +148,7 @@ export function parseFallbackRunCorrelation(displayTitle: string | undefined | n // Dispatch in-flight marker -- a persisted R2 sentinel, not a live GitHub API query (#4112 review fix). // --------------------------------------------------------------------------------------------------------- -const FALLBACK_DISPATCH_MARKER_NAMESPACE = "gittensory/fallback-dispatch/"; +const FALLBACK_DISPATCH_MARKER_NAMESPACE = "loopover/fallback-dispatch/"; /** The workflow's own `timeout-minutes: 15` (visual-capture-fallback.yml) plus a buffer for GitHub's own * runner-queueing delay before the job even starts -- a marker older than this is treated as abandoned @@ -459,7 +459,7 @@ export function fallbackShotFileName(path: string, viewport: "desktop" | "mobile // fingerprint against (capturePage's own key scheme needs a real "page" url; a fallback shot has none). // --------------------------------------------------------------------------------------------------------- -const FALLBACK_SHOT_NAMESPACE = "gittensory/shots/actions-fallback/"; +const FALLBACK_SHOT_NAMESPACE = "loopover/shots/actions-fallback/"; /** The R2 key a fallback-captured shot is stored/read under for one PR head + route + viewport. Pure content * address (no preview URL involved) -- deterministic so the write side (webhook handler) and the read side diff --git a/src/review/visual/capture.ts b/src/review/visual/capture.ts index bc5831bcaf..fb8ae6c625 100644 --- a/src/review/visual/capture.ts +++ b/src/review/visual/capture.ts @@ -4,7 +4,7 @@ // 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 either as /gittensory/shot?key= (this +// (env.REVIEW_AUDIT), and embedded either as /loopover/shot?key= (this // instance's own proxy route) or, when REVIEW_AUDIT_S3_PUBLIC_URL is configured (an operator's own // publicly-readable S3-compatible bucket — see src/selfhost/s3-blob-store.ts), a direct link at the // bucket's own public URL — see resolveShotUrl below. Either way, GitHub's image proxy fetches a fast @@ -31,7 +31,7 @@ import { captureScrollFrames, captureShot, DESKTOP_VIEWPORT, MOBILE_VIEWPORT, ty import { compareCapturedScreenshots, isVisualDiffAvailable, type VisualDiffOutcome } from "./pixel-diff"; import { encodeScrollGif, isScrollGifAvailable } from "./scroll-gif"; -const NAMESPACE = "gittensory"; +const NAMESPACE = "loopover"; const DEFAULT_ROUTES = ["/"]; // The app-folder segment is a wildcard, not hardcoded to gittensory-ui: metagraphed's UI (apps/ui/src/routes/) // uses the identical TanStack flat-file convention `routeForFile` below implements, just under a different app @@ -297,7 +297,7 @@ function routeForFile(raw: string): string { * The publicly-servable URL for an already-stored REVIEW_AUDIT key. Prefers a direct link at the operator's * own S3-compatible bucket (REVIEW_AUDIT_S3_PUBLIC_URL) so GitHub's image proxy — and every other viewer — * fetches straight from that bucket's own CDN, never touching this instance at all. Falls back to this - * instance's own /gittensory/shot?key= proxy route (today's only option, and still the only option for the + * instance's own /loopover/shot?key= proxy route (today's only option, and still the only option for the * filesystem-backed self-host store, which has no public URL of its own). Empty string when neither is * configured, matching every call site's existing "no shotBase" degradation. */ @@ -310,7 +310,7 @@ function resolveShotUrl(env: Env, key: string): string { } /** - * Render `page`, store the PNG in R2, and return its /gittensory/shot?key= URL. Falls back to an on-demand + * Render `page`, store the PNG in R2, and return its /loopover/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). @@ -336,7 +336,7 @@ async function capturePage( themeStorageKey?: string | undefined, ): Promise<{ url?: string | undefined; png?: Uint8Array | undefined }> { if (!page) return {}; - const shotBase = env.PUBLIC_API_ORIGIN; // this worker's public origin (serves /gittensory/shot) + const shotBase = env.PUBLIC_API_ORIGIN; // this worker's public origin (serves /loopover/shot) // Carries the theme (#3678) and, when set, the storage key (#4109) so a LATER on-demand fetch of this // exact URL (e.g. a failed/never-persisted render retried by GitHub's image proxy) still requests the // matching prefers-color-scheme/localStorage forcing, not the default — handleShot's Mode B reads these diff --git a/src/review/visual/shot.ts b/src/review/visual/shot.ts index 858ea43c56..c2ca371fdb 100644 --- a/src/review/visual/shot.ts +++ b/src/review/visual/shot.ts @@ -4,15 +4,15 @@ // • 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. +// • r2 key prefix default 'loopover/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 +// GET /loopover/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 + +// GET /loopover/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). +// GET /loopover/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). @@ -78,7 +78,7 @@ const THEME_STORAGE_WRITE_TIMEOUT_MS = 2_000; const THEME_STORAGE_RELOAD_TIMEOUT_MS = 20000; /** 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. */ + * allowlist. Defaults to loopover so the /loopover/shot route works with no options. */ export interface ShotOptions { namespace?: string; productionUrl?: string; @@ -443,7 +443,7 @@ export async function captureScrollFrames(env: Env, url: string, viewport: Viewp export async function handleShot(request: Request, env: Env, opts: ShotOptions = {}): Promise { const params = new URL(request.url).searchParams; - const r2Prefix = `${opts.namespace ?? "gittensory"}/shots/`; + const r2Prefix = `${opts.namespace ?? "loopover"}/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. diff --git a/src/selfhost/blob-store.ts b/src/selfhost/blob-store.ts index d48df5ff44..71bc611051 100644 --- a/src/selfhost/blob-store.ts +++ b/src/selfhost/blob-store.ts @@ -1,5 +1,5 @@ // Self-host blob store (#10). A minimal R2Bucket-compatible store backed by the local filesystem — the persistence -// the visual-review screenshot path (src/review/visual/capture.ts + the /gittensory/shot serve route) reads/writes +// the visual-review screenshot path (src/review/visual/capture.ts + the /loopover/shot serve route) reads/writes // through `env.REVIEW_AUDIT`. The cloud uses the Cloudflare R2 binding; self-host has none, so visual captures // previously could not be cached/persisted (they degraded to on-demand re-render). This implements only the get/put // surface those two paths use; every other R2Bucket method is unused on self-host. Node-only (fs import never @@ -9,7 +9,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, resolve, sep } from "node:path"; /** Build a filesystem-backed REVIEW_AUDIT store rooted at `baseDir`. Keys are app-generated - * (`gittensory/shots/.png`) and the serve route already prefix-checks + rejects `..`, but the path is + * (`loopover/shots/.png`) and the serve route already prefix-checks + rejects `..`, but the path is * resolved + boundary-checked here too so a key can never escape the base directory. */ export function createFsBlobStore(baseDir: string): R2Bucket { const base = resolve(baseDir); diff --git a/src/selfhost/s3-blob-store.ts b/src/selfhost/s3-blob-store.ts index fb4a0a0bbf..853a242dc5 100644 --- a/src/selfhost/s3-blob-store.ts +++ b/src/selfhost/s3-blob-store.ts @@ -5,14 +5,14 @@ // // Why this exists: the filesystem-backed store (REVIEW_AUDIT_DIR) persists screenshots on the SAME host that // runs the review container, so the images embedded in a public GitHub PR comment are only reachable through -// that host's own public origin (PUBLIC_API_ORIGIN) and the /gittensory/shot proxy route -- if an operator +// that host's own public origin (PUBLIC_API_ORIGIN) and the /loopover/shot proxy route -- if an operator // keeps their instance behind a private network (Tailscale, a firewall, no public DNS at all), those images // are unreachable for anyone outside that network, GitHub's own servers included. Storing in a genuinely // public bucket instead decouples "does my review pipeline run on my own infrastructure" from "are the // resulting public-facing images reachable by anyone" -- this store still only does get/put/delete; making the // resulting keys PUBLICLY SERVABLE (a public r2.dev URL, or a custom domain connected to the bucket) is the // operator's own one-time bucket setup, and `resolveShotUrl` (capture.ts) is what points served links directly -// at REVIEW_AUDIT_S3_PUBLIC_URL instead of this instance's own /gittensory/shot proxy once it's configured. +// at REVIEW_AUDIT_S3_PUBLIC_URL instead of this instance's own /loopover/shot proxy once it's configured. // // MODULAR + off by default: unset REVIEW_AUDIT_S3_BUCKET (+ _ENDPOINT/_ACCESS_KEY_ID/_SECRET_ACCESS_KEY) ⇒ no // REVIEW_AUDIT_S3 binding ⇒ server.ts falls back to REVIEW_AUDIT_DIR (or, if that's unset too, on-demand @@ -38,7 +38,7 @@ export type S3BlobStoreConfig = { const S3_CLIENT_RETRIES = 3; /** Build an S3-compatible-bucket-backed REVIEW_AUDIT store. Keys are app-generated - * (`gittensory/shots/.png`, already validated by the /gittensory/shot serve route's own prefix + + * (`loopover/shots/.png`, already validated by the /loopover/shot serve route's own prefix + * traversal check) and passed straight through as the S3 object key -- no additional encoding beyond the * URL-path escaping every S3 REST call needs regardless of key shape. */ export function createS3BlobStore(config: S3BlobStoreConfig): R2Bucket { diff --git a/src/selfhost/stubs/puppeteer.ts b/src/selfhost/stubs/puppeteer.ts index cc00e61a6c..98c5287754 100644 --- a/src/selfhost/stubs/puppeteer.ts +++ b/src/selfhost/stubs/puppeteer.ts @@ -1,6 +1,6 @@ // Self-host replacement for @cloudflare/puppeteer (#980). When BROWSER_WS_ENDPOINT is set, connects to an // external Chrome-compatible browser (e.g. a `browserless/chrome` sidecar) via puppeteer-core's WebSocket -// connect API — this makes the /gittensory/shot on-demand render endpoint fully functional. When the env var +// connect API — this makes the /loopover/shot on-demand render endpoint fully functional. When the env var // is absent, the functions throw so the caller's `if (!env.BROWSER)` guard (in shot.ts) short-circuits first. // Install: add `puppeteer-core` to package deps + set BROWSER_WS_ENDPOINT (or set INSTALL_VISUAL_REVIEW=true // in the Dockerfile and point at a `browserless/chrome:latest` sidecar). diff --git a/test/unit/actions-fallback.test.ts b/test/unit/actions-fallback.test.ts index bfd7c79189..73954e8332 100644 --- a/test/unit/actions-fallback.test.ts +++ b/test/unit/actions-fallback.test.ts @@ -190,7 +190,7 @@ describe("fallbackShotR2Key", () => { const a = await fallbackShotR2Key("deadbeef", "/pricing", "desktop"); const b = await fallbackShotR2Key("deadbeef", "/pricing", "desktop"); expect(a).toBe(b); - expect(a.startsWith("gittensory/shots/actions-fallback/")).toBe(true); + expect(a.startsWith("loopover/shots/actions-fallback/")).toBe(true); expect(a.endsWith(".png")).toBe(true); }); @@ -431,7 +431,7 @@ describe("isFallbackDispatchInFlight / markFallbackDispatched / clearFallbackDis const store = memoryFallbackMarkerStore(); const env = createTestEnv({ REVIEW_AUDIT: store }); const fingerprint = await sha256Hex(`${HEAD_SHA}:actions-fallback:dispatch-marker`); - const key = `gittensory/fallback-dispatch/${fingerprint.slice(0, 40)}.json`; + const key = `loopover/fallback-dispatch/${fingerprint.slice(0, 40)}.json`; await store.put(key, "not json"); await expect(isFallbackDispatchInFlight(env, HEAD_SHA)).resolves.toBe(false); }); @@ -440,7 +440,7 @@ describe("isFallbackDispatchInFlight / markFallbackDispatched / clearFallbackDis const store = memoryFallbackMarkerStore(); const env = createTestEnv({ REVIEW_AUDIT: store }); const fingerprint = await sha256Hex(`${HEAD_SHA}:actions-fallback:dispatch-marker`); - const key = `gittensory/fallback-dispatch/${fingerprint.slice(0, 40)}.json`; + const key = `loopover/fallback-dispatch/${fingerprint.slice(0, 40)}.json`; await store.put(key, JSON.stringify({ someOtherField: true })); await expect(isFallbackDispatchInFlight(env, HEAD_SHA)).resolves.toBe(false); }); diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index ec7c2d0657..db16524f9d 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -105,7 +105,7 @@ describe("private-beta auth and rate limiting", () => { expect(routeClassForPath("/v1/orb/ingest")).toBe("strict"); // open telemetry ingest — abuse-capped per IP expect(routeClassForPath("/v1/auth/github/device/start")).toBe("strict"); expect(routeClassForPath("/v1/local/branch-analysis")).toBe("expensive"); - expect(routeClassForPath("/gittensory/shot")).toBe("expensive"); + expect(routeClassForPath("/loopover/shot")).toBe("expensive"); expect(routeClassForPath("/v1/scoring/preview")).toBe("expensive"); expect(routeClassForPath("/v1/upstream/status")).toBe("expensive"); expect(routeClassForPath("/v1/contributors/jsonbored/decision-pack")).toBe("expensive"); diff --git a/test/unit/visual-capture.test.ts b/test/unit/visual-capture.test.ts index a00b6ef270..a4efa8ab35 100644 --- a/test/unit/visual-capture.test.ts +++ b/test/unit/visual-capture.test.ts @@ -56,7 +56,7 @@ function reviewAuditWithBrokenCachedBody(key: string): R2Bucket { async function shotKey(prNumber: number, slot: "before" | "after", viewportName: "desktop" | "mobile", page: string): Promise { const fingerprint = await sha256Hex(`${prNumber}:${slot}:${viewportName}:${page}`); - return `gittensory/shots/${fingerprint.slice(0, 40)}.png`; + return `loopover/shots/${fingerprint.slice(0, 40)}.png`; } afterEach(() => { @@ -118,8 +118,8 @@ describe("visual capture preview discovery", () => { path: "/app", beforeUrl: undefined, beforeUrlMobile: undefined, - afterUrl: "https://worker.example/gittensory/shot?placeholder=failed", - afterUrlMobile: "https://worker.example/gittensory/shot?placeholder=failed", + afterUrl: "https://worker.example/loopover/shot?placeholder=failed", + afterUrlMobile: "https://worker.example/loopover/shot?placeholder=failed", }, ]); expect(latestGitHubRestRateLimitObservation(key)).toEqual({ @@ -158,8 +158,8 @@ describe("visual capture preview discovery", () => { path: "/app", beforeUrl: undefined, beforeUrlMobile: undefined, - afterUrl: `https://worker.example/gittensory/shot?url=${encodeURIComponent("https://pr-42-abc1234.preview.example.com/app")}&w=1440&h=900`, - afterUrlMobile: `https://worker.example/gittensory/shot?url=${encodeURIComponent("https://pr-42-abc1234.preview.example.com/app")}&w=390&h=844`, + afterUrl: `https://worker.example/loopover/shot?url=${encodeURIComponent("https://pr-42-abc1234.preview.example.com/app")}&w=1440&h=900`, + afterUrlMobile: `https://worker.example/loopover/shot?url=${encodeURIComponent("https://pr-42-abc1234.preview.example.com/app")}&w=390&h=844`, }, ]); }); @@ -492,8 +492,8 @@ describe("buildCapture pixel-diff wiring (#3674)", () => { { repoFullName: "owner/repo", prNumber: 2, previewUrl: "https://preview.example.com" }, ["apps/gittensory-ui/src/routes/app.index.tsx"], ); - expect(result.routes[0]?.diffUrl).toContain("/gittensory/shot?key="); - expect(result.routes[0]?.diffUrlMobile).toContain("/gittensory/shot?key="); + expect(result.routes[0]?.diffUrl).toContain("/loopover/shot?key="); + expect(result.routes[0]?.diffUrlMobile).toContain("/loopover/shot?key="); expect(result.routes[0]?.diffUrl).not.toBe(result.routes[0]?.diffUrlMobile); } finally { availableSpy.mockRestore(); @@ -588,7 +588,7 @@ describe("buildCapture pixel-diff wiring (#3674)", () => { ["apps/gittensory-ui/src/routes/app.index.tsx"], ); - expect(result.routes[0]?.beforeUrl).toContain("/gittensory/shot?key="); + expect(result.routes[0]?.beforeUrl).toContain("/loopover/shot?key="); expect(result.routes[0]?.diffUrl).toBeUndefined(); }); @@ -610,7 +610,7 @@ describe("buildCapture pixel-diff wiring (#3674)", () => { ["apps/gittensory-ui/src/routes/app.index.tsx"], ); - expect(result.routes[0]?.beforeUrl).toContain("/gittensory/shot?key="); + expect(result.routes[0]?.beforeUrl).toContain("/loopover/shot?key="); } finally { availableSpy.mockRestore(); compareSpy.mockRestore(); @@ -634,7 +634,7 @@ describe("buildCapture pixel-diff wiring (#3674)", () => { ); expect(captureShotSpy).toHaveBeenCalled(); - expect(result.routes[0]?.beforeUrl).toContain("/gittensory/shot?key="); + expect(result.routes[0]?.beforeUrl).toContain("/loopover/shot?key="); expect(result.routes[0]?.diffUrl).toBeUndefined(); } finally { captureShotSpy.mockRestore(); @@ -691,7 +691,7 @@ describe("buildCapture pixel-diff wiring (#3674)", () => { ["apps/gittensory-ui/src/routes/app.index.tsx"], ); - expect(result.routes[0]?.diffUrl).toContain("/gittensory/shot?key="); + expect(result.routes[0]?.diffUrl).toContain("/loopover/shot?key="); } finally { availableSpy.mockRestore(); compareSpy.mockRestore(); @@ -700,7 +700,7 @@ describe("buildCapture pixel-diff wiring (#3674)", () => { }); describe("buildCapture with REVIEW_AUDIT_S3_PUBLIC_URL configured (direct bucket links)", () => { - it("links an already-cached shot directly at the bucket instead of this instance's /gittensory/shot proxy", async () => { + it("links an already-cached shot directly at the bucket instead of this instance's /loopover/shot proxy", async () => { const env = createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "https://prod.example.com", @@ -716,7 +716,7 @@ describe("buildCapture with REVIEW_AUDIT_S3_PUBLIC_URL configured (direct bucket ["apps/gittensory-ui/src/routes/app.index.tsx"], ); expect(result.routes[0]?.afterUrl).toBe(`https://pub-abc123.r2.dev/${afterKey}`); - expect(result.routes[0]?.afterUrl).not.toContain("/gittensory/shot?key="); + expect(result.routes[0]?.afterUrl).not.toContain("/loopover/shot?key="); }); it("strips a trailing slash from REVIEW_AUDIT_S3_PUBLIC_URL before joining the key", async () => { @@ -769,7 +769,7 @@ describe("buildCapture with REVIEW_AUDIT_S3_PUBLIC_URL configured (direct bucket { repoFullName: "owner/repo", prNumber: 23, previewUrl: "https://preview.example.com" }, ["apps/gittensory-ui/src/routes/app.index.tsx"], ); - expect(result.routes[0]?.afterUrl).toContain("worker.example/gittensory/shot?url="); + expect(result.routes[0]?.afterUrl).toContain("worker.example/loopover/shot?url="); }); it("uploadDiffImage links directly at the bucket even when PUBLIC_API_ORIGIN is unset (S3 public URL alone is enough)", async () => { @@ -925,7 +925,7 @@ describe("buildCapture theme matrix (#3678)", () => { { themes: ["dark"] }, ); expect(result.routes[0]?.theme).toBe("dark"); - expect(result.routes[0]?.diffUrl).toContain("/gittensory/shot?key="); + expect(result.routes[0]?.diffUrl).toContain("/loopover/shot?key="); // Same path/PR, but tagged "dark" — must not reuse the untagged diff's fingerprint (theme is part of the key). const untaggedFingerprint = await sha256Hex(`25:diff:desktop:/app`); expect(result.routes[0]?.diffUrl).not.toContain(untaggedFingerprint.slice(0, 40)); @@ -984,7 +984,7 @@ describe("buildCapture theme-storage-key wiring (#4109)", () => { { themes: ["dark"], themeStorageKey: "theme" }, ); expect(result.routes[0]?.beforeUrl).toBe( - `https://worker.example/gittensory/shot?url=${encodeURIComponent("https://prod.example.com/app")}&w=1440&h=900&theme=dark&themeStorageKey=${encodeURIComponent("theme")}`, + `https://worker.example/loopover/shot?url=${encodeURIComponent("https://prod.example.com/app")}&w=1440&h=900&theme=dark&themeStorageKey=${encodeURIComponent("theme")}`, ); }); @@ -997,7 +997,7 @@ describe("buildCapture theme-storage-key wiring (#4109)", () => { undefined, { themeStorageKey: "theme" }, ); - expect(result.routes[0]?.beforeUrl).toBe(`https://worker.example/gittensory/shot?url=${encodeURIComponent("https://prod.example.com/app")}&w=1440&h=900`); + expect(result.routes[0]?.beforeUrl).toBe(`https://worker.example/loopover/shot?url=${encodeURIComponent("https://prod.example.com/app")}&w=1440&h=900`); }); it("threads the theme storage key into the shot fingerprint too, so it never collides with an untagged-key capture of the same theme", async () => { @@ -1013,7 +1013,7 @@ describe("buildCapture theme-storage-key wiring (#4109)", () => { { themes: ["dark"], themeStorageKey: "theme" }, ); expect(result.routes[0]?.theme).toBe("dark"); - expect(result.routes[0]?.beforeUrl).toContain("/gittensory/shot?key="); + expect(result.routes[0]?.beforeUrl).toContain("/loopover/shot?key="); // Same PR/path/theme, but tagged with a storage key — must not reuse the untagged-key fingerprint. const untaggedFingerprint = await sha256Hex(`42:before:desktop:https://prod.example.com/app:dark`); expect(result.routes[0]?.beforeUrl).not.toContain(untaggedFingerprint.slice(0, 40)); @@ -1040,7 +1040,7 @@ describe("buildCapture theme-storage-key wiring (#4109)", () => { { gif: true, themes: ["dark"], themeStorageKey: "theme" }, ); expect(result.routes[0]?.theme).toBe("dark"); - expect(result.routes[0]?.afterGifUrl).toContain("/gittensory/shot?key="); + expect(result.routes[0]?.afterGifUrl).toContain("/loopover/shot?key="); const untaggedFingerprint = await sha256Hex(`43:scrollgif:after:desktop:https://preview.example.com/app:dark`); expect(result.routes[0]?.afterGifUrl).not.toContain(untaggedFingerprint.slice(0, 40)); expect(captureScrollSpy.mock.calls.some(([, , , opts]) => opts?.themeStorageKey === "theme")).toBe(true); @@ -1112,8 +1112,8 @@ describe("buildCapture scroll-GIF wiring (#3612)", () => { ); expect(captureScrollSpy).toHaveBeenCalledTimes(2); // before + after expect(encodeSpy).toHaveBeenCalledTimes(2); - expect(result.routes[0]?.beforeGifUrl).toContain("/gittensory/shot?key="); - expect(result.routes[0]?.afterGifUrl).toContain("/gittensory/shot?key="); + expect(result.routes[0]?.beforeGifUrl).toContain("/loopover/shot?key="); + expect(result.routes[0]?.afterGifUrl).toContain("/loopover/shot?key="); expect(result.routes[0]?.beforeGifUrl).not.toBe(result.routes[0]?.afterGifUrl); } finally { gifAvailableSpy.mockRestore(); @@ -1171,7 +1171,7 @@ describe("buildCapture scroll-GIF wiring (#3612)", () => { { gif: true }, ); expect(captureScrollSpy).toHaveBeenCalledTimes(1); // before only - expect(result.routes[0]?.beforeGifUrl).toContain("/gittensory/shot?key="); + expect(result.routes[0]?.beforeGifUrl).toContain("/loopover/shot?key="); expect(result.routes[0]?.afterGifUrl).toBeUndefined(); } finally { gifAvailableSpy.mockRestore(); @@ -1273,7 +1273,7 @@ describe("buildCapture scroll-GIF wiring (#3612)", () => { ); expect(captureScrollSpy).toHaveBeenCalledTimes(1); // after only — before has no page to capture expect(result.routes[0]?.beforeGifUrl).toBeUndefined(); - expect(result.routes[0]?.afterGifUrl).toContain("/gittensory/shot?key="); + expect(result.routes[0]?.afterGifUrl).toContain("/loopover/shot?key="); } finally { gifAvailableSpy.mockRestore(); captureScrollSpy.mockRestore(); @@ -1321,7 +1321,7 @@ describe("buildCapture scroll-GIF wiring (#3612)", () => { { gif: true, themes: ["dark"] }, ); expect(result.routes[0]?.theme).toBe("dark"); - expect(result.routes[0]?.afterGifUrl).toContain("/gittensory/shot?key="); + expect(result.routes[0]?.afterGifUrl).toContain("/loopover/shot?key="); const untaggedFingerprint = await sha256Hex(`39:scrollgif:after:desktop:https://preview.example.com/app`); expect(result.routes[0]?.afterGifUrl).not.toContain(untaggedFingerprint.slice(0, 40)); } finally { @@ -1353,7 +1353,7 @@ describe("buildCapture scroll-GIF wiring (#3612)", () => { { gif: true }, ); expect(captureScrollSpy).toHaveBeenCalled(); // cache lookup failed -> falls through to a fresh capture - expect(result.routes[0]?.beforeGifUrl).toContain("/gittensory/shot?key="); + expect(result.routes[0]?.beforeGifUrl).toContain("/loopover/shot?key="); } finally { gifAvailableSpy.mockRestore(); captureScrollSpy.mockRestore(); @@ -1403,8 +1403,8 @@ describe("buildCapture scroll-GIF wiring (#3612)", () => { undefined, { gif: true }, ); - expect(result.routes[0]?.beforeGifUrl).toContain("/gittensory/shot?key="); - expect(result.routes[0]?.afterGifUrl).toContain("/gittensory/shot?key="); + expect(result.routes[0]?.beforeGifUrl).toContain("/loopover/shot?key="); + expect(result.routes[0]?.afterGifUrl).toContain("/loopover/shot?key="); } finally { gifAvailableSpy.mockRestore(); captureScrollSpy.mockRestore(); @@ -1414,12 +1414,12 @@ describe("buildCapture scroll-GIF wiring (#3612)", () => { }); describe("hasSuccessfulBotCapture (#4110)", () => { - const REAL_BEFORE = "https://api.example/gittensory/shot?key=gittensory%2Fshots%2Fbefore.png"; - const REAL_AFTER = "https://api.example/gittensory/shot?key=gittensory%2Fshots%2Fafter.png"; - const ON_DEMAND_BEFORE = "https://api.example/gittensory/shot?url=https%3A%2F%2Fprod.example%2Fapp&w=1440&h=900"; - const ON_DEMAND_AFTER = "https://api.example/gittensory/shot?url=https%3A%2F%2Fpreview.example%2Fapp&w=1440&h=900"; - const LOADING_PLACEHOLDER = "https://api.example/gittensory/shot?placeholder=loading"; - const FAILED_PLACEHOLDER = "https://api.example/gittensory/shot?placeholder=failed"; + const REAL_BEFORE = "https://api.example/loopover/shot?key=gittensory%2Fshots%2Fbefore.png"; + const REAL_AFTER = "https://api.example/loopover/shot?key=gittensory%2Fshots%2Fafter.png"; + const ON_DEMAND_BEFORE = "https://api.example/loopover/shot?url=https%3A%2F%2Fprod.example%2Fapp&w=1440&h=900"; + const ON_DEMAND_AFTER = "https://api.example/loopover/shot?url=https%3A%2F%2Fpreview.example%2Fapp&w=1440&h=900"; + const LOADING_PLACEHOLDER = "https://api.example/loopover/shot?placeholder=loading"; + const FAILED_PLACEHOLDER = "https://api.example/loopover/shot?placeholder=failed"; function route(overrides: Partial = {}): CaptureRoute { return { path: "/app", ...overrides }; @@ -1722,7 +1722,7 @@ describe("review.visual.actions_fallback (#4112 GitHub-Actions build-and-serve f { actionsFallback: true }, ); - expect(result.routes[0]?.afterUrl).toBe(`https://worker.example/gittensory/shot?key=${encodeURIComponent(key)}`); + expect(result.routes[0]?.afterUrl).toBe(`https://worker.example/loopover/shot?key=${encodeURIComponent(key)}`); }); it("links an already-stored fallback shot directly at the bucket when REVIEW_AUDIT_S3_PUBLIC_URL is configured", async () => { @@ -1833,18 +1833,18 @@ describe("fetchShotContentBlock (#4111)", () => { it("returns a base64-encoded image content block on a successful fetch", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response(new Uint8Array([137, 80, 78, 71]), { status: 200 }))); - const block = await fetchShotContentBlock("https://x/gittensory/shot?key=before"); + const block = await fetchShotContentBlock("https://x/loopover/shot?key=before"); expect(block).toEqual({ type: "image", data: Buffer.from([137, 80, 78, 71]).toString("base64"), mimeType: "image/png" }); }); it("returns undefined on a non-2xx response", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response("not found", { status: 404 }))); - await expect(fetchShotContentBlock("https://x/gittensory/shot?key=missing")).resolves.toBeUndefined(); + await expect(fetchShotContentBlock("https://x/loopover/shot?key=missing")).resolves.toBeUndefined(); }); it("returns undefined (never throws) when fetch itself rejects", async () => { vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network down"); })); - await expect(fetchShotContentBlock("https://x/gittensory/shot?key=broken")).resolves.toBeUndefined(); + await expect(fetchShotContentBlock("https://x/loopover/shot?key=broken")).resolves.toBeUndefined(); }); }); diff --git a/test/unit/visual-shot.test.ts b/test/unit/visual-shot.test.ts index e093fc5be6..bece73fed7 100644 --- a/test/unit/visual-shot.test.ts +++ b/test/unit/visual-shot.test.ts @@ -31,11 +31,11 @@ function env(): Env { } function request(url: string): Request { - return new Request(`https://api.example.test/gittensory/shot?url=${encodeURIComponent(url)}`); + return new Request(`https://api.example.test/loopover/shot?url=${encodeURIComponent(url)}`); } function shotRequest(query: string): Request { - return new Request(`https://api.example.test/gittensory/shot?${query}`); + return new Request(`https://api.example.test/loopover/shot?${query}`); } // Minimal R2 stub: REVIEW_AUDIT.get(key) returns an object whose `.body` is a byte stream, or null. @@ -628,7 +628,7 @@ describe("visual screenshot placeholder cards", () => { describe("visual screenshot R2 key serve + traversal guard", () => { it("streams a stored PNG for a valid key inside the namespace", async () => { const png = new Uint8Array([10, 20, 30, 40]); - const key = "gittensory/shots/abc.png"; + const key = "loopover/shots/abc.png"; const response = await handleShot(shotRequest(`key=${encodeURIComponent(key)}`), r2Env({ [key]: png })); expect(response.status).toBe(200); @@ -639,7 +639,7 @@ describe("visual screenshot R2 key serve + traversal guard", () => { it("serves a .gif key with an image/gif content-type (#3612) — extension-derived, not stored httpMetadata", async () => { const gif = new Uint8Array([1, 2, 3, 4]); - const key = "gittensory/shots/abc.gif"; + const key = "loopover/shots/abc.gif"; const response = await handleShot(shotRequest(`key=${encodeURIComponent(key)}`), r2Env({ [key]: gif })); expect(response.status).toBe(200); @@ -649,7 +649,7 @@ describe("visual screenshot R2 key serve + traversal guard", () => { it("returns 404 for a valid key that is absent from R2", async () => { const response = await handleShot( - shotRequest(`key=${encodeURIComponent("gittensory/shots/missing.png")}`), + shotRequest(`key=${encodeURIComponent("loopover/shots/missing.png")}`), r2Env({}), ); @@ -659,7 +659,7 @@ describe("visual screenshot R2 key serve + traversal guard", () => { it("rejects a key that traverses with ..", async () => { const response = await handleShot( - shotRequest(`key=${encodeURIComponent("gittensory/shots/../../etc/passwd")}`), + shotRequest(`key=${encodeURIComponent("loopover/shots/../../etc/passwd")}`), r2Env({}), ); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 34e9be5122..e1947b1df9 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,7 +1,9 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: d5851e3e7ea2bc8cd084f39b4328cb86) +// Generated by Wrangler by running `wrangler types` (hash: edef2ca99ed4103c1ab8cdf89078e160) // Runtime types generated with workerd@1.20260701.1 2026-05-28 nodejs_compat interface __BaseEnv_Env { + REVIEW_AUDIT: R2Bucket; + VISUAL_CAPTURE_PUBLIC: R2Bucket; DB: D1Database; JOBS: Queue; GITHUB_OAUTH_CLIENT_ID: "Ov23lixYxDzyUKE070sm"; diff --git a/wrangler.jsonc b/wrangler.jsonc index 1fb51b625f..da88e5fe26 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -235,6 +235,20 @@ "custom_domain": true, }, ], + // R2 bucket bindings were previously configured directly via the dashboard, not tracked in code (#5332). + // REVIEW_AUDIT backs both the review-audit ledger and the /loopover/shot screenshot cache (key-prefixed); + // VISUAL_CAPTURE_PUBLIC is provisioned for a future split of public-facing screenshot storage but is not + // yet read/written by any code path. + "r2_buckets": [ + { + "binding": "REVIEW_AUDIT", + "bucket_name": "loopover-review-audit", + }, + { + "binding": "VISUAL_CAPTURE_PUBLIC", + "bucket_name": "loopover-visual-capture-public", + }, + ], "d1_databases": [ { "binding": "DB",