From f889fe7d9edf9b23ef92314b7346ee1b4622a6dc Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Wed, 8 Jul 2026 04:17:14 -0700
Subject: [PATCH 1/2] feat(selfhost): support an S3-compatible bucket for
visual-capture storage
REVIEW_AUDIT (the visual-capture screenshot store) previously had only one
self-host backend: a local filesystem directory (REVIEW_AUDIT_DIR), served
back out through this instance's own /gittensory/shot proxy route. That
means the resulting URLs embedded in a public GitHub PR comment are
only reachable through this instance's own PUBLIC_API_ORIGIN -- if an
operator keeps their instance behind a private network (a VPN, a firewall,
no public DNS), GitHub's own servers can never fetch them, and every
screenshot renders as a broken image for every viewer.
Adds REVIEW_AUDIT_S3_BUCKET/_ENDPOINT/_ACCESS_KEY_ID/_SECRET_ACCESS_KEY/
_REGION: an alternative backend (src/selfhost/s3-blob-store.ts, using
aws4fetch to sign S3-compatible REST calls) that stores screenshots in an
operator's own bucket -- their own Cloudflare R2 bucket, or any other
S3-compatible provider -- instead of the local filesystem. Takes priority
over REVIEW_AUDIT_DIR when both are configured. A bounded retry budget (3
attempts, not aws4fetch's resilience-first default of 10) keeps a
persistently-misconfigured bucket from stalling the review pipeline for
30+ seconds on every best-effort read/write.
REVIEW_AUDIT_S3_PUBLIC_URL additionally makes served links point DIRECTLY
at the bucket's own public URL (an R2 public r2.dev bucket URL, or a
custom domain) instead of proxying through /gittensory/shot -- genuinely
decoupling "does my review pipeline run on my own infrastructure" from
"are the resulting public-facing images reachable by anyone." Unset (the
default) keeps today's proxy-based serving unchanged.
Also adds a delete() method to the filesystem-backed store (blob-store.ts)
for interface parity with the new S3 one -- actions-fallback.ts's dispatch-
marker cleanup already calls .delete() and previously depended on that
call silently throwing-and-being-caught on self-host, since the fs store
never implemented it.
---
.env.example | 28 +-
.../src/lib/selfhost-env-reference.ts | 25 +
.../docs.self-hosting-configuration.tsx | 12 +
package-lock.json | 1010 +----------------
package.json | 1 +
src/env.d.ts | 29 +-
src/review/visual/capture.ts | 38 +-
src/selfhost/blob-store.ts | 8 +-
src/selfhost/s3-blob-store.ts | 91 ++
src/server.ts | 35 +-
test/unit/selfhost-blob-store.test.ts | 12 +
test/unit/selfhost-s3-blob-store.test.ts | 122 ++
test/unit/visual-capture.test.ts | 181 +++
13 files changed, 561 insertions(+), 1031 deletions(-)
create mode 100644 src/selfhost/s3-blob-store.ts
create mode 100644 test/unit/selfhost-s3-blob-store.test.ts
diff --git a/.env.example b/.env.example
index 3ce1260f97..ea90402971 100644
--- a/.env.example
+++ b/.env.example
@@ -181,7 +181,11 @@ GITTENSORY_REVIEW_DRAFT=false
# # PUBLIC_SITE_ORIGIN look like a private/internal hostname (e.g. a
# # bare Tailscale MagicDNS `*.ts.net` address) once you've confirmed
# # it's genuinely public (Funnel enabled, a reverse proxy in front of
-# # it, etc.). Default false (warning shown).
+# # it, etc.). Default false (warning shown). If your instance is
+# # NOT genuinely public and you'd rather not expose it, see
+# # REVIEW_AUDIT_S3_BUCKET below instead -- it moves screenshot
+# # serving to a public bucket so this warning never applies to
+# # PUBLIC_API_ORIGIN in the first place.
# SELFHOST_SETUP_TOKEN=change-this-long-random-value # REQUIRED to unlock the first-run /setup wizard. Without it
# # /setup returns 400; with it, enter the token in the browser form
# # or send an x-setup-token / Bearer header. Never put this token in
@@ -268,7 +272,27 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
# BROWSERLESS_MEM_LIMIT=2g # (--profile visual-review) container memory cap.
# REVIEW_AUDIT_DIR= # persist visual-review screenshot PNGs to this filesystem path
# # instead of re-rendering on demand. Unset = re-render each time.
-# # Only relevant when BROWSER_WS_ENDPOINT above is set.
+# # Only relevant when BROWSER_WS_ENDPOINT above is set. Ignored
+# # when REVIEW_AUDIT_S3_BUCKET below is configured (S3 wins).
+# --- Visual-review screenshot storage in an S3-compatible bucket (optional; an operator's own Cloudflare R2
+# bucket, or any other S3-compatible provider) instead of the local filesystem above. Recommended once
+# GITTENSORY_REVIEW_SCREENSHOTS is on: screenshots embedded in a PUBLIC GitHub PR comment need to be
+# reachable by GitHub itself and by anyone viewing the PR, not just by this instance's own network. All
+# four of the following must be set together to enable it. ---
+# REVIEW_AUDIT_S3_BUCKET= # bucket name.
+# REVIEW_AUDIT_S3_ENDPOINT= # e.g. https://.r2.cloudflarestorage.com for R2.
+# REVIEW_AUDIT_S3_ACCESS_KEY_ID= # e.g. an R2 API token's Access Key ID (Object Read & Write,
+# # scoped to this one bucket, is enough).
+# REVIEW_AUDIT_S3_SECRET_ACCESS_KEY= # the matching Secret Access Key.
+# REVIEW_AUDIT_S3_REGION=auto # "auto" for R2 (its own S3-API convention); a real AWS region
+# # string for other S3-compatible providers. Default "auto".
+# REVIEW_AUDIT_S3_PUBLIC_URL= # the bucket's own public base URL (an R2 public r2.dev bucket
+# # URL, or a custom domain connected to the bucket) -- when set,
+# # screenshots link DIRECTLY at this URL instead of proxying
+# # through this instance's own /gittensory/shot route. Strongly
+# # recommended alongside the bucket vars above; without it,
+# # screenshots are stored in the bucket but still served through
+# # this instance (same reachability requirement as REVIEW_AUDIT_DIR).
# DISCORD_WEBHOOK_URL= # one Discord channel for per-action notifications (merged/closed/
# # manual) on ANY repo you review. Unset = no Discord notifications.
# # Collection and schema are auto-created at startup. Off when unset.
diff --git a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts
index 482340970b..2f59b03eb6 100644
--- a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts
+++ b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts
@@ -369,6 +369,26 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
name: "REVIEW_AUDIT_DIR",
firstReference: "src/server.ts",
},
+ {
+ name: "REVIEW_AUDIT_S3_ACCESS_KEY_ID",
+ firstReference: "src/server.ts",
+ },
+ {
+ name: "REVIEW_AUDIT_S3_BUCKET",
+ firstReference: "src/server.ts",
+ },
+ {
+ name: "REVIEW_AUDIT_S3_ENDPOINT",
+ firstReference: "src/server.ts",
+ },
+ {
+ name: "REVIEW_AUDIT_S3_REGION",
+ firstReference: "src/server.ts",
+ },
+ {
+ name: "REVIEW_AUDIT_S3_SECRET_ACCESS_KEY",
+ firstReference: "src/server.ts",
+ },
{
name: "SELFHOST_BUNDLE_ALL",
firstReference: "scripts/build-selfhost.mjs",
@@ -505,6 +525,11 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
"| `QUEUE_STARTUP_JITTER_MIN_JOBS` | `src/selfhost/queue-common.ts` |",
"| `REDIS_URL` | `src/selfhost/preflight.ts` |",
"| `REVIEW_AUDIT_DIR` | `src/server.ts` |",
+ "| `REVIEW_AUDIT_S3_ACCESS_KEY_ID` | `src/server.ts` |",
+ "| `REVIEW_AUDIT_S3_BUCKET` | `src/server.ts` |",
+ "| `REVIEW_AUDIT_S3_ENDPOINT` | `src/server.ts` |",
+ "| `REVIEW_AUDIT_S3_REGION` | `src/server.ts` |",
+ "| `REVIEW_AUDIT_S3_SECRET_ACCESS_KEY` | `src/server.ts` |",
"| `SELFHOST_BUNDLE_ALL` | `scripts/build-selfhost.mjs` |",
"| `SELFHOST_SERVICE` | `scripts/smoke-observability-traces.mjs` |",
"| `SELFHOST_SETUP_TOKEN` | `src/selfhost/preflight.ts` |",
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx
index 985fbe3125..092d349cc1 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx
@@ -232,6 +232,18 @@ MCP_ACTUATION_REPO_ALLOWLIST=owner/repo-one, owner/repo-two
REES enrichment) is also set — visual review is
fully inert without it.
+
+ REVIEW_AUDIT_S3_BUCKET / _ENDPOINT / _ACCESS_KEY_ID{" "}
+ / _SECRET_ACCESS_KEY — an alternative to REVIEW_AUDIT_DIR:
+ persist screenshots in an S3-compatible bucket (your own Cloudflare R2 bucket, or any
+ other S3-compatible provider) instead of the local filesystem, and set{" "}
+ REVIEW_AUDIT_S3_PUBLIC_URL to that bucket’s own public base URL so
+ screenshots link directly at the bucket instead of proxying through this instance. This
+ matters if your instance sits behind a private network (a VPN, a firewall, no public DNS)
+ — without a public bucket, screenshots embedded in a public PR comment are unreachable by
+ GitHub and by anyone viewing the PR who isn’t on that same private network. Takes
+ priority over REVIEW_AUDIT_DIR when both are set.
+
CODEX_HOME — do not set this for the app container. The Codex provider
rejects a container-set CODEX_HOME outright (fails closed with{" "}
diff --git a/package-lock.json b/package-lock.json
index b472807276..a64edb5bad 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
"@sentry/node": "^10.63.0",
"@sentry/opentelemetry": "^10.63.0",
"agents": "^0.17.3",
+ "aws4fetch": "^1.0.20",
"drizzle-orm": "^0.45.2",
"hono": "^4.12.27",
"ioredis": "^5.11.1",
@@ -350,58 +351,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@ai-sdk/gateway": {
- "version": "3.0.120",
- "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.120.tgz",
- "integrity": "sha512-MYKAeD2q7/sa1ZdqtL2tw0Me0B8Tok6Q/fhkJDhJl39dG8u+VBlWO9yk9lcdm784bM418o1EKObo4aOxs6+18Q==",
- "license": "Apache-2.0",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@ai-sdk/provider": "3.0.10",
- "@ai-sdk/provider-utils": "4.0.27",
- "@vercel/oidc": "3.2.0"
- },
- "engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "zod": "^3.25.76 || ^4.1.8"
- }
- },
- "node_modules/@ai-sdk/provider": {
- "version": "3.0.10",
- "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz",
- "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==",
- "license": "Apache-2.0",
- "optional": true,
- "peer": true,
- "dependencies": {
- "json-schema": "^0.4.0"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@ai-sdk/provider-utils": {
- "version": "4.0.27",
- "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz",
- "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==",
- "license": "Apache-2.0",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@ai-sdk/provider": "3.0.10",
- "@standard-schema/spec": "^1.1.0",
- "eventsource-parser": "^3.0.8"
- },
- "engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "zod": "^3.25.76 || ^4.1.8"
- }
- },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -822,18 +771,6 @@
"node": ">=18"
}
},
- "node_modules/@borewit/text-codec": {
- "version": "0.2.2",
- "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
- "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
"node_modules/@cfworker/json-schema": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
@@ -2462,58 +2399,6 @@
"node": ">=18.0.0"
}
},
- "node_modules/@jitl/quickjs-ffi-types": {
- "version": "0.32.0",
- "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
- "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
- "license": "MIT",
- "optional": true,
- "peer": true
- },
- "node_modules/@jitl/quickjs-wasmfile-debug-asyncify": {
- "version": "0.32.0",
- "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz",
- "integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@jitl/quickjs-ffi-types": "0.32.0"
- }
- },
- "node_modules/@jitl/quickjs-wasmfile-debug-sync": {
- "version": "0.32.0",
- "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz",
- "integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@jitl/quickjs-ffi-types": "0.32.0"
- }
- },
- "node_modules/@jitl/quickjs-wasmfile-release-asyncify": {
- "version": "0.32.0",
- "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz",
- "integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@jitl/quickjs-ffi-types": "0.32.0"
- }
- },
- "node_modules/@jitl/quickjs-wasmfile-release-sync": {
- "version": "0.32.0",
- "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz",
- "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@jitl/quickjs-ffi-types": "0.32.0"
- }
- },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2626,14 +2511,6 @@
}
}
},
- "node_modules/@mixmark-io/domino": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
- "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
- "license": "BSD-2-Clause",
- "optional": true,
- "peer": true
- },
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -2695,22 +2572,6 @@
"node": ">= 0.6"
}
},
- "node_modules/@mongodb-js/zstd": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/@mongodb-js/zstd/-/zstd-7.0.0.tgz",
- "integrity": "sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA==",
- "hasInstallScript": true,
- "license": "Apache-2.0",
- "optional": true,
- "peer": true,
- "dependencies": {
- "node-addon-api": "^8.5.0",
- "prebuild-install": "^7.1.3"
- },
- "engines": {
- "node": ">= 20.19.0"
- }
- },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -2729,20 +2590,6 @@
"@emnapi/runtime": "^1.7.1"
}
},
- "node_modules/@nodable/entities": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
- "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/nodable"
- }
- ],
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -6031,33 +5878,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
- "node_modules/@tokenizer/inflate": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
- "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "debug": "^4.4.3",
- "token-types": "^6.1.1"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
- "node_modules/@tokenizer/token": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
- "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/@tootallnate/quickjs-emscripten": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
@@ -6586,17 +6406,6 @@
"url": "https://opencollective.com/eslint"
}
},
- "node_modules/@vercel/oidc": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz",
- "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==",
- "license": "Apache-2.0",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">= 20"
- }
- },
"node_modules/@vitejs/plugin-react": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
@@ -7274,26 +7083,6 @@
"node": "^18 || >=20"
}
},
- "node_modules/ai": {
- "version": "6.0.191",
- "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.191.tgz",
- "integrity": "sha512-zAxvjKebQE7YkSyyNIl0OM7i6/zygnKeF+yNUjD4nWOelYrG+LpDd6RnH6mjySI4zUpZ7o4wbnmAy8jc6u98vQ==",
- "license": "Apache-2.0",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@ai-sdk/gateway": "3.0.120",
- "@ai-sdk/provider": "3.0.10",
- "@ai-sdk/provider-utils": "4.0.27",
- "@opentelemetry/api": "^1.9.0"
- },
- "engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "zod": "^3.25.76 || ^4.1.8"
- }
- },
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -7394,20 +7183,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
- "node_modules/anynum": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
- "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/NaturalIntelligence"
- }
- ],
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -7504,6 +7279,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/aws4fetch": {
+ "version": "1.0.20",
+ "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz",
+ "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==",
+ "license": "MIT"
+ },
"node_modules/b4a": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz",
@@ -7689,19 +7470,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/bl": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
- "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "buffer": "^5.5.0",
- "inherits": "^2.0.4",
- "readable-stream": "^3.4.0"
- }
- },
"node_modules/blake3-wasm": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
@@ -8536,34 +8304,6 @@
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
- "node_modules/decompress-response": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
- "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "mimic-response": "^3.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/deep-extend": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
- "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=4.0.0"
- }
- },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -9479,17 +9219,6 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
- "node_modules/expand-template": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
- "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
- "license": "(MIT OR WTFPL)",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -9731,49 +9460,6 @@
],
"license": "BSD-3-Clause"
},
- "node_modules/fast-xml-builder": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
- "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/NaturalIntelligence"
- }
- ],
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "path-expression-matcher": "^1.5.0",
- "xml-naming": "^0.1.0"
- }
- },
- "node_modules/fast-xml-parser": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz",
- "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/NaturalIntelligence"
- }
- ],
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@nodable/entities": "^2.2.0",
- "fast-xml-builder": "^1.2.0",
- "is-unsafe": "^1.0.1",
- "path-expression-matcher": "^1.5.0",
- "strnum": "^2.4.1",
- "xml-naming": "^0.1.0"
- },
- "bin": {
- "fxparser": "src/cli/cli.js"
- }
- },
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -9845,26 +9531,6 @@
"node": ">=16.0.0"
}
},
- "node_modules/file-type": {
- "version": "21.3.4",
- "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
- "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@tokenizer/inflate": "^0.4.1",
- "strtok3": "^10.3.4",
- "token-types": "^6.1.1",
- "uint8array-extras": "^1.4.0"
- },
- "engines": {
- "node": ">=20"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/file-type?sponsor=1"
- }
- },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -10035,14 +9701,6 @@
"node": ">= 0.8"
}
},
- "node_modules/fs-constants": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
- "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -10318,14 +9976,6 @@
"node": ">=18.0.0"
}
},
- "node_modules/github-from-package": {
- "version": "0.0.0",
- "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
- "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -10686,17 +10336,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
- "node_modules/ini": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
- "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "engines": {
- "node": "^20.17.0 || >=22.9.0"
- }
- },
"node_modules/input-otp": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
@@ -10879,20 +10518,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/is-unsafe": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz",
- "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/NaturalIntelligence"
- }
- ],
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/isbot": {
"version": "5.1.40",
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.40.tgz",
@@ -11076,14 +10701,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/json-schema": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
- "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
- "license": "(AFL-2.1 OR BSD-3-Clause)",
- "optional": true,
- "peer": true
- },
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -11121,81 +10738,6 @@
"node": ">=6"
}
},
- "node_modules/just-bash": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/just-bash/-/just-bash-3.0.1.tgz",
- "integrity": "sha512-YVyzCN08fKarUnwqy7rKOAcX+2MLYLnYInuowmUXn3mqhrtd4ieZNBuzdQG+qYV9DqnIWuv9Whiph0WRIWsBtw==",
- "license": "Apache-2.0",
- "optional": true,
- "peer": true,
- "dependencies": {
- "diff": "^8.0.2",
- "fast-xml-parser": "^5.7.3",
- "file-type": "^21.2.0",
- "ini": "^6.0.0",
- "minimatch": "^10.1.1",
- "modern-tar": "^0.7.3",
- "papaparse": "^5.5.3",
- "quickjs-emscripten": "^0.32.0",
- "re2js": "^1.2.1",
- "seek-bzip": "^2.0.0",
- "smol-toml": "^1.6.0",
- "sprintf-js": "^1.1.3",
- "sql.js": "^1.13.0",
- "turndown": "^7.2.2",
- "yaml": "^2.8.2"
- },
- "bin": {
- "just-bash": "dist/bin/just-bash.js",
- "just-bash-shell": "dist/bin/shell/shell.js"
- },
- "optionalDependencies": {
- "@mongodb-js/zstd": "^7.0.0",
- "node-liblzma": "^2.0.3"
- }
- },
- "node_modules/just-bash/node_modules/balanced-match": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
- "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
- "node_modules/just-bash/node_modules/brace-expansion": {
- "version": "5.0.6",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
- "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "balanced-match": "^4.0.2"
- },
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
- "node_modules/just-bash/node_modules/minimatch": {
- "version": "10.2.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
- "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
- "license": "BlueOak-1.0.0",
- "optional": true,
- "peer": true,
- "dependencies": {
- "brace-expansion": "^5.0.5"
- },
- "engines": {
- "node": "18 || 20 || >=22"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -11881,20 +11423,6 @@
"node": ">= 0.6"
}
},
- "node_modules/mimic-response": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
- "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/miniflare": {
"version": "4.20260701.0",
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260701.0.tgz",
@@ -11939,17 +11467,6 @@
"node": "*"
}
},
- "node_modules/minimist": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
- "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
@@ -11973,25 +11490,6 @@
"node": ">= 18"
}
},
- "node_modules/mkdirp-classic": {
- "version": "0.5.3",
- "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
- "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
- "license": "MIT",
- "optional": true,
- "peer": true
- },
- "node_modules/modern-tar": {
- "version": "0.7.6",
- "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz",
- "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=18.0.0"
- }
- },
"node_modules/module-details-from-path": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
@@ -12075,14 +11573,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
- "node_modules/napi-build-utils": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
- "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -12115,20 +11605,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/node-abi": {
- "version": "3.92.0",
- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
- "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "semver": "^7.3.5"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/node-addon-api": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
@@ -12164,42 +11640,6 @@
"node": "^20.17.0 || >=22.9.0"
}
},
- "node_modules/node-gyp-build": {
- "version": "4.8.4",
- "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
- "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "bin": {
- "node-gyp-build": "bin.js",
- "node-gyp-build-optional": "optional.js",
- "node-gyp-build-test": "build-test.js"
- }
- },
- "node_modules/node-liblzma": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/node-liblzma/-/node-liblzma-2.2.0.tgz",
- "integrity": "sha512-s0KzNOWwOJJgPG6wxg6cKohnAl9Wk/oW1KrQaVzJBjQwVcUGPQCzpR46Ximygjqj/3KhOrtJXnYMp/xYAXp75g==",
- "hasInstallScript": true,
- "license": "LGPL-3.0",
- "optional": true,
- "peer": true,
- "dependencies": {
- "node-addon-api": "^8.5.0",
- "node-gyp-build": "^4.8.4"
- },
- "bin": {
- "nxz": "lib/cli/nxz.js"
- },
- "engines": {
- "node": ">=16.0.0"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/oorabona"
- }
- },
"node_modules/node-releases": {
"version": "2.0.46",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
@@ -12449,14 +11889,6 @@
"node": ">= 14"
}
},
- "node_modules/papaparse": {
- "version": "5.5.4",
- "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz",
- "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==",
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -12562,23 +11994,6 @@
"node": ">=8"
}
},
- "node_modules/path-expression-matcher": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
- "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/NaturalIntelligence"
- }
- ],
- "license": "MIT",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=14.0.0"
- }
- },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -13021,35 +12436,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/prebuild-install": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
- "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
- "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "detect-libc": "^2.0.0",
- "expand-template": "^2.0.3",
- "github-from-package": "0.0.0",
- "minimist": "^1.2.3",
- "mkdirp-classic": "^0.5.3",
- "napi-build-utils": "^2.0.0",
- "node-abi": "^3.3.0",
- "pump": "^3.0.0",
- "rc": "^1.2.7",
- "simple-get": "^4.0.0",
- "tar-fs": "^2.0.0",
- "tunnel-agent": "^0.6.0"
- },
- "bin": {
- "prebuild-install": "bin.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -13288,35 +12674,6 @@
],
"license": "MIT"
},
- "node_modules/quickjs-emscripten": {
- "version": "0.32.0",
- "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz",
- "integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0",
- "@jitl/quickjs-wasmfile-debug-sync": "0.32.0",
- "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0",
- "@jitl/quickjs-wasmfile-release-sync": "0.32.0",
- "quickjs-emscripten-core": "0.32.0"
- },
- "engines": {
- "node": ">=16.0.0"
- }
- },
- "node_modules/quickjs-emscripten-core": {
- "version": "0.32.0",
- "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
- "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@jitl/quickjs-ffi-types": "0.32.0"
- }
- },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -13341,50 +12698,6 @@
"node": ">= 0.10"
}
},
- "node_modules/rc": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
- "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
- "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
- "optional": true,
- "peer": true,
- "dependencies": {
- "deep-extend": "^0.6.0",
- "ini": "~1.3.0",
- "minimist": "^1.2.0",
- "strip-json-comments": "~2.0.1"
- },
- "bin": {
- "rc": "cli.js"
- }
- },
- "node_modules/rc/node_modules/ini": {
- "version": "1.3.8",
- "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
- "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
- "license": "ISC",
- "optional": true,
- "peer": true
- },
- "node_modules/rc/node_modules/strip-json-comments": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
- "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/re2js": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/re2js/-/re2js-1.3.3.tgz",
- "integrity": "sha512-s/I5zEAo79SUK0Qw4dpZKpiMwbQ6Gz0KU2NRr7eaO4x/p2g7Vvmn3hdeXDg8VsaUjfj/ora+e9oi27LX/C9+mw==",
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
@@ -13580,22 +12893,6 @@
"pify": "^2.3.0"
}
},
- "node_modules/readable-stream": {
- "version": "3.6.2",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
- "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -13843,28 +13140,6 @@
"queue-microtask": "^1.2.2"
}
},
- "node_modules/safe-buffer": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
- "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -13890,32 +13165,6 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
- "node_modules/seek-bzip": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-2.0.0.tgz",
- "integrity": "sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "commander": "^6.0.0"
- },
- "bin": {
- "seek-bunzip": "bin/seek-bunzip",
- "seek-table": "bin/seek-bzip-table"
- }
- },
- "node_modules/seek-bzip/node_modules/commander": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
- "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/semifies": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz",
@@ -14164,55 +13413,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/simple-concat": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
- "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "optional": true,
- "peer": true
- },
- "node_modules/simple-get": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
- "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "decompress-response": "^6.0.0",
- "once": "^1.3.1",
- "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",
@@ -14223,20 +13423,6 @@
"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",
- "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
- "license": "BSD-3-Clause",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">= 18"
- },
- "funding": {
- "url": "https://github.com/sponsors/cyyynthia"
- }
- },
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
@@ -14313,22 +13499,6 @@
"node": ">= 10.x"
}
},
- "node_modules/sprintf-js": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
- "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
- "license": "BSD-3-Clause",
- "optional": true,
- "peer": true
- },
- "node_modules/sql.js": {
- "version": "1.14.1",
- "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
- "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/srvx": {
"version": "0.11.16",
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.16.tgz",
@@ -14381,17 +13551,6 @@
"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",
- "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "safe-buffer": "~5.2.0"
- }
- },
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
@@ -14450,41 +13609,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/strnum": {
- "version": "2.4.1",
- "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
- "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/NaturalIntelligence"
- }
- ],
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "anynum": "^1.0.1"
- }
- },
- "node_modules/strtok3": {
- "version": "10.3.5",
- "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
- "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@tokenizer/token": "^0.3.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -14603,46 +13727,6 @@
"node": ">=18"
}
},
- "node_modules/tar-fs": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
- "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "chownr": "^1.1.1",
- "mkdirp-classic": "^0.5.2",
- "pump": "^3.0.0",
- "tar-stream": "^2.1.4"
- }
- },
- "node_modules/tar-fs/node_modules/chownr": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
- "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
- "license": "ISC",
- "optional": true,
- "peer": true
- },
- "node_modules/tar-stream": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
- "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "bl": "^4.0.3",
- "end-of-stream": "^1.4.1",
- "fs-constants": "^1.0.0",
- "inherits": "^2.0.3",
- "readable-stream": "^3.1.1"
- },
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/teex": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
@@ -14781,26 +13865,6 @@
"node": ">=0.6"
}
},
- "node_modules/token-types": {
- "version": "6.1.2",
- "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
- "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@borewit/text-codec": "^0.2.1",
- "@tokenizer/token": "^0.3.0",
- "ieee754": "^1.2.1"
- },
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
@@ -14892,35 +13956,6 @@
"fsevents": "~2.3.3"
}
},
- "node_modules/tunnel-agent": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
- "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
- "license": "Apache-2.0",
- "optional": true,
- "peer": true,
- "dependencies": {
- "safe-buffer": "^5.0.1"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/turndown": {
- "version": "7.2.4",
- "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
- "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@mixmark-io/domino": "^2.2.0"
- },
- "engines": {
- "node": ">=18",
- "npm": ">=9"
- }
- },
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
@@ -15005,20 +14040,6 @@
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"license": "MIT"
},
- "node_modules/uint8array-extras": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
- "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "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",
@@ -15675,23 +14696,6 @@
"node": ">=18"
}
},
- "node_modules/xml-naming": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
- "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/NaturalIntelligence"
- }
- ],
- "license": "MIT",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=16.0.0"
- }
- },
"node_modules/xmlbuilder2": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz",
diff --git a/package.json b/package.json
index c6c4bf71fe..6fe3dedf83 100644
--- a/package.json
+++ b/package.json
@@ -94,6 +94,7 @@
"@sentry/node": "^10.63.0",
"@sentry/opentelemetry": "^10.63.0",
"agents": "^0.17.3",
+ "aws4fetch": "^1.0.20",
"drizzle-orm": "^0.45.2",
"hono": "^4.12.27",
"ioredis": "^5.11.1",
diff --git a/src/env.d.ts b/src/env.d.ts
index 38c67fbfed..4dd9cf356e 100644
--- a/src/env.d.ts
+++ b/src/env.d.ts
@@ -18,8 +18,19 @@ declare global {
/** Self-host RAG vector width. Must match the configured embedding model and vector backend. */
QDRANT_DIM?: string;
/** Optional self-host review audit + visual-capture blob store. The Node runtime injects a filesystem-backed
- * store when REVIEW_AUDIT_DIR is set; the Cloudflare API worker no longer binds the review R2 bucket. */
+ * 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?: 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=
+ * 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;
/** Optional visual capture binding. Self-host exposes this when BROWSER_WS_ENDPOINT is set; the Cloudflare API
* worker no longer binds Browser Rendering for reviews. */
BROWSER?: Fetcher;
@@ -206,13 +217,15 @@ declare global {
/** 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 optional BROWSER binding, stored through REVIEW_AUDIT when configured, and embedded in
- * the unified PR comment as a "Visual preview" table served from the PUBLIC /gittensory/shot route. Self-host
- * equivalents are BROWSER_WS_ENDPOINT + REVIEW_AUDIT_DIR; 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 audit
- * write, no comment change) so the review path is byte-identical to today. */
+ * 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
+ * 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
+ * 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 audit 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
diff --git a/src/review/visual/capture.ts b/src/review/visual/capture.ts
index a8d7910678..8f5ac2fa18 100644
--- a/src/review/visual/capture.ts
+++ b/src/review/visual/capture.ts
@@ -3,8 +3,11 @@
// 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.
+// (env.REVIEW_AUDIT), and embedded either as /gittensory/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
+// 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
@@ -199,6 +202,22 @@ function routeForFile(raw: string): string {
return `/${segments.join("/")}`.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
}
+/**
+ * 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
+ * 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.
+ */
+function resolveShotUrl(env: Env, key: string): string {
+ if (env.REVIEW_AUDIT_S3_PUBLIC_URL) {
+ return `${env.REVIEW_AUDIT_S3_PUBLIC_URL.replace(/\/+$/, "")}/${key}`;
+ }
+ const shotBase = env.PUBLIC_API_ORIGIN;
+ return shotBase ? `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}` : "";
+}
+
/**
* 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
@@ -242,7 +261,7 @@ async function capturePage(
`${target.headSha ?? target.prNumber}:${slot}:${viewportName}:${page}${theme ? `:${theme}` : ""}${theme && themeStorageKey ? `:${themeStorageKey}` : ""}`,
);
const key = `${NAMESPACE}/shots/${fingerprint.slice(0, 40)}.png`;
- const url = shotBase ? `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}` : onDemand;
+ const url = resolveShotUrl(env, key) || onDemand;
const cached = await env.REVIEW_AUDIT.get(key).catch(() => null);
if (cached) {
if (!includeBytes) return { url };
@@ -281,8 +300,7 @@ async function resolveFallbackAfterShot(
const key = await fallbackShotR2Key(target.headSha, path, viewportName);
const cached = await env.REVIEW_AUDIT.get(key).catch(() => null);
if (!cached) return { url: placeholder };
- const shotBase = env.PUBLIC_API_ORIGIN;
- return { url: shotBase ? `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}` : placeholder };
+ return { url: resolveShotUrl(env, key) || placeholder };
}
/** Upload a computed diff-overlay PNG to the same store `capturePage` uses, returning its shot URL — or
@@ -297,12 +315,11 @@ async function uploadDiffImage(
theme?: ShotTheme | undefined,
): Promise {
if (!diff?.diffImagePng) return undefined;
- const shotBase = env.PUBLIC_API_ORIGIN;
- if (!env.REVIEW_AUDIT || !shotBase) return undefined;
+ if (!env.REVIEW_AUDIT || (!env.PUBLIC_API_ORIGIN && !env.REVIEW_AUDIT_S3_PUBLIC_URL)) return undefined;
const fingerprint = await sha256Hex(`${target.headSha ?? target.prNumber}:diff:${viewportName}:${path}${theme ? `:${theme}` : ""}`);
const key = `${NAMESPACE}/shots/${fingerprint.slice(0, 40)}-diff.png`;
await env.REVIEW_AUDIT.put(key, diff.diffImagePng, { httpMetadata: { contentType: "image/png" } }).catch(() => undefined);
- return `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}`;
+ return resolveShotUrl(env, key);
}
// How long each frame shows when the assembled GIF plays back (#3612) — a quick "evidence clip" pace: the
@@ -328,13 +345,12 @@ async function captureScrollGif(
themeStorageKey?: string | undefined,
): Promise {
if (!page) return undefined;
- const shotBase = env.PUBLIC_API_ORIGIN;
- if (!env.REVIEW_AUDIT || !shotBase) return undefined;
+ if (!env.REVIEW_AUDIT || (!env.PUBLIC_API_ORIGIN && !env.REVIEW_AUDIT_S3_PUBLIC_URL)) return undefined;
const fingerprint = await sha256Hex(
`${target.headSha ?? target.prNumber}:scrollgif:${slot}:${viewportName}:${page}${theme ? `:${theme}` : ""}${theme && themeStorageKey ? `:${themeStorageKey}` : ""}`,
);
const key = `${NAMESPACE}/shots/${fingerprint.slice(0, 40)}.gif`;
- const url = `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}`;
+ const url = resolveShotUrl(env, key);
const cached = await env.REVIEW_AUDIT.get(key).catch(() => null);
if (cached) return url;
const { frames, authWalled } = await captureScrollFrames(env, page, viewport, theme ? { theme, ...(themeStorageKey ? { themeStorageKey } : {}) } : {}).catch(() => ({ frames: [] as Uint8Array[], authWalled: false }));
diff --git a/src/selfhost/blob-store.ts b/src/selfhost/blob-store.ts
index 8e890ebd98..d48df5ff44 100644
--- a/src/selfhost/blob-store.ts
+++ b/src/selfhost/blob-store.ts
@@ -5,7 +5,7 @@
// surface those two paths use; every other R2Bucket method is unused on self-host. Node-only (fs import never
// reaches the Worker bundle — wired in server.ts behind REVIEW_AUDIT_DIR). MODULAR + off by default: unset
// REVIEW_AUDIT_DIR ⇒ no REVIEW_AUDIT binding ⇒ captures degrade to on-demand exactly as before.
-import { mkdir, readFile, writeFile } from "node:fs/promises";
+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
@@ -35,6 +35,12 @@ export function createFsBlobStore(baseDir: string): R2Bucket {
await writeFile(target, Buffer.from(await new Response(value ?? "").arrayBuffer()));
return { key } as unknown as R2Object;
},
+ /** Remove a stored object. A missing file is not an error (matches R2's own delete-is-idempotent
+ * semantics) -- unlike before this method existed, callers no longer hit a synchronous
+ * "not a function" TypeError (see actions-fallback.ts's dispatch-marker cleanup). */
+ async delete(key: string): Promise {
+ await rm(pathFor(key), { force: true });
+ },
};
return store as unknown as R2Bucket;
}
diff --git a/src/selfhost/s3-blob-store.ts b/src/selfhost/s3-blob-store.ts
new file mode 100644
index 0000000000..fb4a0a0bbf
--- /dev/null
+++ b/src/selfhost/s3-blob-store.ts
@@ -0,0 +1,91 @@
+// Self-host blob store, S3-compatible-bucket-backed variant. A minimal R2Bucket-compatible store (same
+// get/put/delete surface as ./blob-store.ts's filesystem-backed one) that talks to an operator's OWN
+// S3-compatible bucket -- Cloudflare R2 (https://.r2.cloudflarestorage.com, region "auto"), or any
+// other S3-compatible provider -- via signed REST calls (AWS SigV4, aws4fetch).
+//
+// 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
+// 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.
+//
+// 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
+// rendering) exactly as before -- see server.ts's REVIEW_AUDIT wiring.
+import { AwsClient } from "aws4fetch";
+
+export type S3BlobStoreConfig = {
+ endpoint: string;
+ bucket: string;
+ accessKeyId: string;
+ secretAccessKey: string;
+ /** R2's S3-compatible API always uses "auto" -- see https://developers.cloudflare.com/r2/api/s3/api/#bucket-region.
+ * Configurable for other S3-compatible providers that expect a real AWS region string. Default "auto". */
+ region?: string | undefined;
+};
+
+// aws4fetch retries a failed request internally (default: 10 attempts, exponential backoff from
+// initRetryMs=50 -- 10 attempts can take 30+ seconds to finally give up). Every call site into this store is
+// already best-effort (wrapped in `.catch()` -- a cache miss/write failure just means "re-render instead"),
+// so a bounded, fast-failing retry budget matters more here than aws4fetch's own resilience-first default:
+// 3 attempts is enough to ride out a genuinely transient blip without risking a multi-second stall in the
+// review pipeline over a persistently misconfigured or down bucket.
+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 +
+ * 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 {
+ const client = new AwsClient({
+ accessKeyId: config.accessKeyId,
+ secretAccessKey: config.secretAccessKey,
+ retries: S3_CLIENT_RETRIES,
+ service: "s3",
+ region: config.region ?? "auto",
+ });
+ const base = config.endpoint.replace(/\/+$/, "");
+ const urlFor = (key: string): string => `${base}/${config.bucket}/${key.split("/").map(encodeURIComponent).join("/")}`;
+
+ const store = {
+ /** Stream a stored object's bytes, or null on a miss (404) or any request failure. */
+ async get(key: string): Promise {
+ try {
+ const response = await client.fetch(urlFor(key), { method: "GET" });
+ if (!response.ok) return null;
+ return { body: response.body } as unknown as R2ObjectBody;
+ } catch {
+ return null;
+ }
+ },
+ /** Persist `value` (the captured PNG/GIF) under `key`. Throws on a non-2xx response or request failure --
+ * every call site already wraps `.put(...)` in `.catch(() => undefined)` (best-effort caching), matching
+ * the filesystem store's own let-it-throw-and-let-the-caller-degrade contract. */
+ async put(
+ key: string,
+ value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null,
+ options?: { httpMetadata?: { contentType?: string } },
+ ): Promise {
+ const body = await new Response(value ?? "").arrayBuffer();
+ const headers: Record = {};
+ if (options?.httpMetadata?.contentType) headers["content-type"] = options.httpMetadata.contentType;
+ const response = await client.fetch(urlFor(key), { method: "PUT", headers, body });
+ if (!response.ok) throw new Error(`S3 put failed: ${response.status} ${await response.text().catch(() => "")}`);
+ return { key } as unknown as R2Object;
+ },
+ /** Delete a stored object. Best-effort semantics live with the caller (see actions-fallback.ts's dispatch
+ * marker cleanup) -- this itself just reports whether the DELETE request succeeded. */
+ async delete(key: string): Promise {
+ const response = await client.fetch(urlFor(key), { method: "DELETE" });
+ if (!response.ok && response.status !== 404) {
+ throw new Error(`S3 delete failed: ${response.status} ${await response.text().catch(() => "")}`);
+ }
+ },
+ };
+ return store as unknown as R2Bucket;
+}
diff --git a/src/server.ts b/src/server.ts
index cfe2a45249..ee4d3c7954 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -65,6 +65,7 @@ import type { DurableQueue } from "./selfhost/backend-contracts";
import { createSqliteQueue } from "./selfhost/sqlite-queue";
import { createSqliteVectorize } from "./selfhost/vectorize";
import { createFsBlobStore } from "./selfhost/blob-store";
+import { createS3BlobStore } from "./selfhost/s3-blob-store";
import {
makeLocalManifestReader,
makeLocalReviewContextReader,
@@ -269,6 +270,27 @@ function buildSqliteBackend(
};
}
+/** Resolve the REVIEW_AUDIT blob-store binding from env vars, or undefined for the on-demand (no persistence)
+ * default. An S3-compatible bucket (an operator's own Cloudflare R2 bucket, or any other S3-compatible
+ * provider) takes priority over the plain filesystem store when both are configured -- S3 is the one that can
+ * actually be made publicly reachable without exposing this instance itself (see s3-blob-store.ts's own
+ * header comment), so it's the strictly more capable option when an operator has set up both. */
+function resolveReviewAuditBinding(): R2Bucket | undefined {
+ const { REVIEW_AUDIT_S3_BUCKET, REVIEW_AUDIT_S3_ENDPOINT, REVIEW_AUDIT_S3_ACCESS_KEY_ID, REVIEW_AUDIT_S3_SECRET_ACCESS_KEY, REVIEW_AUDIT_S3_REGION } =
+ process.env;
+ if (REVIEW_AUDIT_S3_BUCKET && REVIEW_AUDIT_S3_ENDPOINT && REVIEW_AUDIT_S3_ACCESS_KEY_ID && REVIEW_AUDIT_S3_SECRET_ACCESS_KEY) {
+ return createS3BlobStore({
+ bucket: REVIEW_AUDIT_S3_BUCKET,
+ endpoint: REVIEW_AUDIT_S3_ENDPOINT,
+ accessKeyId: REVIEW_AUDIT_S3_ACCESS_KEY_ID,
+ secretAccessKey: REVIEW_AUDIT_S3_SECRET_ACCESS_KEY,
+ ...(REVIEW_AUDIT_S3_REGION ? { region: REVIEW_AUDIT_S3_REGION } : {}),
+ });
+ }
+ if (process.env.REVIEW_AUDIT_DIR) return createFsBlobStore(process.env.REVIEW_AUDIT_DIR);
+ return undefined;
+}
+
async function main(): Promise {
loadFileSecrets();
/* v8 ignore next -- importing this entrypoint starts the Node server; pure validation is covered in selfhost-preflight tests. */
@@ -584,12 +606,13 @@ async function main(): Promise {
// Visual review: when BROWSER_WS_ENDPOINT is set, expose a truthy BROWSER binding so shot.ts's
// `if (!env.BROWSER) return` guard is bypassed; the puppeteer stub then connects via WS.
...(process.env.BROWSER_WS_ENDPOINT ? { BROWSER: {} } : {}),
- // Visual screenshot persistence (#10): bind an fs-backed REVIEW_AUDIT store when REVIEW_AUDIT_DIR is set so
- // captured PNGs are cached + served from /gittensory/shot?key=… instead of re-rendering on demand. Unset ⇒
- // no binding ⇒ on-demand behavior, byte-identical to before.
- ...(process.env.REVIEW_AUDIT_DIR
- ? { REVIEW_AUDIT: createFsBlobStore(process.env.REVIEW_AUDIT_DIR) }
- : {}),
+ // Visual screenshot persistence (#10 / S3-bucket support): bind a REVIEW_AUDIT store (S3-compatible bucket,
+ // or plain filesystem — see resolveReviewAuditBinding) so captured PNGs are cached instead of re-rendering
+ // on demand. Unset (neither configured) ⇒ no binding ⇒ on-demand behavior, byte-identical to before.
+ ...(() => {
+ const binding = resolveReviewAuditBinding();
+ return binding ? { REVIEW_AUDIT: binding } : {};
+ })(),
} as unknown as Env;
// GitHub App auth: a successful JWT mint proves GITHUB_APP_PRIVATE_KEY is set and parses as a valid signing
diff --git a/test/unit/selfhost-blob-store.test.ts b/test/unit/selfhost-blob-store.test.ts
index 3fee800211..289b97cb61 100644
--- a/test/unit/selfhost-blob-store.test.ts
+++ b/test/unit/selfhost-blob-store.test.ts
@@ -39,4 +39,16 @@ describe("createFsBlobStore (#10 — self-host visual screenshot persistence)",
await expect(store.put("../escape.png", new Uint8Array([1]))).rejects.toThrow(/escapes base dir/);
expect(await store.get("../../etc/passwd")).toBeNull(); // the pathFor throw is caught inside get → safe miss
});
+
+ it("delete removes a stored object — a subsequent get is a miss", async () => {
+ const store = createFsBlobStore(dir);
+ await store.put("gittensory/shots/gone.png", new Uint8Array([1, 2, 3]));
+ expect(await store.get("gittensory/shots/gone.png")).not.toBeNull();
+ await store.delete("gittensory/shots/gone.png");
+ expect(await store.get("gittensory/shots/gone.png")).toBeNull();
+ });
+
+ it("delete on a key that was never written does not throw (idempotent, matches R2)", async () => {
+ await expect(createFsBlobStore(dir).delete("gittensory/shots/never-existed.png")).resolves.toBeUndefined();
+ });
});
diff --git a/test/unit/selfhost-s3-blob-store.test.ts b/test/unit/selfhost-s3-blob-store.test.ts
new file mode 100644
index 0000000000..93c85c14c1
--- /dev/null
+++ b/test/unit/selfhost-s3-blob-store.test.ts
@@ -0,0 +1,122 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createS3BlobStore } from "../../src/selfhost/s3-blob-store";
+
+const CONFIG = {
+ endpoint: "https://abc123.r2.cloudflarestorage.com",
+ bucket: "gittensory-shots",
+ accessKeyId: "test-access-key-id",
+ secretAccessKey: "test-secret-access-key",
+};
+
+describe("createS3BlobStore (self-host visual screenshot persistence, S3-compatible bucket)", () => {
+ let fetchMock: ReturnType;
+
+ beforeEach(() => {
+ fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("get: a successful response streams the object body", async () => {
+ fetchMock.mockResolvedValueOnce(new Response(new Uint8Array([1, 2, 3]), { status: 200 }));
+ const store = createS3BlobStore(CONFIG);
+ const object = await store.get("gittensory/shots/abc.png");
+ expect(object).not.toBeNull();
+ expect(Array.from(new Uint8Array(await new Response(object!.body).arrayBuffer()))).toEqual([1, 2, 3]);
+
+ const [request] = fetchMock.mock.calls[0] as [Request];
+ expect(request.url).toBe("https://abc123.r2.cloudflarestorage.com/gittensory-shots/gittensory/shots/abc.png");
+ expect(request.method).toBe("GET");
+ });
+
+ it("get: a 404 response is a miss (returns null), not an error", async () => {
+ fetchMock.mockResolvedValueOnce(new Response("not found", { status: 404 }));
+ expect(await createS3BlobStore(CONFIG).get("gittensory/shots/missing.png")).toBeNull();
+ });
+
+ it("get: a network failure degrades to null (never throws)", async () => {
+ fetchMock.mockRejectedValueOnce(new Error("network down"));
+ await expect(createS3BlobStore(CONFIG).get("gittensory/shots/x.png")).resolves.toBeNull();
+ });
+
+ it("put: a successful response returns the key and carries the content-type", async () => {
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
+ const store = createS3BlobStore(CONFIG);
+ const result = await store.put("gittensory/shots/new.png", new Uint8Array([9, 9]), { httpMetadata: { contentType: "image/png" } });
+ expect(result.key).toBe("gittensory/shots/new.png");
+
+ const [request] = fetchMock.mock.calls[0] as [Request];
+ expect(request.url).toBe("https://abc123.r2.cloudflarestorage.com/gittensory-shots/gittensory/shots/new.png");
+ expect(request.method).toBe("PUT");
+ expect(request.headers.get("content-type")).toBe("image/png");
+ });
+
+ it("put: accepts a null value (stores an empty body), satisfying the R2 put body type", async () => {
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
+ await createS3BlobStore(CONFIG).put("gittensory/shots/empty.png", null);
+ const [request] = fetchMock.mock.calls[0] as [Request];
+ expect((await request.arrayBuffer()).byteLength).toBe(0);
+ });
+
+ it("put: omitted httpMetadata sends no content-type header (both branches of the optional covered)", async () => {
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
+ await createS3BlobStore(CONFIG).put("gittensory/shots/no-type.png", new Uint8Array([1]));
+ const [request] = fetchMock.mock.calls[0] as [Request];
+ expect(request.headers.get("content-type")).toBeNull();
+ });
+
+ it("put: a non-2xx response throws", async () => {
+ fetchMock.mockResolvedValueOnce(new Response("access denied", { status: 403 }));
+ await expect(createS3BlobStore(CONFIG).put("gittensory/shots/x.png", new Uint8Array([1]))).rejects.toThrow(/S3 put failed: 403/);
+ });
+
+ it("delete: a successful response resolves", async () => {
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
+ await expect(createS3BlobStore(CONFIG).delete("gittensory/shots/x.png")).resolves.toBeUndefined();
+ const [request] = fetchMock.mock.calls[0] as [Request];
+ expect(request.method).toBe("DELETE");
+ });
+
+ it("delete: a 404 is treated as already-deleted (idempotent, matches R2/fs-store semantics), not an error", async () => {
+ fetchMock.mockResolvedValueOnce(new Response("not found", { status: 404 }));
+ await expect(createS3BlobStore(CONFIG).delete("gittensory/shots/already-gone.png")).resolves.toBeUndefined();
+ });
+
+ it("delete: any other failure throws", async () => {
+ // aws4fetch retries a 5xx internally (default up to 10x) before giving up -- mockResolvedValue (not
+ // -Once) so every retry attempt sees the same persistent failure, matching a genuinely-down upstream.
+ fetchMock.mockResolvedValue(new Response("server error", { status: 500 }));
+ await expect(createS3BlobStore(CONFIG).delete("gittensory/shots/x.png")).rejects.toThrow(/S3 delete failed: 500/);
+ });
+
+ it("URL-encodes each key segment while preserving the / path structure", async () => {
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
+ await createS3BlobStore(CONFIG).put("gittensory/shots/a b#c.png", new Uint8Array([1]));
+ const [request] = fetchMock.mock.calls[0] as [Request];
+ expect(request.url).toBe("https://abc123.r2.cloudflarestorage.com/gittensory-shots/gittensory/shots/a%20b%23c.png");
+ });
+
+ it("strips a trailing slash from the configured endpoint", async () => {
+ fetchMock.mockResolvedValueOnce(new Response(new Uint8Array([1]), { status: 200 }));
+ await createS3BlobStore({ ...CONFIG, endpoint: `${CONFIG.endpoint}/` }).get("gittensory/shots/x.png");
+ const [request] = fetchMock.mock.calls[0] as [Request];
+ expect(request.url).toBe("https://abc123.r2.cloudflarestorage.com/gittensory-shots/gittensory/shots/x.png");
+ });
+
+ it("defaults the SigV4 region to \"auto\" (R2's convention) when not configured", async () => {
+ fetchMock.mockResolvedValueOnce(new Response(new Uint8Array([1]), { status: 200 }));
+ await createS3BlobStore(CONFIG).get("gittensory/shots/x.png");
+ const [request] = fetchMock.mock.calls[0] as [Request];
+ expect(request.headers.get("authorization")).toContain("/auto/s3/aws4_request");
+ });
+
+ it("uses a configured region instead of the \"auto\" default when provided", async () => {
+ fetchMock.mockResolvedValueOnce(new Response(new Uint8Array([1]), { status: 200 }));
+ await createS3BlobStore({ ...CONFIG, region: "us-east-1" }).get("gittensory/shots/x.png");
+ const [request] = fetchMock.mock.calls[0] as [Request];
+ expect(request.headers.get("authorization")).toContain("/us-east-1/s3/aws4_request");
+ });
+});
diff --git a/test/unit/visual-capture.test.ts b/test/unit/visual-capture.test.ts
index 2d72479208..080e06982c 100644
--- a/test/unit/visual-capture.test.ts
+++ b/test/unit/visual-capture.test.ts
@@ -638,6 +638,133 @@ 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 () => {
+ const env = createTestEnv({
+ PUBLIC_API_ORIGIN: "https://worker.example",
+ PUBLIC_SITE_ORIGIN: "https://prod.example.com",
+ REVIEW_AUDIT: memoryReviewAudit(),
+ REVIEW_AUDIT_S3_PUBLIC_URL: "https://pub-abc123.r2.dev",
+ });
+ const afterKey = await shotKey(30, "after", "desktop", "https://preview.example.com/app");
+ await env.REVIEW_AUDIT!.put(afterKey, new Uint8Array([1, 2, 3]));
+ const result = await buildCapture(
+ env,
+ "installation-token",
+ { repoFullName: "owner/repo", prNumber: 30, previewUrl: "https://preview.example.com" },
+ ["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=");
+ });
+
+ it("strips a trailing slash from REVIEW_AUDIT_S3_PUBLIC_URL before joining the key", async () => {
+ const env = createTestEnv({
+ PUBLIC_API_ORIGIN: "https://worker.example",
+ PUBLIC_SITE_ORIGIN: "https://prod.example.com",
+ REVIEW_AUDIT: memoryReviewAudit(),
+ REVIEW_AUDIT_S3_PUBLIC_URL: "https://pub-abc123.r2.dev/",
+ });
+ const afterKey = await shotKey(31, "after", "desktop", "https://preview.example.com/app");
+ await env.REVIEW_AUDIT!.put(afterKey, new Uint8Array([1, 2, 3]));
+ const result = await buildCapture(
+ env,
+ "installation-token",
+ { repoFullName: "owner/repo", prNumber: 31, previewUrl: "https://preview.example.com" },
+ ["apps/gittensory-ui/src/routes/app.index.tsx"],
+ );
+ expect(result.routes[0]?.afterUrl).toBe(`https://pub-abc123.r2.dev/${afterKey}`); // no double slash
+ });
+
+ it("wins over PUBLIC_API_ORIGIN when both are configured", async () => {
+ const env = createTestEnv({
+ PUBLIC_API_ORIGIN: "https://worker.example",
+ PUBLIC_SITE_ORIGIN: "https://prod.example.com",
+ REVIEW_AUDIT: memoryReviewAudit(),
+ REVIEW_AUDIT_S3_PUBLIC_URL: "https://pub-abc123.r2.dev",
+ });
+ const beforeKey = await shotKey(32, "before", "desktop", "https://prod.example.com/app");
+ await env.REVIEW_AUDIT!.put(beforeKey, new Uint8Array([1, 2, 3]));
+ const result = await buildCapture(
+ env,
+ "installation-token",
+ { repoFullName: "owner/repo", prNumber: 32, previewUrl: "https://preview.example.com" },
+ ["apps/gittensory-ui/src/routes/app.index.tsx"],
+ );
+ expect(result.routes[0]?.beforeUrl).toContain("pub-abc123.r2.dev");
+ expect(result.routes[0]?.beforeUrl).not.toContain("worker.example");
+ });
+
+ it("the on-demand (?url=) fallback still goes through this instance's own PUBLIC_API_ORIGIN even with a public bucket configured — rendering only ever happens here", async () => {
+ const env = createTestEnv({
+ PUBLIC_API_ORIGIN: "https://worker.example",
+ PUBLIC_SITE_ORIGIN: "https://prod.example.com",
+ // REVIEW_AUDIT deliberately absent: forces the onDemand fallback path in capturePage.
+ REVIEW_AUDIT_S3_PUBLIC_URL: "https://pub-abc123.r2.dev",
+ });
+ const result = await buildCapture(
+ env,
+ "installation-token",
+ { 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=");
+ });
+
+ it("uploadDiffImage links directly at the bucket even when PUBLIC_API_ORIGIN is unset (S3 public URL alone is enough)", async () => {
+ const availableSpy = vi.spyOn(pixelDiffModule, "isVisualDiffAvailable").mockReturnValue(true);
+ const compareSpy = vi.spyOn(pixelDiffModule, "compareCapturedScreenshots").mockResolvedValue({
+ status: "changed",
+ changedPixelPercent: 15,
+ diffImagePng: new Uint8Array([3, 3, 3]),
+ });
+ try {
+ const env = createTestEnv({
+ PUBLIC_API_ORIGIN: "",
+ PUBLIC_SITE_ORIGIN: "https://prod.example.com",
+ REVIEW_AUDIT: memoryReviewAudit(),
+ REVIEW_AUDIT_S3_PUBLIC_URL: "https://pub-abc123.r2.dev",
+ });
+ const result = await buildCapture(
+ env,
+ "installation-token",
+ { repoFullName: "owner/repo", prNumber: 24, previewUrl: "https://preview.example.com" },
+ ["apps/gittensory-ui/src/routes/app.index.tsx"],
+ );
+ expect(result.routes[0]?.diffUrl).toMatch(/^https:\/\/pub-abc123\.r2\.dev\//);
+ } finally {
+ availableSpy.mockRestore();
+ compareSpy.mockRestore();
+ }
+ });
+
+ it("uploadDiffImage still skips gracefully when NEITHER PUBLIC_API_ORIGIN nor the S3 public URL is configured", async () => {
+ const availableSpy = vi.spyOn(pixelDiffModule, "isVisualDiffAvailable").mockReturnValue(true);
+ const compareSpy = vi.spyOn(pixelDiffModule, "compareCapturedScreenshots").mockResolvedValue({
+ status: "changed",
+ changedPixelPercent: 15,
+ diffImagePng: new Uint8Array([3, 3, 3]),
+ });
+ try {
+ const env = createTestEnv({
+ PUBLIC_API_ORIGIN: "",
+ PUBLIC_SITE_ORIGIN: "https://prod.example.com",
+ REVIEW_AUDIT: memoryReviewAudit(),
+ });
+ const result = await buildCapture(
+ env,
+ "installation-token",
+ { repoFullName: "owner/repo", prNumber: 25, previewUrl: "https://preview.example.com" },
+ ["apps/gittensory-ui/src/routes/app.index.tsx"],
+ );
+ expect(result.routes[0]?.diffUrl).toBeUndefined();
+ } finally {
+ availableSpy.mockRestore();
+ compareSpy.mockRestore();
+ }
+ });
+});
+
describe("buildCapture theme matrix (#3678)", () => {
it("produces exactly one untagged route per path when no themes are configured — byte-identical to pre-#3678", async () => {
const result = await buildCapture(
@@ -934,6 +1061,37 @@ describe("buildCapture scroll-GIF wiring (#3612)", () => {
}
});
+ it("links scroll GIFs directly at the bucket when REVIEW_AUDIT_S3_PUBLIC_URL is configured, even with PUBLIC_API_ORIGIN unset", async () => {
+ const gifAvailableSpy = vi.spyOn(scrollGifModule, "isScrollGifAvailable").mockReturnValue(true);
+ const captureScrollSpy = vi.spyOn(shotModule, "captureScrollFrames").mockResolvedValue({
+ frames: [new Uint8Array([1, 2, 3])],
+ authWalled: false,
+ });
+ const encodeSpy = vi.spyOn(scrollGifModule, "encodeScrollGif").mockResolvedValue(new Uint8Array([7, 8, 9]));
+ try {
+ const env = createTestEnv({
+ PUBLIC_API_ORIGIN: "",
+ PUBLIC_SITE_ORIGIN: "https://prod.example.com",
+ REVIEW_AUDIT: memoryReviewAudit(),
+ REVIEW_AUDIT_S3_PUBLIC_URL: "https://pub-abc123.r2.dev",
+ });
+ const result = await buildCapture(
+ env,
+ "installation-token",
+ { repoFullName: "owner/repo", prNumber: 33, previewUrl: "https://preview.example.com" },
+ ["apps/gittensory-ui/src/routes/app.index.tsx"],
+ undefined,
+ { gif: true },
+ );
+ expect(result.routes[0]?.beforeGifUrl).toMatch(/^https:\/\/pub-abc123\.r2\.dev\//);
+ expect(result.routes[0]?.afterGifUrl).toMatch(/^https:\/\/pub-abc123\.r2\.dev\//);
+ } finally {
+ gifAvailableSpy.mockRestore();
+ captureScrollSpy.mockRestore();
+ encodeSpy.mockRestore();
+ }
+ });
+
it("does not attempt an after-GIF when there is no preview URL yet (afterPage is empty)", async () => {
const gifAvailableSpy = vi.spyOn(scrollGifModule, "isScrollGifAvailable").mockReturnValue(true);
const captureScrollSpy = vi.spyOn(shotModule, "captureScrollFrames").mockResolvedValue({
@@ -1500,6 +1658,29 @@ describe("review.visual.actions_fallback (#4112 GitHub-Actions build-and-serve f
expect(result.routes[0]?.afterUrl).toBe(`https://worker.example/gittensory/shot?key=${encodeURIComponent(key)}`);
});
+ it("links an already-stored fallback shot directly at the bucket when REVIEW_AUDIT_S3_PUBLIC_URL is configured", async () => {
+ vi.stubGlobal("fetch", stubNoPreviewFound());
+ const env = createTestEnv({
+ PUBLIC_API_ORIGIN: "https://worker.example",
+ PUBLIC_SITE_ORIGIN: "https://prod.example.com",
+ REVIEW_AUDIT: memoryReviewAudit(),
+ REVIEW_AUDIT_S3_PUBLIC_URL: "https://pub-abc123.r2.dev",
+ });
+ const key = await fallbackShotR2Key("cafebabe", "/app", "desktop");
+ await env.REVIEW_AUDIT!.put(key, new Uint8Array([1, 2, 3]));
+
+ const result = await buildCapture(
+ env,
+ "installation-token",
+ { repoFullName: "owner/repo", prNumber: 29, headSha: "cafebabe", previewFromChecks: true, defaultBranchRef: "main" },
+ ["apps/gittensory-ui/src/routes/app.index.tsx"],
+ undefined,
+ { actionsFallback: true },
+ );
+
+ expect(result.routes[0]?.afterUrl).toBe(`https://pub-abc123.r2.dev/${key}`);
+ });
+
it("falls back to the loading placeholder when actions_fallback is enabled but no shot has landed in R2 yet", async () => {
vi.stubGlobal("fetch", stubNoPreviewFound());
const env = createTestEnv({
From 5bf952efb8060072e53af053e9bda801ceb1f5cb Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Wed, 8 Jul 2026 04:34:02 -0700
Subject: [PATCH 2/2] test(selfhost): cover the no-shotBase-at-all branch in
resolveFallbackAfterShot
resolveShotUrl(env, key) || placeholder was only ever exercised with a
truthy resolveShotUrl result (PUBLIC_API_ORIGIN or REVIEW_AUDIT_S3_PUBLIC_URL
set), leaving the falsy/placeholder side of the fallback branch untested.
---
test/unit/visual-capture.test.ts | 22 ++++++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/test/unit/visual-capture.test.ts b/test/unit/visual-capture.test.ts
index 080e06982c..76326e2646 100644
--- a/test/unit/visual-capture.test.ts
+++ b/test/unit/visual-capture.test.ts
@@ -1681,6 +1681,28 @@ describe("review.visual.actions_fallback (#4112 GitHub-Actions build-and-serve f
expect(result.routes[0]?.afterUrl).toBe(`https://pub-abc123.r2.dev/${key}`);
});
+ it("degrades to no after URL at all when a fallback shot is cached but neither PUBLIC_API_ORIGIN nor REVIEW_AUDIT_S3_PUBLIC_URL is configured (nothing servable can be constructed, not even a placeholder)", async () => {
+ vi.stubGlobal("fetch", stubNoPreviewFound());
+ const env = createTestEnv({
+ PUBLIC_API_ORIGIN: "",
+ PUBLIC_SITE_ORIGIN: "https://prod.example.com",
+ REVIEW_AUDIT: memoryReviewAudit(),
+ });
+ const key = await fallbackShotR2Key("cafebabe", "/app", "desktop");
+ await env.REVIEW_AUDIT!.put(key, new Uint8Array([1, 2, 3]));
+
+ const result = await buildCapture(
+ env,
+ "installation-token",
+ { repoFullName: "owner/repo", prNumber: 31, headSha: "cafebabe", previewFromChecks: true, defaultBranchRef: "main" },
+ ["apps/gittensory-ui/src/routes/app.index.tsx"],
+ undefined,
+ { actionsFallback: true },
+ );
+
+ expect(result.routes[0]?.afterUrl).toBeUndefined();
+ });
+
it("falls back to the loading placeholder when actions_fallback is enabled but no shot has landed in R2 yet", async () => {
vi.stubGlobal("fetch", stubNoPreviewFound());
const env = createTestEnv({