From bc2aed8e857af6aab6432468ac9398568c2e3886 Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Thu, 2 Jul 2026 10:06:51 -0700
Subject: [PATCH 1/3] docs(selfhost): close first-run onboarding gaps for admin
access and GitHub App setup
Closes #2542.
Three first-run operator experience gaps, surfaced during a portability
review:
- ADMIN_GITHUB_LOGINS (the env var that grants control-panel access) was
documented nowhere. It fails closed by design -- unset means nobody gets
access, not even the operator who just finished setup -- but with no
explanation anywhere, that reads as a bug rather than the intended
first-run step. Documented in .env.example and a new "Control-panel
access" section on the self-hosting security doc, framed explicitly as
fail-closed-by-design.
- The self-host already ships a working one-click GitHub App setup wizard
(GET /setup, POSTs a manifest to GitHub's own App-creation flow), but the
docs never mentioned it -- only manual App creation was documented, and
its recommended permission set ("Contents: read, add write only if the
self-host should merge") didn't match what the manifest actually requests
(contents: write, unconditionally, since it's needed for update_branch
too, not just merge). An operator following the manual doc as written
could create an App that silently breaks auto-merge with no error
surfaced anywhere. Documented the one-click flow as the recommended path
and corrected the manual instructions to match the manifest exactly, so
the two can't drift apart silently again.
- Added .env.example entries for GITHUB_WEBHOOK_MAX_BODY_BYTES (webhook
body-size guard), BROWSER_WS_ENDPOINT (visual-review browser endpoint),
MCP_READ_REPO_ALLOWLIST / MCP_ACTUATION_REPO_ALLOWLIST (MCP read/write
repo scoping), and AI_DAILY_NEURON_BUDGET (AI spend-budget control) --
each genuinely read at runtime but previously undiscoverable without
reading source.
---
.env.example | 29 +++++++++++
.../routes/docs.self-hosting-github-app.tsx | 48 ++++++++++++++++---
.../src/routes/docs.self-hosting-security.tsx | 19 ++++++++
3 files changed, 90 insertions(+), 6 deletions(-)
diff --git a/.env.example b/.env.example
index 8e53a488b1..f48397b3d8 100644
--- a/.env.example
+++ b/.env.example
@@ -154,6 +154,23 @@ GITTENSORY_REVIEW_DRAFT=false
# # /setup returns 400; with it, /setup needs ?token= (or an
# # x-setup-token / Bearer header) so a freshly-booted, not-yet-configured
# # instance can't be driven through App creation by a random visitor.
+# ADMIN_GITHUB_LOGINS=your-github-login # REQUIRED for control-panel ("operator") access. A comma- or
+# # whitespace-separated allowlist of GitHub logins, case-insensitive.
+# # FAIL-CLOSED: unset/empty means NOBODY can sign into the dashboard,
+# # even the person who set up this instance — this is not a bug, add
+# # your own login here right after first-run setup. Also exempts these
+# # logins from the agent's own-PR auto-close rules (fleet-operator
+# # identity) and lets them bypass per-repo MCP scope.
+# MCP_READ_REPO_ALLOWLIST= # scopes the shared GITTENSORY_MCP_TOKEN identity's READ-only MCP
+# # tools (repo context, issue quality, watch subscriptions) to these
+# # owner/repo entries (comma/whitespace-separated). FAIL-CLOSED:
+# # unset/empty grants no repo access. `*` or `all` is an explicit
+# # escape hatch for full unscoped read access, incl. cross-repo
+# # contributor/operator tools that have no single repo to scope against.
+# MCP_ACTUATION_REPO_ALLOWLIST= # same fail-closed/wildcard model as MCP_READ_REPO_ALLOWLIST, but for
+# # WRITE (merge/close/approve) MCP tools — kept separate so read and
+# # write trust can differ. Unrelated to ADMIN_GITHUB_LOGINS, which
+# # scopes dashboard sign-in, not the shared MCP token's per-repo reach.
# PORT=8787
# DATABASE_PATH=/data/gittensory.sqlite # SQLite file on the mounted data volume; all migrations auto-apply
# POSTGRES_PASSWORD=change-this-long-random-value # used by the --profile postgres / --profile pgbouncer services
@@ -184,8 +201,15 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
# GITHUB_BRANCH_PROTECTION_CACHE_TTL_SECONDS=1200 # TTL for required-status branch protection reads.
# GITHUB_METADATA_CACHE_TTL_SECONDS=600 # TTL for stable repo/user/installation metadata reads.
# GITHUB_COMMIT_CACHE_TTL_SECONDS=900 # TTL for bare /commits/{ref} resolves; dedups the two hourly upstream ref→SHA reads.
+# GITHUB_WEBHOOK_MAX_BODY_BYTES=1048576 # max accepted GitHub webhook body size, in bytes (default 1 MiB).
+# # Larger deliveries are rejected before parsing. Raise only if you
+# # see legitimate webhooks rejected for size (e.g. very large PR diffs
+# # embedded in the payload); most installs never need to touch this.
# QDRANT_URL= # set to http://qdrant:6333 to use Qdrant as the RAG vector store
# # (--profile qdrant). Overrides the built-in sqlite-vec / pgvector.
+# BROWSER_WS_ENDPOINT= # ws:// URL of a browserless/chrome instance for visual-review
+# # screenshot capture. Unset = visual review is fully inert (no
+# # screenshots, no error) — this feature is entirely optional.
# 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.
@@ -324,6 +348,11 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
# # merged decision. single = one reviewer's verdict (auto when 1).
# AI_ON_MERGE=either # synthesis merge rule: either (block if EITHER reviewer flags) |
# # both (block only when both do). Ignored unless AI_COMBINE=synthesis.
+# AI_DAILY_NEURON_BUDGET=10000000 # daily spend cap (Cloudflare Workers AI "neurons") shared by AI
+# # summaries, the free consensus-defect reviewer pair, and the
+# # AI-slop scorer. Default 10,000,000/day; clamped to [0, 10000000].
+# # Only meters FREE Workers-AI calls — BYOK/provider calls above bill
+# # the maintainer's own account and are not counted against this.
# Ollama reviewer (AI_PROVIDER=ollama). Defaults: OLLAMA_AI_BASE_URL=http://localhost:11434/v1,
# OLLAMA_AI_MODEL=llama3.1, no API key. Set the base URL to http://ollama:11434/v1 when using the compose
# --profile ollama service.
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx
index a241debfe1..11e46414ae 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx
@@ -48,18 +48,54 @@ function SelfHostingGithubApp() {
]}
/>
+ One-click App creation (recommended for a Direct App)
+
+ Before the App exists (no GITHUB_APP_ID set yet), the self-host serves a setup
+ wizard at GET /setup. It renders a form that POSTs a GitHub App{" "}
+ manifest — the exact permission and event set below, pre-filled — to GitHub's own
+ App-creation flow. GitHub creates the App with the correct configuration in one step and
+ redirects back to exchange credentials automatically; there is no manual permission
+ checklist to get right or wrong. The route is disabled once an App is configured, so it
+ can't rebind a live install.
+
+
+ "`}
+ />
+
+ Manual App creation (below) is still fully supported — for an air-gapped instance, a
+ stricter change-review process, or simply a preference for reviewing every permission by
+ hand before it exists. Whichever path you take, the resulting App needs the SAME
+ permissions: this doc's manual list is generated from the identical source the wizard's
+ manifest uses, so the two can never drift apart again.
+
+
Direct App permissions
- - Pull requests: read/write.
- - Checks: read/write.
- - Issues: read/write.
- - Contents: read. Add write only if the self-host should merge.
+ - Pull requests: write.
+ -
+ Checks: write — the gate posts a check-run;
checks: read alone 403s that
+ write (silently fails the first review with no obvious cause).
+
+ - Issues: write.
+ -
+ Contents: write — required for BOTH merging and the auto-maintain{" "}
+
update_branch action. contents: read looks sufficient at
+ creation time but silently breaks auto-merge later with no error surfaced in the UI; there
+ is no lesser permission that keeps merge/update-branch working.
+
- Commit statuses: read.
- Metadata: read.
- Events should include pull request, pull request review, push, issues, check suite, check
- run, and status.
+ Events: pull request, pull request review, push, issues, check suite, check run, and status.
Direct App env
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx
index f1f96b91c1..60e9f87854 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx
@@ -70,6 +70,25 @@ function SelfHostingSecurity() {
+ Control-panel access
+
+ GitHub sign-in to the control panel (the maintainer/owner dashboard) is gated by{" "}
+ ADMIN_GITHUB_LOGINS — a comma- or whitespace-separated, case-insensitive
+ allowlist of GitHub logins.
+
+
+
+ Unset or empty means NOBODY gets control-panel access — not even the person who just
+ finished setup. This is intentional, not a bug: add your own GitHub login here right after
+ first-run setup, or you will sign in successfully and see zero privileges with no
+ explanation. The same allowlist also exempts these logins from the agent's own-PR auto-close
+ rules and lets them bypass per-repo MCP scope (MCP_READ_REPO_ALLOWLIST /{" "}
+ MCP_ACTUATION_REPO_ALLOWLIST).
+
+
AI credential boundaries
CLI auth files can be readable by the runtime. Do not mount a prompt-readable Claude Code or
From 2d698c2821822aff912f4f49e6268075b1258fa5 Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Thu, 2 Jul 2026 10:14:46 -0700
Subject: [PATCH 2/3] fix(docs): shorten a .env code-block comment that
overflowed the container
Caught by rendering the page in-browser: the multi-line inline comments on
PUBLIC_API_ORIGIN/SELFHOST_SETUP_TOKEN ran past the CodeBlock's right edge
instead of wrapping or scrolling. Shortened to one line each.
---
.../src/routes/docs.self-hosting-github-app.tsx | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx
index 11e46414ae..98e1035697 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx
@@ -60,10 +60,8 @@ function SelfHostingGithubApp() {
Date: Thu, 2 Jul 2026 13:32:09 -0700
Subject: [PATCH 3/3] fix(docs): make the manifest/docs permission-parity claim
actually enforced
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The self-hosting-github-app doc claimed its manual permission list "is
generated from the identical source" as the setup wizard's manifest, but
nothing backed that — the list was hand-authored JSX with no shared source
or check. Add a unit test that reads buildManifest's real
default_permissions/default_events and asserts the docs page still names
every one of them at the right level, so an edit to one without the other
now fails CI instead of shipping silently wrong setup instructions. Reword
the claim to match what's actually true (kept in sync + CI-checked, not
generated).
Also correct .env.example's GITHUB_WEBHOOK_MAX_BODY_BYTES comment: GitHub
webhooks carry PR/commit metadata, not diffs, so "very large PR diffs" was
a misleading example of what could trip the size cap.
---
.env.example | 5 +-
.../routes/docs.self-hosting-github-app.tsx | 4 +-
test/unit/setup-wizard-docs-parity.test.ts | 52 +++++++++++++++++++
3 files changed, 57 insertions(+), 4 deletions(-)
create mode 100644 test/unit/setup-wizard-docs-parity.test.ts
diff --git a/.env.example b/.env.example
index f48397b3d8..af0b5083c2 100644
--- a/.env.example
+++ b/.env.example
@@ -203,8 +203,9 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
# GITHUB_COMMIT_CACHE_TTL_SECONDS=900 # TTL for bare /commits/{ref} resolves; dedups the two hourly upstream ref→SHA reads.
# GITHUB_WEBHOOK_MAX_BODY_BYTES=1048576 # max accepted GitHub webhook body size, in bytes (default 1 MiB).
# # Larger deliveries are rejected before parsing. Raise only if you
-# # see legitimate webhooks rejected for size (e.g. very large PR diffs
-# # embedded in the payload); most installs never need to touch this.
+# # see legitimate webhooks rejected for size (e.g. a `push` event
+# # with a large `commits` array from a big force-push or merge);
+# # most installs never need to touch this.
# QDRANT_URL= # set to http://qdrant:6333 to use Qdrant as the RAG vector store
# # (--profile qdrant). Overrides the built-in sqlite-vec / pgvector.
# BROWSER_WS_ENDPOINT= # ws:// URL of a browserless/chrome instance for visual-review
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx
index 98e1035697..59f4913ddf 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx
@@ -71,8 +71,8 @@ SELFHOST_SETUP_TOKEN=change-this-long-random-value # unlocks /setup for a fresh
Manual App creation (below) is still fully supported — for an air-gapped instance, a
stricter change-review process, or simply a preference for reviewing every permission by
hand before it exists. Whichever path you take, the resulting App needs the SAME
- permissions: this doc's manual list is generated from the identical source the wizard's
- manifest uses, so the two can never drift apart again.
+ permissions: this doc's manual list is kept in sync with the wizard's manifest and checked
+ in CI, so the two can't silently drift apart.
Direct App permissions
diff --git a/test/unit/setup-wizard-docs-parity.test.ts b/test/unit/setup-wizard-docs-parity.test.ts
new file mode 100644
index 0000000000..b934a509ef
--- /dev/null
+++ b/test/unit/setup-wizard-docs-parity.test.ts
@@ -0,0 +1,52 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+import { buildManifest } from "../../src/selfhost/setup-wizard";
+
+const DOCS_PATH = "apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx";
+
+// docs.self-hosting-github-app.tsx claims its manual permission list "is generated from the identical
+// source the wizard's manifest uses, so the two can never drift apart again" (#2542). That claim is only
+// true if something actually catches drift — this test IS that something: it reads buildManifest's real
+// default_permissions/default_events and asserts the docs page's prose still names every one of them at the
+// right access level, so an editor who changes one without the other fails CI instead of shipping silently
+// wrong setup instructions.
+const PERMISSION_LABELS: Record = {
+ pull_requests: "Pull requests",
+ checks: "Checks",
+ issues: "Issues",
+ contents: "Contents",
+ statuses: "Commit statuses",
+ metadata: "Metadata",
+};
+
+describe("self-host GitHub App manifest <-> docs parity (#2542)", () => {
+ it("the docs page's manual permission list names every buildManifest permission at the correct access level", () => {
+ const manifest = buildManifest("https://example.com", "state") as { default_permissions: Record };
+ const docsSource = readFileSync(DOCS_PATH, "utf8");
+
+ expect(Object.keys(manifest.default_permissions).sort()).toEqual(Object.keys(PERMISSION_LABELS).sort());
+ for (const [key, level] of Object.entries(manifest.default_permissions)) {
+ const label = PERMISSION_LABELS[key];
+ expect(label, `no docs label mapped for manifest permission "${key}" — add one to PERMISSION_LABELS`).toBeDefined();
+ const pattern = new RegExp(`${label}:\\s*${level}\\b`, "i");
+ expect(docsSource, `docs page missing or wrong access level for "${label}" (expected "${level}")`).toMatch(pattern);
+ }
+ });
+
+ it("the docs page's events sentence names every buildManifest default_event, and only those", () => {
+ // Scoped to the "Events: ...." sentence specifically — a bare substring search (e.g. for "status")
+ // would false-pass by matching inside unrelated text like "Commit statuses" elsewhere on the page.
+ const manifest = buildManifest("https://example.com", "state") as { default_events: string[] };
+ const docsSource = readFileSync(DOCS_PATH, "utf8");
+ const sentenceMatch = /Events:\s*([^.]+)\./.exec(docsSource);
+ expect(sentenceMatch, "docs page has no \"Events: ...\" sentence to check").not.toBeNull();
+ const eventsSentence = sentenceMatch![1]!;
+ const docsEvents = eventsSentence
+ .split(",")
+ .map((s) => s.replace(/\band\b/i, "").trim().toLowerCase())
+ .filter(Boolean);
+
+ const manifestEvents = manifest.default_events.map((e) => e.replace(/_/g, " "));
+ expect(docsEvents.sort()).toEqual(manifestEvents.sort());
+ });
+});