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..76326e2646 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,51 @@ 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("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({