From 11a58e3401c0fbfb6329c2a3a905cb8f2338f39a Mon Sep 17 00:00:00 2001 From: Hannu Teulahti Date: Fri, 17 Jul 2026 20:43:27 +0300 Subject: [PATCH] fix(server): keep sync enabled for token-less self-hosted forges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The push-permission probe presumes any host not in KNOWN_NON_GITHUB_GIT_HOSTS is GitHub or GHES, because GHES hostnames are arbitrary. When no token resolved for such a host, runProbe short-circuited to `denied` with no network call, so SyncEngine paused with 'no-push-permission'. For a self-hosted Gitea/Forgejo origin pushed over SSH this is wrong — the user can push fine — and it silently disabled auto-sync (regression from #597, which stopped ignoring non-github.com hosts). Only treat an anonymous (no-token) probe as `denied` for github.com; for any other host return `unknown/host-unverified` so callers stay lenient and a real error surfaces only if the actual push fails. github.com and token-bearing GHES behaviour are unchanged. Adds a regression test asserting the anonymous non-github.com path returns unknown with no HTTP call, and threads the new `host-unverified` code through the wire schema and status unions. --- .changeset/self-hosted-remote-sync.md | 5 +++ packages/core/src/schemas/api/sync-seed.ts | 9 +++- .../server/src/github-permissions.test.ts | 21 ++++++++++ packages/server/src/github-permissions.ts | 42 ++++++++++++++----- packages/server/src/sync-engine.ts | 8 +++- 5 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 .changeset/self-hosted-remote-sync.md diff --git a/.changeset/self-hosted-remote-sync.md b/.changeset/self-hosted-remote-sync.md new file mode 100644 index 000000000..ae1f4d2fd --- /dev/null +++ b/.changeset/self-hosted-remote-sync.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": patch +--- + +Auto-sync is no longer silently disabled for projects whose `origin` is a self-hosted Git forge (Gitea, Forgejo, …). The push-permission probe presumes any host that is not a known non-GitHub forge is GitHub or GitHub Enterprise Server, since GHES hostnames are arbitrary. When no GitHub token could be resolved for such a host, the probe previously returned `denied` without any network call, which paused sync with "You don't have permission to push to this repo" — even though the user pushes fine over SSH. The probe now only treats an anonymous (no-token) result as `denied` for `github.com`; for any other host it returns `unknown`, so sync stays enabled and the real push attempt surfaces a genuine error only if it actually fails. Behavior for `github.com` and for GHES hosts with a resolvable token is unchanged. diff --git a/packages/core/src/schemas/api/sync-seed.ts b/packages/core/src/schemas/api/sync-seed.ts index 1af19f7ad..ce747e1b7 100644 --- a/packages/core/src/schemas/api/sync-seed.ts +++ b/packages/core/src/schemas/api/sync-seed.ts @@ -73,7 +73,14 @@ export const PushPermissionSchema = z.discriminatedUnion('checkStatus', [ .object({ checkStatus: z.literal('unknown'), unknownError: z - .enum(['network', 'timeout', 'rate-limit', 'token-invalid', 'malformed-response']) + .enum([ + 'network', + 'timeout', + 'rate-limit', + 'token-invalid', + 'malformed-response', + 'host-unverified', + ]) .optional(), }) .loose(), diff --git a/packages/server/src/github-permissions.test.ts b/packages/server/src/github-permissions.test.ts index 564ad482c..2a70617ed 100644 --- a/packages/server/src/github-permissions.test.ts +++ b/packages/server/src/github-permissions.test.ts @@ -299,6 +299,27 @@ describe('checkPushPermission — token resolution', () => { expect(calls).toHaveLength(0); }); + test('anonymous on a non-github.com host → unknown/host-unverified with NO HTTP call', async () => { + // Self-hosted Gitea/Forgejo (arbitrary hostname, indistinguishable from a + // GHES host) with no resolvable gh/stored token. We cannot assert the user + // can't push — they may well push over SSH with a key that never involves + // an HTTP token. Denying here would hard-pause auto-sync for every + // self-hosted-forge user. `unknown` keeps callers lenient and lets the real + // push attempt surface any real error. + const { fetch, calls } = mockFetch(() => jsonResponse(200, {})); + const { store } = fakeStore(null); + const result = await checkPushPermission({ + owner: 'example-org', + repo: 'notes', + host: 'git.example.com', + detectGh: ghUnavailable(), + tokenStore: store, + _fetchFn: fetch, + }); + expect(result).toEqual({ kind: 'unknown', error: 'host-unverified' }); + expect(calls).toHaveLength(0); // still no network call — the short-circuit holds + }); + test('gh detection is scoped to the requested host', async () => { const seenHosts: Array = []; const detectGh: DetectGhFn = (host) => { diff --git a/packages/server/src/github-permissions.ts b/packages/server/src/github-permissions.ts index e570620fe..ef7faead4 100644 --- a/packages/server/src/github-permissions.ts +++ b/packages/server/src/github-permissions.ts @@ -33,7 +33,11 @@ type PushPermissionUnknownError = | 'timeout' | 'rate-limit' | 'token-invalid' - | 'malformed-response'; + | 'malformed-response' + // No credential resolved for a non-github.com host we cannot verify is + // GitHub (a GHES host or a self-hosted forge like Gitea/Forgejo). Push + // permission is genuinely unknown — see the anonymous branch in `runProbe`. + | 'host-unverified'; /** * Outcome of a single push-permission probe. @@ -211,15 +215,33 @@ async function runProbe(opts: CheckPushPermissionOptions): Promise