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()); + }); +});