diff --git a/.env.example b/.env.example
index 31c8b0883e..3ce1260f97 100644
--- a/.env.example
+++ b/.env.example
@@ -167,7 +167,21 @@ GITTENSORY_REVIEW_DRAFT=false
# # EXACT public URL operators browse to (scheme + host [+ port]).
# # Deriving it from the request Host header would let an attacker
# # redirect the App-creation callback, so it must be set explicitly.
-# # Not needed once the App credentials are configured.
+# # Stays load-bearing after setup too: this same origin is embedded
+# # as the
for every visual-capture screenshot posted to a
+# # PR comment (see PUBLIC_SITE_ORIGIN below), so it must be
+# # reachable from GitHub's own servers, not just this host or your
+# # tailnet. See PUBLIC_ORIGIN_ACKNOWLEDGED below.
+# PUBLIC_SITE_ORIGIN=https://your-fork.example.com # "before" screenshots in the visual-capture PR comment
+# # table render THIS origin's production pages (vs. PUBLIC_API_ORIGIN,
+# # which serves the screenshot images themselves). Unset ⇒ every
+# # "Before (production)" cell is blank, not an error — visual capture
+# # degrades to after-only, never fails the review.
+# PUBLIC_ORIGIN_ACKNOWLEDGED=false # silences the boot-time warning when PUBLIC_API_ORIGIN/
+# # 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).
# 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
diff --git a/.env.selfhost.example b/.env.selfhost.example
index 09e3120c42..95220217a4 100644
--- a/.env.selfhost.example
+++ b/.env.selfhost.example
@@ -27,8 +27,12 @@ GITTENSOR_REGISTRY_URL=https://example.invalid/registry.json
# GITTENSORY_MCP_TOKEN= # shared MCP bearer token
# INTERNAL_JOB_TOKEN= # gates internal-only routes
-# REQUIRED once, for the first-run /setup wizard that creates the GitHub App manifest. Not needed
-# after the App credentials above are filled in.
+# REQUIRED for the first-run /setup wizard that creates the GitHub App manifest, AND required ongoing:
+# this exact origin is embedded as the
for every visual-capture screenshot posted to a PR
+# comment, so it MUST be reachable from GitHub's own servers, not just from this machine or your
+# tailnet — a private/internal hostname here (e.g. a Tailscale MagicDNS `*.ts.net` address with no
+# Funnel) makes every screenshot render as a broken image with no other warning besides this instance's
+# own boot log (see PUBLIC_ORIGIN_ACKNOWLEDGED below).
PUBLIC_API_ORIGIN=https://reviews.example.com
# SELFHOST_SETUP_TOKEN= # generate a real random value the same way as above before setting this
diff --git a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts
index 179ae5dce9..482340970b 100644
--- a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts
+++ b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts
@@ -325,6 +325,14 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
name: "PUBLIC_API_ORIGIN",
firstReference: "src/selfhost/preflight.ts",
},
+ {
+ name: "PUBLIC_ORIGIN_ACKNOWLEDGED",
+ firstReference: "src/server.ts",
+ },
+ {
+ name: "PUBLIC_SITE_ORIGIN",
+ firstReference: "src/server.ts",
+ },
{
name: "QDRANT_API_KEY",
firstReference: "src/selfhost/qdrant-vectorize.ts",
@@ -486,6 +494,8 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
"| `PGVECTOR_ENABLED` | `src/server.ts` |",
"| `PORT` | `src/server.ts` |",
"| `PUBLIC_API_ORIGIN` | `src/selfhost/preflight.ts` |",
+ "| `PUBLIC_ORIGIN_ACKNOWLEDGED` | `src/server.ts` |",
+ "| `PUBLIC_SITE_ORIGIN` | `src/server.ts` |",
"| `QDRANT_API_KEY` | `src/selfhost/qdrant-vectorize.ts` |",
"| `QDRANT_DIM` | `src/selfhost/qdrant-vectorize.ts` |",
"| `QDRANT_URL` | `src/server.ts` |",
diff --git a/src/selfhost/health.ts b/src/selfhost/health.ts
index b02b6b7b8e..b9daaf0e04 100644
--- a/src/selfhost/health.ts
+++ b/src/selfhost/health.ts
@@ -155,3 +155,78 @@ export function sqliteBackupAdvisory(opts: { usingSqlite: boolean; backupAcknowl
export function backupAcknowledgedGaugeValue(opts: { usingSqlite: boolean; backupAcknowledged: boolean }): 0 | 1 {
return sqliteBackupAdvisory(opts) === null ? 1 : 0;
}
+
+// Hostnames that are NEVER reachable from the public internet, regardless of deployment — loopback, RFC1918
+// private ranges, and the mDNS/internal-DNS conventions no public resolver honors. Deliberately excludes
+// Tailscale's own *.ts.net MagicDNS suffix from this "never" list: a node with Funnel explicitly enabled DOES
+// serve that exact hostname over public HTTPS, so *.ts.net gets its own softer check below rather than being
+// treated as definitely non-public.
+function isDefinitelyPrivateHostname(hostname: string): boolean {
+ const host = hostname.toLowerCase();
+ // URL.hostname keeps the brackets on an IPv6 literal ("[::1]", not "::1") — compare against the bracketed
+ // form, not the bare address.
+ if (host === "localhost" || host === "0.0.0.0" || host === "[::1]") return true;
+ if (host.endsWith(".local") || host.endsWith(".internal")) return true;
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/.exec(host);
+ if (ipv4) {
+ const first = Number(ipv4[1]);
+ const second = Number(ipv4[2]);
+ if (first === 127 || first === 10) return true;
+ if (first === 192 && second === 168) return true;
+ if (first === 172 && second >= 16 && second <= 31) return true;
+ }
+ return false;
+}
+
+/** True when `origin` parses as a URL whose hostname is a well-known non-public pattern (loopback, RFC1918,
+ * mDNS/`.internal`), OR a bare Tailscale MagicDNS hostname (`*.ts.net`) — publicly reachable ONLY when the
+ * operator has explicitly enabled Tailscale Funnel for that node, which this check has no way to observe.
+ * An unparseable value is this function's job to reject as "not a URL at all", not "looks non-public" — but
+ * that is a distinct, well-formedness problem this advisory doesn't cover (PUBLIC_API_ORIGIN's own
+ * first-run-setup preflight check already validates that shape; nothing else currently reads
+ * PUBLIC_SITE_ORIGIN at boot), so it's silently false here rather than double-reported. */
+function looksNonPublic(origin: string): boolean {
+ try {
+ const { hostname } = new URL(origin);
+ return isDefinitelyPrivateHostname(hostname) || hostname.toLowerCase().endsWith(".ts.net");
+ } catch {
+ return false;
+ }
+}
+
+/** Boot-time advisory (JSONbored/gittensory PR #4180's live bug): `PUBLIC_API_ORIGIN`/`PUBLIC_SITE_ORIGIN` get
+ * embedded VERBATIM as `
` in the public "Visual preview" PR comment table (see
+ * `src/review/visual/capture.ts`) — a value GitHub's own servers, not this instance, must be able to fetch.
+ * `PUBLIC_API_ORIGIN`'s existing preflight check (see `isBareHttpsOrigin` above) only confirms it's a
+ * WELL-FORMED bare https origin — a private tailnet hostname like `https://node.example.ts.net` passes that
+ * check fine while still being completely unfetchable by GitHub, so every screenshot silently renders as a
+ * broken image with no operator-visible signal until a human notices. This never hard-fails boot (unlike
+ * `assertSelfHostPreflight`'s checks): a false positive here (e.g. a legitimately Funnel-exposed `*.ts.net`
+ * node) would only be a degraded review feature, not a security or data-loss risk, so — mirroring
+ * {@link sqliteBackupAdvisory}'s own acknowledgment escape hatch — it warns rather than blocks, and the
+ * operator can silence it with `PUBLIC_ORIGIN_ACKNOWLEDGED=true` once they've confirmed the origin really is
+ * public (Funnel enabled, a reverse proxy in front of it, etc.). Neither var set is left alone: visual
+ * capture degrades to dash cells in that case (see capture.ts), not a broken image, so there's nothing to warn
+ * about until an operator sets one of these to something that looks wrong. */
+export function publicOriginReachabilityAdvisory(opts: {
+ publicApiOrigin: string | undefined;
+ publicSiteOrigin: string | undefined;
+ acknowledged: boolean;
+}): string | null {
+ if (opts.acknowledged) return null;
+ const suspect = [opts.publicApiOrigin, opts.publicSiteOrigin]
+ .filter((value): value is string => Boolean(value?.trim()))
+ .find((value) => looksNonPublic(value));
+ if (!suspect) return null;
+ return `PUBLIC_API_ORIGIN/PUBLIC_SITE_ORIGIN includes "${suspect}", which looks like a private/internal hostname — GitHub's servers cannot fetch it, so visual-capture screenshots embedded in PR comments will render as broken images. Set it to a real publicly-reachable origin (or, if this host has Tailscale Funnel enabled and IS genuinely public, set PUBLIC_ORIGIN_ACKNOWLEDGED=true to silence this warning).`;
+}
+
+/** Prometheus gauge value mirroring {@link publicOriginReachabilityAdvisory}: 1 when no suspect origin is
+ * configured (or the operator acknowledged it), 0 when the advisory would fire. */
+export function publicOriginAcknowledgedGaugeValue(opts: {
+ publicApiOrigin: string | undefined;
+ publicSiteOrigin: string | undefined;
+ acknowledged: boolean;
+}): 0 | 1 {
+ return publicOriginReachabilityAdvisory(opts) === null ? 1 : 0;
+}
diff --git a/src/server.ts b/src/server.ts
index 44198a8f90..efedbc5e03 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -47,6 +47,8 @@ import {
buildHealthBody,
codexAuthReadinessProbe,
githubAppReadinessProbe,
+ publicOriginAcknowledgedGaugeValue,
+ publicOriginReachabilityAdvisory,
readiness,
sqliteBackupAdvisory,
type ReadinessProbe,
@@ -392,6 +394,24 @@ async function main(): Promise {
}),
);
+ // Public-origin advisory (JSONbored/gittensory#4180): warn LOUDLY at boot if PUBLIC_API_ORIGIN/
+ // PUBLIC_SITE_ORIGIN look like a private/internal hostname, so an operator doesn't run for weeks with every
+ // visual-capture screenshot silently rendering as a broken image in public PR comments.
+ const publicOriginOpts = {
+ publicApiOrigin: process.env.PUBLIC_API_ORIGIN,
+ publicSiteOrigin: process.env.PUBLIC_SITE_ORIGIN,
+ acknowledged: process.env.PUBLIC_ORIGIN_ACKNOWLEDGED === "true",
+ };
+ const publicOriginAdvisory = publicOriginReachabilityAdvisory(publicOriginOpts);
+ if (publicOriginAdvisory)
+ console.warn(
+ JSON.stringify({
+ level: "warn",
+ event: "selfhost_public_origin_advisory",
+ message: publicOriginAdvisory,
+ }),
+ );
+
const applied = await runSelfHostMigrations(
backend.db,
process.env.MIGRATIONS_DIR ?? "migrations",
@@ -701,6 +721,7 @@ async function main(): Promise {
Math.floor((Date.now() - startedAt) / 1000),
);
gauge("gittensory_backup_acknowledged", () => backupAcknowledgedGaugeValue(sqliteBackupOpts));
+ gauge("gittensory_public_origin_acknowledged", () => publicOriginAcknowledgedGaugeValue(publicOriginOpts));
// Pre-initialize job counters to 0 so they appear in the first Prometheus scrape (lazy counters
// created on first use would otherwise cause "No data" in Grafana until the first job event).
for (const c of [
diff --git a/test/unit/selfhost-health.test.ts b/test/unit/selfhost-health.test.ts
index 65a386a483..9f8a394feb 100644
--- a/test/unit/selfhost-health.test.ts
+++ b/test/unit/selfhost-health.test.ts
@@ -9,6 +9,8 @@ import {
buildHealthBody,
codexAuthReadinessProbe,
githubAppReadinessProbe,
+ publicOriginAcknowledgedGaugeValue,
+ publicOriginReachabilityAdvisory,
readiness,
sqliteBackupAdvisory,
} from "../../src/selfhost/health";
@@ -219,6 +221,91 @@ describe("backupAcknowledgedGaugeValue (#2089)", () => {
});
});
+describe("publicOriginReachabilityAdvisory (#4180)", () => {
+ it("is silent when acknowledged, regardless of how bad the origin looks", () => {
+ expect(
+ publicOriginReachabilityAdvisory({
+ publicApiOrigin: "https://node.raccoon-bushi.ts.net",
+ publicSiteOrigin: undefined,
+ acknowledged: true,
+ }),
+ ).toBeNull();
+ });
+
+ it("is silent when neither origin is set, or both are ordinary public https origins", () => {
+ expect(publicOriginReachabilityAdvisory({ publicApiOrigin: undefined, publicSiteOrigin: undefined, acknowledged: false })).toBeNull();
+ expect(publicOriginReachabilityAdvisory({ publicApiOrigin: " ", publicSiteOrigin: undefined, acknowledged: false })).toBeNull();
+ expect(
+ publicOriginReachabilityAdvisory({
+ publicApiOrigin: "https://reviews.example.com",
+ publicSiteOrigin: "https://example.com",
+ acknowledged: false,
+ }),
+ ).toBeNull();
+ });
+
+ it("is silent on an unparseable value — a well-formedness problem, not this advisory's job", () => {
+ expect(
+ publicOriginReachabilityAdvisory({ publicApiOrigin: "not a url", publicSiteOrigin: undefined, acknowledged: false }),
+ ).toBeNull();
+ });
+
+ it("regression: warns on the exact PR #4180 shape — a bare Tailscale MagicDNS origin", () => {
+ const message = publicOriginReachabilityAdvisory({
+ publicApiOrigin: "https://edge-us-01.raccoon-bushi.ts.net",
+ publicSiteOrigin: undefined,
+ acknowledged: false,
+ });
+ expect(message).toMatch(/edge-us-01\.raccoon-bushi\.ts\.net/);
+ expect(message).toMatch(/PUBLIC_ORIGIN_ACKNOWLEDGED/);
+ });
+
+ it("checks PUBLIC_SITE_ORIGIN too, not just PUBLIC_API_ORIGIN", () => {
+ expect(
+ publicOriginReachabilityAdvisory({
+ publicApiOrigin: "https://reviews.example.com",
+ publicSiteOrigin: "http://localhost:3000",
+ acknowledged: false,
+ }),
+ ).toMatch(/localhost/);
+ });
+
+ it("flags loopback, RFC1918 ranges, mDNS, and .internal — but not a normal public IP or domain", () => {
+ const bad = [
+ "http://localhost",
+ "http://0.0.0.0",
+ "http://[::1]",
+ "https://node.local",
+ "https://svc.internal",
+ "http://127.0.0.1",
+ "http://10.0.0.5",
+ "http://192.168.1.1",
+ "http://172.20.0.5",
+ ];
+ for (const publicApiOrigin of bad) {
+ expect(publicOriginReachabilityAdvisory({ publicApiOrigin, publicSiteOrigin: undefined, acknowledged: false })).not.toBeNull();
+ }
+ const fine = ["https://8.8.8.8", "http://172.15.0.5", "http://172.32.0.5", "https://reviews.example.com"];
+ for (const publicApiOrigin of fine) {
+ expect(publicOriginReachabilityAdvisory({ publicApiOrigin, publicSiteOrigin: undefined, acknowledged: false })).toBeNull();
+ }
+ });
+});
+
+describe("publicOriginAcknowledgedGaugeValue (#4180)", () => {
+ it("mirrors the advisory: 0 only when a suspect origin is unacknowledged", () => {
+ expect(
+ publicOriginAcknowledgedGaugeValue({ publicApiOrigin: "https://node.ts.net", publicSiteOrigin: undefined, acknowledged: false }),
+ ).toBe(0);
+ expect(
+ publicOriginAcknowledgedGaugeValue({ publicApiOrigin: "https://node.ts.net", publicSiteOrigin: undefined, acknowledged: true }),
+ ).toBe(1);
+ expect(
+ publicOriginAcknowledgedGaugeValue({ publicApiOrigin: "https://reviews.example.com", publicSiteOrigin: undefined, acknowledged: false }),
+ ).toBe(1);
+ });
+});
+
describe("readiness (#982)", () => {
afterEach(() => {
vi.restoreAllMocks();